Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

live_school: conversion tool for lessons to interactive LiveView notebooks #196

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions lib/live_school.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env elixir

defmodule LiveSchool do
@moduledoc """
LiveSchool converts school lessons to LiveBook notebooks!

"""

def rewrite_expre(code) do
wrap_code = fn x -> "\n```elixir\n" <> x <> "```\n" end

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some benefit to establishing anonymous functions for wrap_code/1 and match_to_cell/1 over defining them as functions in this module?


match_to_cell = fn
[code | _] -> wrap_code.(code)
_ -> nil
end

res =
code
|> Enum.map(&Regex.run(~r/^iex>(.*\n)$/, &1, capture: :all_but_first))
|> Enum.reject(&is_nil/1)
|> Enum.map(match_to_cell)

if Enum.any?(res) do
res |> Enum.join()
else
code |> Enum.join() |> wrap_code.()
end
end

defp local_cats_only(ast) do
ast
|> Macro.prewalk(fn
danger = {{:., _, _}, _, _} ->
IO.puts("warning: removed non local call to #inspect(danger)}")
nil

{:eval, _, args} when is_list(args) ->
IO.puts("warning: removed call to eval")
nil

code ->
code
end)
end

defp kernel_only(ast) do
quote do
import Kernel, only: [sigil_D: 2]
unquote(ast)
end
end

defp safer_eval(danger) do
{value, _} =
danger
|> Code.string_to_quoted!()
|> local_cats_only
|> kernel_only
|> Code.eval_quoted()

value
end

def split_at(array, string) do
loc = Enum.find_index(array, fn x -> String.equivalent?(String.trim(x), string) end)

if is_nil(loc) do
array
else
{head, [_sep | tail]} = Enum.split(array, loc)
{head, tail}
end
end

def rewrite_title(content) do
case split_at(content, "---") do
{code, rest} ->
if String.starts_with?(hd(code), "%{") do
# nee Code.eval_string()
info = code |> Enum.join("") |> safer_eval
title = Map.get(info, :title, "Untitled") |> IO.inspect()
["# " <> title <> "\n" | rest]
else
[code, "---\n", rest]
end

single ->
single
end
end

def reschool(pid) when is_pid(pid) do
pid |> IO.stream(:line) |> reschool()
end

def reschool(content) when is_struct(content, File.Stream) do
content |> Enum.to_list() |> reschool()
end

def reschool(content) when is_list(content) do
content
|> rewrite_title
|> Stream.chunk_by(fn x -> String.match?(x, ~r/^```/) end)
|> Stream.chunk_every(4)
|> Stream.map(fn
[pre, ["```elixir\n"], content, ["```\n"]] ->
[pre, rewrite_expre(content)]

rest ->
rest
end)
|> Enum.join()
end

def reschool_file(filename) when is_binary(filename) do
stream = File.stream!(filename, [:utf8])
stream |> reschool()
end

def reschool_path!(path) when is_binary(path) do
files = Path.wildcard(path <> "/**/*\.md")

res =
files
|> Enum.map(fn filename ->
newname = String.trim(filename, ".md") <> ".livemd"
IO.write(newname <> " --> ")
newcontent = reschool_file(filename)
:ok = File.write(newname, newcontent)
end)

pass = Enum.count(res, fn x -> x == :ok end)
count = Enum.count(res)

{pass, count}
end
end
70 changes: 70 additions & 0 deletions lib/live_school/cli.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env elixir

defmodule LiveSchool.Cli do
@moduledoc """
synopsis:
Convert elixir lessons to LiveView notebooks.
usage:
$ live_school {options} location
options:
--path Convert an entire path containing .md files, writes .livemd files
--file Convet single file to stdout
"""

def main([help_opt]) when help_opt == "-h" or help_opt == "--help" do
IO.puts(@moduledoc)
end

def main(args) do
{opts, cmd_and_args, errors} = parse_args(args)

case errors do
[] ->
process_args(opts, cmd_and_args)

_ ->
IO.puts("Bad option:")
IO.inspect(errors)
IO.puts(@moduledoc)
end
end

defp parse_args(args) do
{opts, cmd_and_args, errors} =
args
|> OptionParser.parse(strict: [help: :boolean, file: :string, path: :string])

{opts, cmd_and_args, errors}
end

defp process_args(opts, _args) do
path_spec = Keyword.has_key?(opts, :path)
file_spec = Keyword.has_key?(opts, :file)

if (file_spec and path_spec) or
!(file_spec or path_spec) do
{nil, nil, "Must specify either a path or a file"}
else
cond do
file_spec ->
file = opts[:file]

if File.regular?(file) do
IO.write(LiveSchool.reschool_file(file))
else
IO.puts("Regular file not found: " <> file)
end

path_spec ->
path = opts[:path]

if File.dir?(path) do
{success, total} = LiveSchool.reschool_path!(path)
IO.puts("#{success} / #{total} successfully converted")
else
IO.puts("Directory not found: " <> path)
end
end
end
end
end
3 changes: 3 additions & 0 deletions liveschool.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env elixir

LiveSchool.Cli.main(System.argv())
Copy link

@SophieDeBenedetto SophieDeBenedetto Jul 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not totally sure of the intended usage of this tool but based on this file I'm assuming its a CLI script. Any reason not to make it executable via an escript?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd think we'd want to convert all of the files (or a list of them), so a mix task to run this cli module would probably be the best bet.