From fe481037af03d8312ac41174afa8010615ce0dbe Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Thu, 30 Apr 2026 17:07:38 +0930 Subject: [PATCH] mermaid export --- .drafts/mermaid-export.md | 132 +++++++++++++++++++++++++ artefact/lib/artefact/mermaid.ex | 135 ++++++++++++++++++++++++++ artefact/test/artefact_test.exs | 130 +++++++++++++++++++++++++ artefact/test/data/us_two/mermaid.mmd | 8 ++ artefact_kino/artefact_kino.livemd | 4 +- artefact_kino/lib/artefact_kino.ex | 16 +-- 6 files changed, 418 insertions(+), 7 deletions(-) create mode 100644 .drafts/mermaid-export.md create mode 100644 artefact/lib/artefact/mermaid.ex create mode 100644 artefact/test/data/us_two/mermaid.mmd diff --git a/.drafts/mermaid-export.md b/.drafts/mermaid-export.md new file mode 100644 index 0000000..13cfd6b --- /dev/null +++ b/.drafts/mermaid-export.md @@ -0,0 +1,132 @@ + + +# Drafts — Mermaid export for Artefact + ArtefactKino + +These are drafts only. Per `.claude/settings.json` no `git commit / push / add` +was run; per AGENTS.md the work belongs to the relation, not to the agent. +Review, edit, and use as you see fit. + +--- + +## Draft commit message — `artefact` + +``` +feat(artefact): add Mermaid export + +Artefact.Mermaid.export/2 emits Mermaid `graph` source from an +%Artefact{}, sitting alongside Artefact.Cypher and Artefact.Arrows +as a third derived form. + +- legacy `graph` syntax for broad renderer reach (GitHub, Notion, + mdBook, Livebook) +- nodes render as circles (`id(("..."))`) — property-graph + convention, matches the vis-network ellipses in the heartside + panel +- node label mirrors the ArtefactKino vis-network panel: + name (or id) on top, semantic labels joined with a space below, + separated by
+- base_label is dropped from per-node labels at output time, + consistent with the Cypher exporter's effective_labels rule +- artefact.title becomes Mermaid front-matter `title:` plus an + `accTitle:` line for screen readers; nil title omits both +- :direction option (:LR default, :RL :TB :BT :TD) +- escape rules: + * double quote in node label -> " + * pipe in edge label -> | + * YAML-unsafe chars in title -> double-quoted scalar with \" + and \\ escaped + +Lossy: position, style, and properties beyond `name` are not +represented — Mermaid is a render concern, not a persistence form. + +Fixture added at test/data/us_two/mermaid.mmd; ExUnit cases cover +the us_two round-trip, direction option, both escapes, the empty +graph, and the no-name fallback to node id. +``` + +## Draft commit message — `artefact_kino` + +``` +feat(artefact_kino): MERMAID button on the export panel + +Adds Artefact.Mermaid.export/1 alongside CREATE / MERGE / JSON +in the export panel of the three-panel widget. Pure text output +for now — copy with click-to-select, same as the existing buttons. + +Live Mermaid rendering via mermaid.js was discussed but deferred — +keeping the panel symmetric with the other text exports for this +pass. +``` + +--- + +## Draft issue — *Live Mermaid rendering in ArtefactKino* + +**Title:** `artefact_kino: render Mermaid live in the export panel` + +**Body:** + +> Today the MERMAID button shows the source as text, the same as +> CREATE / MERGE / JSON. Useful for copying out, less useful as a +> second view of the graph next to the vis-network panel. +> +> A follow-up would load mermaid.js from CDN (matching the existing +> vis-network bootstrap) and render the diagram inside the export +> panel, with a small toggle to flip back to source view. +> +> ### Why it might be worth doing +> +> - vis-network is force-directed; Mermaid layouts are deterministic. +> Two renderings of the same artefact, side by side, can show +> structure that one alone does not. +> - Mermaid is what most readers paste into a doc. Seeing it render +> the same way it will appear in the doc closes a feedback loop. +> +> ### Why we deferred it +> +> - The current panel is symmetric across CREATE / MERGE / JSON / MERMAID +> as text. Adding a render mode for one of them breaks that symmetry. +> - mermaid.js is a heavier CDN load than vis-network. Worth measuring +> before adding. +> - The vis-network panel already does live rendering — the question is +> whether a second live view earns its keep. +> +> ### Sketch +> +> - keep the `MERMAID` button +> - add a small `source / rendered` switch that only appears when +> MERMAID is selected +> - bootstrap mermaid.js the same way `loadVis()` bootstraps vis-network +> - render into a `
` swapped in for the `
`
+
+---
+
+## Draft issue — *Mermaid fixtures for the remaining test data sets*
+
+**Title:** `artefact: add mermaid.mmd fixtures for artefact_*, artefactory, lexical_categories, create_merge`
+
+**Body:**
+
+> `test/data/us_two/mermaid.mmd` is in. The other fixture folders
+> (`artefact`, `artefact_combine`, `artefact_harmonise`, `artefactory`,
+> `lexical_categories`, `create_merge`) all have `arrows.json` plus
+> Cypher fixtures but no Mermaid one yet.
+>
+> Either:
+> 1. Generate fixtures by running `Artefact.Mermaid.export/1` once
+>    against each, eyeball the output, commit. (Risk: locks in
+>    whatever the implementation does today.)
+> 2. Hand-author each one, then assert the export matches. (Slower,
+>    but each fixture acts as a spec for what the diagram should
+>    say to a reader.)
+>
+> Recommendation: option 2 for `artefact` and `artefactory` (the
+> self-describing artefacts — the fixture *is* the documentation),
+> option 1 for the rest.
+
+---
+
+*The artefact belongs to the edge.*
diff --git a/artefact/lib/artefact/mermaid.ex b/artefact/lib/artefact/mermaid.ex
new file mode 100644
index 0000000..42a049f
--- /dev/null
+++ b/artefact/lib/artefact/mermaid.ex
@@ -0,0 +1,135 @@
+# SPDX-FileCopyrightText: 2026 artefactory contributors 
+# SPDX-License-Identifier: MIT
+
+defmodule Artefact.Mermaid do
+  @moduledoc """
+  Derives Mermaid diagram source from an `%Artefact{}`.
+
+  Uses the legacy `graph` syntax for broad renderer compatibility (GitHub,
+  Notion, mdBook, Livebook). Nodes render as circles (`id(("..."))`) — the
+  property-graph convention, and a closer match to the vis-network ellipses in
+  the `ArtefactKino` heartside panel. Labels mirror that panel: the `name`
+  property (or node id, when no name is set) on top, semantic labels joined
+  with a space below, separated by `
`. + + When `artefact.title` is set, a YAML front-matter block carries the title + (rendered as a heading by Mermaid 9.4+) and an `accTitle:` line is emitted + for screen readers. A nil title produces neither — the export starts at + `graph `. + + Lossy: `position`, `style`, properties beyond `name`, and the artefact-level + `base_label` (collapsed into per-node labels at output time) are not represented. + """ + + @directions ~w(LR RL TB BT TD)a + + @doc """ + Emit a Mermaid source string for the artefact. + + ## Options + + * `:direction` — flow direction. One of `:LR`, `:RL`, `:TB`, `:BT`, `:TD`. + Defaults to `:LR`. + + ## Example + + For the `us_two` artefact: + + --- + title: UsTwo + --- + graph LR + accTitle: UsTwo + n0(("Matt
Agent Me")) + n1(("Claude
Agent You")) + n0 -->|US_TWO| n1 + + """ + def export(%Artefact{title: title, base_label: base_label, graph: graph}, opts \\ []) do + direction = Keyword.get(opts, :direction, :LR) + + unless direction in @directions do + raise ArgumentError, + "invalid :direction #{inspect(direction)} — expected one of #{inspect(@directions)}" + end + + node_lines = Enum.map(graph.nodes, &node_line(&1, base_label)) + rel_lines = Enum.map(graph.relationships, &rel_line/1) + + body = ["graph #{direction}" | acc_title_lines(title) ++ node_lines ++ rel_lines] + + Enum.join(front_matter(title) ++ body, "\n") + end + + # -- front-matter & accessibility -- + + defp front_matter(nil), do: [] + defp front_matter(title), do: ["---", "title: #{yaml_scalar(title)}", "---"] + + defp acc_title_lines(nil), do: [] + defp acc_title_lines(title), do: [" accTitle: #{single_line(title)}"] + + # YAML plain scalar where safe; double-quoted (with `"` and `\` escaped) + # when the value contains characters that break a bare scalar. + defp yaml_scalar(value) do + s = to_string(value) + + if needs_yaml_quoting?(s) do + escaped = + s + |> String.replace("\\", "\\\\") + |> String.replace("\"", "\\\"") + + "\"#{escaped}\"" + else + s + end + end + + defp needs_yaml_quoting?(""), do: true + + defp needs_yaml_quoting?(s) do + String.contains?(s, [":", "\"", "#", "\n"]) or + String.starts_with?(s, [" ", "\t", "&", "*", "!", "?", "{", "[", "|", ">", "%", "@", "`", "'", "-"]) + end + + # accTitle / accDescr inline form is single-line; collapse any newlines to spaces. + defp single_line(value) do + value + |> to_string() + |> String.replace(~r/\s*\n\s*/, " ") + end + + defp node_line(%Artefact.Node{} = node, base_label) do + name = Map.get(node.properties, "name", node.id) + semantic = Enum.reject(node.labels, &(&1 == base_label)) + + label_text = + case semantic do + [] -> escape(name) + labels -> "#{escape(name)}
#{escape(Enum.join(labels, " "))}" + end + + " #{node.id}((\"#{label_text}\"))" + end + + defp rel_line(%Artefact.Relationship{type: type, from_id: from, to_id: to}) do + " #{from} -->|#{escape_pipe(type)}| #{to}" + end + + # Mermaid node label text inside `(("..."))` — escape double quotes only; + # `
` is rendered as a line break, which is what we want. + defp escape(value) do + value + |> to_string() + |> String.replace("\"", """) + end + + # Edge labels live between pipes, so the pipe itself must be entity-encoded. + defp escape_pipe(value) do + value + |> to_string() + |> String.replace("|", "|") + |> String.replace("\"", """) + end +end diff --git a/artefact/test/artefact_test.exs b/artefact/test/artefact_test.exs index 84e5d4f..aba9140 100644 --- a/artefact/test/artefact_test.exs +++ b/artefact/test/artefact_test.exs @@ -752,4 +752,134 @@ defmodule ArtefactTest do assert Artefact.Cypher.merge(a) == expected end end + + describe "Artefact.Mermaid.export/2 — us_two" do + setup do + json = File.read!(Path.join([@fixtures, "us_two", "arrows.json"])) + %{artefact: Artefact.Arrows.from_json!(json)} + end + + test "matches fixture", %{artefact: a} do + expected = File.read!(Path.join([@fixtures, "us_two", "mermaid.mmd"])) |> String.trim() + assert Artefact.Mermaid.export(a) == expected + end + + test "uses `graph LR` by default", %{artefact: a} do + assert String.contains?(Artefact.Mermaid.export(a), "\ngraph LR\n") + end + + test "respects :direction option", %{artefact: a} do + assert String.contains?(Artefact.Mermaid.export(a, direction: :TB), "\ngraph TB\n") + end + + test "emits front-matter title from artefact.title", %{artefact: a} do + assert String.starts_with?(Artefact.Mermaid.export(a), "---\ntitle: UsTwo\n---\n") + end + + test "emits accTitle mirroring the title", %{artefact: a} do + assert String.contains?(Artefact.Mermaid.export(a), " accTitle: UsTwo\n") + end + + test "raises on unknown direction", %{artefact: a} do + assert_raise ArgumentError, ~r/invalid :direction/, fn -> + Artefact.Mermaid.export(a, direction: :sideways) + end + end + + test "renders node label as name + semantic labels in circle nodes", %{artefact: a} do + mmd = Artefact.Mermaid.export(a) + assert String.contains?(mmd, ~s|n0(("Matt
Agent Me"))|) + assert String.contains?(mmd, ~s|n1(("Claude
Agent You"))|) + end + + test "drops base_label from per-node labels", %{artefact: a} do + mmd = Artefact.Mermaid.export(a) + refute String.contains?(mmd, "UsTwo|US_TWO| n1") + end + end + + describe "Artefact.Mermaid.export/2 — escapes and edge cases" do + test "falls back to node id when no name property is present" do + a = + Artefact.new( + base_label: "Bare", + nodes: [n: [labels: ["X"]]], + relationships: [] + ) + + assert String.contains?(Artefact.Mermaid.export(a), ~s|n0(("n0
X"))|) + end + + test "uses name only when no semantic labels remain" do + a = + Artefact.new( + base_label: "Solo", + nodes: [n: [labels: ["Solo"], properties: %{"name" => "alone"}]], + relationships: [] + ) + + mmd = Artefact.Mermaid.export(a) + assert String.contains?(mmd, ~s|n0(("alone"))|) + refute String.contains?(mmd, "
") + end + + test "escapes double quotes in node names" do + a = + Artefact.new( + nodes: [q: [labels: [], properties: %{"name" => ~s|she said "hi"|}]], + relationships: [] + ) + + assert String.contains?(Artefact.Mermaid.export(a), ~s|n0(("she said "hi""))|) + end + + test "escapes pipes in relationship type" do + a = + Artefact.new( + nodes: [a: [labels: []], b: [labels: []]], + relationships: [[from: :a, type: "HAS|PIPE", to: :b]] + ) + + assert String.contains?(Artefact.Mermaid.export(a), "-->|HAS|PIPE|") + end + + test "empty untitled graph still emits a header" do + a = Artefact.new(title: nil, nodes: [], relationships: []) + assert Artefact.Mermaid.export(a) == "graph LR" + end + + test "untitled artefact omits front-matter and accTitle" do + a = + Artefact.new( + title: nil, + nodes: [n: [labels: ["X"]]], + relationships: [] + ) + + mmd = Artefact.Mermaid.export(a) + refute String.contains?(mmd, "---") + refute String.contains?(mmd, "accTitle") + assert String.starts_with?(mmd, "graph LR\n") + end + + test "YAML-quotes a title containing a colon" do + a = Artefact.new(title: "Sand Talk: a yarn", nodes: [], relationships: []) + assert String.contains?(Artefact.Mermaid.export(a), ~s|title: "Sand Talk: a yarn"|) + end + + test "YAML-quotes and escapes a title with a double quote" do + a = Artefact.new(title: ~s|she said "hi"|, nodes: [], relationships: []) + assert String.contains?(Artefact.Mermaid.export(a), ~s|title: "she said \\"hi\\""|) + end + + test "accTitle escaping is independent of YAML quoting" do + a = Artefact.new(title: "Sand Talk: a yarn", nodes: [], relationships: []) + assert String.contains?(Artefact.Mermaid.export(a), " accTitle: Sand Talk: a yarn") + end + end end diff --git a/artefact/test/data/us_two/mermaid.mmd b/artefact/test/data/us_two/mermaid.mmd new file mode 100644 index 0000000..e544659 --- /dev/null +++ b/artefact/test/data/us_two/mermaid.mmd @@ -0,0 +1,8 @@ +--- +title: UsTwo +--- +graph LR + accTitle: UsTwo + n0(("Matt
Agent Me")) + n1(("Claude
Agent You")) + n0 -->|US_TWO| n1 diff --git a/artefact_kino/artefact_kino.livemd b/artefact_kino/artefact_kino.livemd index e5f395f..68b3b10 100644 --- a/artefact_kino/artefact_kino.livemd +++ b/artefact_kino/artefact_kino.livemd @@ -7,7 +7,9 @@ SPDX-License-Identifier: MIT ```elixir Mix.install([ - {:artefact_kino, "~> 0.1.1"}, + #{:artefact_kino, "~> 0.1.2"}, + {:artefact_kino, path: "/Users/beanlanda/git/artefactory/artefact_kino"}, + {:artefact, path: "/Users/beanlanda/git/artefactory/artefact", override: true}, {:req, "~> 0.5"} ]) ``` diff --git a/artefact_kino/lib/artefact_kino.ex b/artefact_kino/lib/artefact_kino.ex index 406d43d..2cb93e3 100644 --- a/artefact_kino/lib/artefact_kino.ex +++ b/artefact_kino/lib/artefact_kino.ex @@ -5,9 +5,10 @@ defmodule ArtefactKino do @moduledoc """ Livebook Kino widget for rendering `%Artefact{}` knowledge graphs. - Renders three panels: interactive vis-network graph (heartside), Cypher - fragment with CREATE/MERGE toggle, and a tabbed Elixir inspector showing - the artefact, nodes and relationships as tables. + Renders three panels: interactive vis-network graph (heartside), an export + panel toggling between CREATE / MERGE Cypher, Arrows JSON and Mermaid + source, and a tabbed Elixir inspector showing the artefact, nodes and + relationships as tables. ## Usage @@ -35,6 +36,7 @@ defmodule ArtefactKino do create_cypher: Artefact.Cypher.create(artefact), merge_cypher: Artefact.Cypher.merge(artefact), arrows_json: Artefact.Arrows.to_json(artefact), + mermaid: Artefact.Mermaid.export(artefact), default: Atom.to_string(default), title: artefact.title || artefact.base_label || "Artefact", artefact_rows: artefact_rows(artefact), @@ -217,6 +219,7 @@ defmodule ArtefactKino do +

@@ -239,9 +242,10 @@ defmodule ArtefactKino do
       const cypherBtns = ctx.root.querySelectorAll(".cbtn");
 
       function renderCypher() {
-        if (currentCypher === "create")      cypherEl.textContent = data.create_cypher;
-        else if (currentCypher === "merge")  cypherEl.textContent = data.merge_cypher;
-        else                                 cypherEl.textContent = data.arrows_json;
+        if (currentCypher === "create")        cypherEl.textContent = data.create_cypher;
+        else if (currentCypher === "merge")    cypherEl.textContent = data.merge_cypher;
+        else if (currentCypher === "mermaid")  cypherEl.textContent = data.mermaid;
+        else                                   cypherEl.textContent = data.arrows_json;
         cypherBtns.forEach(b => btnStyle(b, b.dataset.cypher === currentCypher));
       }
       cypherBtns.forEach(b => b.addEventListener("click", () => { currentCypher = b.dataset.cypher; renderCypher(); }));