Skip to content
Merged
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
132 changes: 132 additions & 0 deletions .drafts/mermaid-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<!--
SPDX-FileCopyrightText: 2026 artefactory contributors <https://github.com/diffo-dev/artefactory/graphs/contributors>
SPDX-License-Identifier: MIT
-->

# 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 <br/>
- 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 -> &quot;
* pipe in edge label -> &#124;
* 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 `<div>` swapped in for the `<pre>`

---

## 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.*
135 changes: 135 additions & 0 deletions artefact/lib/artefact/mermaid.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# SPDX-FileCopyrightText: 2026 artefactory contributors <https://github.com/diffo-dev/artefactory/graphs/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 `<br/>`.

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 <direction>`.

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<br/>Agent Me"))
n1(("Claude<br/>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)}<br/>#{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;
# `<br/>` is rendered as a line break, which is what we want.
defp escape(value) do
value
|> to_string()
|> String.replace("\"", "&quot;")
end

# Edge labels live between pipes, so the pipe itself must be entity-encoded.
defp escape_pipe(value) do
value
|> to_string()
|> String.replace("|", "&#124;")
|> String.replace("\"", "&quot;")
end
end
Loading
Loading