From a777c9a8b349611ae25bf0a02bb275d9a4541d67 Mon Sep 17 00:00:00 2001 From: Inhji Date: Tue, 4 Jul 2023 22:46:00 +0200 Subject: [PATCH] include internal link rendering --- lib/chiya_web/markdown.ex | 64 ++++++++++++++++++++--- lib/chiya_web/references.ex | 89 ++++++++++++++++++++++++++++++++ test/chiya_web/markdown_test.exs | 25 +++++++++ 3 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 lib/chiya_web/references.ex create mode 100644 test/chiya_web/markdown_test.exs diff --git a/lib/chiya_web/markdown.ex b/lib/chiya_web/markdown.ex index 030cd81..c2c0cac 100644 --- a/lib/chiya_web/markdown.ex +++ b/lib/chiya_web/markdown.ex @@ -1,13 +1,63 @@ defmodule ChiyaWeb.Markdown do - @options [ - footnotes: true, - breaks: true, - escape: true, - wikilinks: true - ] + @moduledoc """ + Module used for rendering markdown. + Rendering also collects and replaces internal [references](`Mirage.References`) to other entities like Lists and Tags. + """ + + import Ecto.Changeset, only: [get_change: 2, put_change: 3] + + defp options() do + %Earmark.Options{ + code_class_prefix: "lang- language-", + footnotes: true, + breaks: true, + escape: false, + registered_processors: processors() + } + end + + @doc """ + Renders markdown with Earmark. + """ def render(markdown) do markdown - |> Earmark.as_html!(@options) + |> ChiyaWeb.References.replace_references() + |> Earmark.as_html!(options()) + end + + @doc """ + Renders markdown to HTML inside a changeset if markdown changed. + Markdown field and HTML field can be configured. + """ + def maybe_render(changeset, markdown_field, html_field) do + if markdown = get_change(changeset, markdown_field) do + html = render(markdown) + put_change(changeset, html_field, html) + else + changeset + end + end + + def processors() do + heading = fn + {_, _, [content], _} = n when is_binary(content) -> + slug = Slugger.slugify_downcase(content) + Earmark.AstTools.merge_atts_in_node(n, id: slug) + + node -> + node + end + + [ + Earmark.TagSpecificProcessors.new([ + {"h1", heading}, + {"h2", heading}, + {"h3", heading}, + {"h4", heading}, + {"h5", heading}, + {"h6", heading} + ]) + ] end end diff --git a/lib/chiya_web/references.ex b/lib/chiya_web/references.ex new file mode 100644 index 0000000..e24faea --- /dev/null +++ b/lib/chiya_web/references.ex @@ -0,0 +1,89 @@ +defmodule ChiyaWeb.References do + @moduledoc """ + Finds and replaces references to entities in a string. + + A reference is an internal link like the following examples: + + * `[[sustainablity]]` -> A note named *Sustainability* + * `[[a-long-unfitting-slug|A simple title]]` -> A note named *A long unfitting title* + """ + + @reference_regex ~r/\[\[?(?[\d\w-]+)(?:\|(?[\w\d\s'`äöüß]+))?\]\]/ + + @doc """ + Returns a list of references in `string`. + """ + def get_references(nil), do: [] + + def get_references(string) do + @reference_regex + |> Regex.scan(string, capture: :all) + |> Enum.map(&map_to_tuple/1) + end + + @doc """ + Checks each reference returned from `get_references/1` and validates its existence. + """ + def validate_references(references) do + Enum.map(references, fn {placeholder, slug, custom_title} -> + note = Chiya.Notes.get_note_by_slug_preloaded(slug) + + valid = + case note do + nil -> false + _ -> true + end + + # If a custom title was defined, use it, + # otherwise use the note's title + title = + cond do + custom_title != slug -> + custom_title + + valid -> + note.name + + true -> + slug + end + + {placeholder, slug, title, valid} + end) + end + + @doc """ + Returns a list of slugs that are referenced in `string`, optionally filtering by `filter_type`. + """ + def get_reference_ids(string) do + string + |> get_references() + |> Enum.map(fn {_, note_slug, _} -> note_slug end) + end + + @doc """ + Finds and replaces references with the matching url in `string`. + """ + def replace_references(string) do + string + |> get_references() + |> validate_references() + |> Enum.reduce(string, fn {placeholder, slug, title, valid}, s -> + String.replace(s, placeholder, get_link(slug, title, valid)) + end) + end + + defp get_link(slug, title, valid) do + "[#{title}](/note/#{slug})#{get_link_class(valid)}" + end + + defp get_link_class(valid) do + if valid, do: "{:.invalid}", else: "" + end + + defp map_to_tuple([placeholder, note_slug]), + do: {placeholder, note_slug, note_slug} + + defp map_to_tuple([placeholder, note_slug, title]), + do: {placeholder, note_slug, title} +end diff --git a/test/chiya_web/markdown_test.exs b/test/chiya_web/markdown_test.exs new file mode 100644 index 0000000..bcb49fb --- /dev/null +++ b/test/chiya_web/markdown_test.exs @@ -0,0 +1,25 @@ +defmodule ChiyaWeb.MarkdownTest do + use Chiya.DataCase + + alias ChiyaWeb.Markdown + + describe "render/1" do + test "renders simple markdown" do + html = Markdown.render("# Title") + assert html =~ "id=\"title\"" + assert html =~ "Title" + assert html =~ "</h1>" + end + + test "renders a link to a note" do + html = Markdown.render("[[foo]]") + assert html =~ "/note/foo" + end + + test "renders a link to a note with custom title" do + html = Markdown.render("[[foo|MyFoo]]") + assert html =~ "/note/foo" + assert html =~ "MyFoo" + end + end +end \ No newline at end of file