From f4ca80c09418754f780be0aff7015513171432eb Mon Sep 17 00:00:00 2001 From: radu raduta Date: Sun, 26 Jun 2022 22:49:10 +0000 Subject: [PATCH 1/2] live_school: conversion tool for markdown lessons to interactive LiveView notebooks --- lib/live_school.ex | 128 +++++++++++++++++++++++++++++++++++++++++ lib/live_school/cli.ex | 69 ++++++++++++++++++++++ liveschool.ex | 3 + 3 files changed, 200 insertions(+) create mode 100644 lib/live_school.ex create mode 100644 lib/live_school/cli.ex create mode 100644 liveschool.ex diff --git a/lib/live_school.ex b/lib/live_school.ex new file mode 100644 index 00000000..bd4c6f4a --- /dev/null +++ b/lib/live_school.ex @@ -0,0 +1,128 @@ +#!/usr/bin/env elixir + +defmodule LiveSchool do + @moduledoc """ + LiveSchool converts school lessons to LiveBook notebooks! + + """ + + + def rewrite_expre(code) do + is_iex = fn x -> String.match?(x, ~r/^iex>/) end + write_cells = fn code -> "\n```elixir\n" <> code <> "```\n" end + strip_iex = fn x -> String.slice(x, 4..-1//1) end + + if Enum.any?(code, is_iex) do + code + |> Enum.filter(is_iex) + |> Enum.map(strip_iex) + |> Enum.map(write_cells) + |> Enum.join() + else + "\n```elixir\n" <> Enum.join(code) <> "```\n" + 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 + info = code |> Enum.join("") |> safer_eval # nee Code.eval_string() + 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 + diff --git a/lib/live_school/cli.ex b/lib/live_school/cli.ex new file mode 100644 index 00000000..0b8a325a --- /dev/null +++ b/lib/live_school/cli.ex @@ -0,0 +1,69 @@ +#!/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 + + diff --git a/liveschool.ex b/liveschool.ex new file mode 100644 index 00000000..7fbf968e --- /dev/null +++ b/liveschool.ex @@ -0,0 +1,3 @@ +#!/usr/bin/env elixir + +LiveSchool.Cli.main(System.argv) From e92fa345d0e6121de5b6889ab4f6e68e72022846 Mon Sep 17 00:00:00 2001 From: radu raduta Date: Mon, 27 Jun 2022 00:21:32 +0000 Subject: [PATCH 2/2] lint --- lib/live_school.ex | 101 ++++++++++++++++++++++------------------- lib/live_school/cli.ex | 31 +++++++------ liveschool.ex | 2 +- 3 files changed, 72 insertions(+), 62 deletions(-) diff --git a/lib/live_school.ex b/lib/live_school.ex index bd4c6f4a..07bbbd40 100644 --- a/lib/live_school.ex +++ b/lib/live_school.ex @@ -6,39 +6,43 @@ defmodule LiveSchool do """ - def rewrite_expre(code) do - is_iex = fn x -> String.match?(x, ~r/^iex>/) end - write_cells = fn code -> "\n```elixir\n" <> code <> "```\n" end - strip_iex = fn x -> String.slice(x, 4..-1//1) end + wrap_code = fn x -> "\n```elixir\n" <> x <> "```\n" end + + match_to_cell = fn + [code | _] -> wrap_code.(code) + _ -> nil + end - if Enum.any?(code, is_iex) do + res = code - |> Enum.filter(is_iex) - |> Enum.map(strip_iex) - |> Enum.map(write_cells) - |> Enum.join() + |> 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 - "\n```elixir\n" <> Enum.join(code) <> "```\n" + 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) + 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] @@ -47,12 +51,12 @@ defmodule LiveSchool do end defp safer_eval(danger) do - {value, _} = - danger - |> Code.string_to_quoted!() - |> local_cats_only - |> kernel_only - |> Code.eval_quoted + {value, _} = + danger + |> Code.string_to_quoted!() + |> local_cats_only + |> kernel_only + |> Code.eval_quoted() value end @@ -70,15 +74,18 @@ defmodule LiveSchool do def rewrite_title(content) do case split_at(content, "---") do - {code, rest} -> - if String.starts_with?(hd(code), "%{") do - info = code |> Enum.join("") |> safer_eval # nee Code.eval_string() - title = Map.get(info, :title, "Untitled") |> IO.inspect - ["# " <> title <> "\n" | rest] - else - [ code, "---\n" , rest ] - end - single -> single + {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 @@ -98,6 +105,7 @@ defmodule LiveSchool do |> Stream.map(fn [pre, ["```elixir\n"], content, ["```\n"]] -> [pre, rewrite_expre(content)] + rest -> rest end) @@ -112,12 +120,14 @@ defmodule LiveSchool do 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) + 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) @@ -125,4 +135,3 @@ defmodule LiveSchool do {pass, count} end end - diff --git a/lib/live_school/cli.ex b/lib/live_school/cli.ex index 0b8a325a..030598a4 100644 --- a/lib/live_school/cli.ex +++ b/lib/live_school/cli.ex @@ -1,7 +1,6 @@ #!/usr/bin/env elixir defmodule LiveSchool.Cli do - @moduledoc """ synopsis: Convert elixir lessons to LiveView notebooks. @@ -18,9 +17,11 @@ defmodule LiveSchool.Cli do 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) @@ -31,8 +32,8 @@ defmodule LiveSchool.Cli do defp parse_args(args) do {opts, cmd_and_args, errors} = args - |> OptionParser.parse(strict: - [help: :boolean, file: :string, path: :string]) + |> OptionParser.parse(strict: [help: :boolean, file: :string, path: :string]) + {opts, cmd_and_args, errors} end @@ -41,29 +42,29 @@ defmodule LiveSchool.Cli do 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"} + !(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)) + + if File.regular?(file) do + IO.write(LiveSchool.reschool_file(file)) else - IO.puts("Regular file not found: " <> file) + 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") + + if File.dir?(path) do + {success, total} = LiveSchool.reschool_path!(path) + IO.puts("#{success} / #{total} successfully converted") else - IO.puts("Directory not found: " <> path) + IO.puts("Directory not found: " <> path) end end end end - end - - diff --git a/liveschool.ex b/liveschool.ex index 7fbf968e..3cf6054c 100644 --- a/liveschool.ex +++ b/liveschool.ex @@ -1,3 +1,3 @@ #!/usr/bin/env elixir -LiveSchool.Cli.main(System.argv) +LiveSchool.Cli.main(System.argv())