diff --git a/.drafts/convenience-combine.md b/.drafts/convenience-combine.md new file mode 100644 index 0000000..296ac4b --- /dev/null +++ b/.drafts/convenience-combine.md @@ -0,0 +1,58 @@ + + +# Drafts — Artefact.combine convenience wrapper + +Drafts only. Branch `10-convenience-combine`. Per `.claude/settings.json` no +`git commit / push / add` was run. + +--- + +## Draft commit message — `artefact` + +``` +feat(artefact): pipeline-friendly combine/3 + +Artefact.combine(heart, other, opts \\ []) is a convenience wrapper around +Artefact.Binding.find/2 + Artefact.harmonise/4 designed for pipelines — +the heart flows through the pipe as the first argument: + + me_knowing + |> Artefact.combine(me_valuing) + |> Artefact.combine(me_being) + |> Artefact.combine(me_doing, title: "MeMind", description: "Mind of Me") + +Bindings are auto-found via shared uuids; opts pass through to harmonise +for :title and :base_label overrides. :description is patched onto the +result (since harmonise itself does not yet honour :description in opts). +Raises MatchError when there are no shared nodes. + +Provenance: :harmonised, with the calling module. Combine is sugar over +harmonise — the underlying operation IS a harmonise, so the trace stays +honest. The convenience is the binding-find and the heart-first arg order. +``` + +## Notes for the next yarn + +- **harmonise/compose could honour :description in opts.** Right now combine + patches it post-hoc. If you later extend `do_harmonise/5` and `do_compose/4` + to read `:description` from opts and pass it into `build/1`, combine can + drop the patch and just delegate. Worth a small follow-up issue, not part + of this branch. + +- **Portmanteau base_label grows.** Each combine step concatenates the + heart and other base_labels. Through a five-step pipeline that becomes a + long word like `KnowingValuingBeingKnowingMoreDoing`. The final step's + opts can rename it (`base_label: "MeMind"`), which is what the example + in the docstring shows. Worth a note in any usage example. + +- **No `combine!` variant.** combine raises MatchError on no bindings, which + matches the user's original livebook helper. If we later want a + `combine_with/3` that explicitly accepts `inject:` bindings, that's a new + function — keep combine as the simple shared-uuid case. + +--- + +*Held in the commons.* diff --git a/.drafts/mermaid-export.md b/.drafts/mermaid-export.md new file mode 100644 index 0000000..1ac5cb3 --- /dev/null +++ b/.drafts/mermaid-export.md @@ -0,0 +1,156 @@ + + +# 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` (branch `9-support-description`) + +``` +feat(artefact): optional description field, surfaced as Mermaid accDescr + +%Artefact{} now carries an optional :description alongside :title. +It defaults to nil and is only emitted into Mermaid when set. + +- new field on the struct + typespec +- Artefact.new accepts description: passthrough; macro docstring updated +- Artefact.Arrows round-trips description as a top-level "description" + key (peer of "title", may be null) +- Artefact.Mermaid emits accDescr (inline form for single-line, + block form `accDescr { ... }` when the description contains + newlines); placed between accTitle and the node lines +- ArtefactKino inspector grows a `description` row on the Artefact tab + +AGENTS.md schema updated. + +compose/harmonise are deliberately left to default description to nil — +combining two descriptions is a judgement call we don't want the +library making silently. Pass description: in opts when needed. +``` + +## 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/AGENTS.md b/AGENTS.md
index 01252d1..d311de6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -197,11 +197,12 @@ Do not build this yet. Hold it as the direction `artefactory` could grow toward.
 
 ```elixir
 %Artefact{
-  id:       String.t(),          # generated UUID
-  title:    String.t() | nil,    # human label
-  style:    atom() | nil,        # render style reference — not persisted in graph
-  graph:    %Artefact.Graph{},   # the knowledge
-  metadata: map()                # open map — consumers add their own keys
+  id:          String.t(),          # generated UUID
+  title:       String.t() | nil,    # human label
+  description: String.t() | nil,    # human description — surfaced as Mermaid accDescr
+  style:       atom() | nil,        # render style reference — not persisted in graph
+  graph:       %Artefact.Graph{},   # the knowledge
+  metadata:    map()                # open map — consumers add their own keys
 }
 
 %Artefact.Graph{
diff --git a/artefact/CHANGELOG.md b/artefact/CHANGELOG.md
index 212cb21..558b582 100644
--- a/artefact/CHANGELOG.md
+++ b/artefact/CHANGELOG.md
@@ -5,6 +5,13 @@ SPDX-License-Identifier: MIT
 
 # Changelog
 
+## 0.1.3 — 2026-04-30
+
+- `Artefact.Mermaid.export/2` — derives Mermaid `graph` source from an `%Artefact{}`, alongside `Artefact.Cypher` and `Artefact.Arrows`; nodes render as circles, `:direction` option for `:LR`, `:RL`, `:TB`, `:BT`, `:TD`
+- `:description` field on `%Artefact{}` — optional human-readable description, defaults to `nil`; accepted by `Artefact.new/1` and round-tripped through `Artefact.Arrows`
+- Mermaid front-matter `title:` (Mermaid 9.4+ heading) and body `accTitle:` derived from `artefact.title`; `accDescr:` derived from `artefact.description` (inline form for single-line, block form `accDescr { ... }` for multi-line)
+- `Artefact.combine/3` — pipeline-friendly convenience over `Artefact.Binding.find/2` + `Artefact.harmonise/4`; the heart flows through the pipe as the first argument, opts honour `:title`, `:base_label` and `:description` overrides; raises `MatchError` when no shared bindings exist
+
 ## 0.1.2 — 2026-04-21
 
 - Improved `Artefact.new/1` macro — nodes and relationships declared inline with atom keys and keyword options
diff --git a/artefact/lib/artefact.ex b/artefact/lib/artefact.ex
index b9fae31..94ab2b1 100644
--- a/artefact/lib/artefact.ex
+++ b/artefact/lib/artefact.ex
@@ -11,12 +11,13 @@ defmodule Artefact do
   persistence.
   """
 
-  defstruct [:id, :uuid, :title, :base_label, :style, metadata: %{}, graph: %Artefact.Graph{}]
+  defstruct [:id, :uuid, :title, :description, :base_label, :style, metadata: %{}, graph: %Artefact.Graph{}]
 
   @type t :: %__MODULE__{
           id: String.t(),
           uuid: String.t(),
           title: String.t() | nil,
+          description: String.t() | nil,
           base_label: String.t() | nil,
           style: atom() | nil,
           graph: Artefact.Graph.t(),
@@ -27,6 +28,10 @@ defmodule Artefact do
   Create a new Artefact. Defaults `base_label` and `title` to the short name
   of the calling module. Override with `title:` or `base_label:` in attrs.
 
+  Optional `description:` is a longer human-readable note about the artefact —
+  surfaced as Mermaid `accDescr` and in the `ArtefactKino` inspector. Defaults
+  to `nil`; pass it explicitly when you have something to say.
+
   Records `:struct` provenance with the calling module.
   """
   defmacro new(attrs \\ []) do
@@ -70,6 +75,45 @@ defmodule Artefact do
     build([{:title, title}, {:base_label, base_label}, {:graph, graph}, {:metadata, metadata}])
   end
 
+  @doc """
+  Combine `other` into `heart` using bindings auto-found between them.
+
+  Designed for pipelines — `heart` flows through the pipe as the first argument,
+  so a chain of combines accumulates a single heart from many others:
+
+      me_knowing
+      |> Artefact.combine(me_valuing)
+      |> Artefact.combine(me_being)
+      |> Artefact.combine(me_doing, title: "MeMind", description: "Mind of Me.")
+
+  Bindings are found via `Artefact.Binding.find/2` — every node sharing a uuid
+  between `heart` and `other` becomes a binding. Raises `MatchError` if no
+  bindings are found.
+
+  Internally delegates to `harmonise/4`, so `:title` and `:base_label` overrides
+  in `opts` are honoured. `:description` is also accepted and applied to the
+  result.
+
+  Records `:harmonised` provenance with the calling module.
+  """
+  defmacro combine(heart, other, opts \\ []) do
+    caller = __CALLER__.module
+    quote do
+      Artefact.do_combine(unquote(heart), unquote(other), unquote(opts), unquote(caller))
+    end
+  end
+
+  @doc false
+  def do_combine(%__MODULE__{} = heart, %__MODULE__{} = other, opts, caller) do
+    {:ok, bindings} = Artefact.Binding.find(heart, other)
+    result = do_harmonise(heart, other, bindings, opts, caller)
+
+    case Keyword.fetch(opts, :description) do
+      {:ok, description} -> %{result | description: description}
+      :error -> result
+    end
+  end
+
   @doc """
   Harmonise two artefacts using declared bindings.
 
diff --git a/artefact/lib/artefact/arrows.ex b/artefact/lib/artefact/arrows.ex
index 621f04a..1853025 100644
--- a/artefact/lib/artefact/arrows.ex
+++ b/artefact/lib/artefact/arrows.ex
@@ -51,6 +51,7 @@ defmodule Artefact.Arrows do
       id: Keyword.get(opts, :id, Artefact.UUID.generate_v7()),
       uuid: Keyword.get(opts, :uuid, Map.get(raw, "uuid", Artefact.UUID.generate_v7())),
       title: Keyword.get(opts, :title, Map.get(raw, "title")),
+      description: Keyword.get(opts, :description, Map.get(raw, "description")),
       base_label: base_label,
       style: Keyword.get(opts, :style),
       graph: graph,
@@ -87,10 +88,11 @@ defmodule Artefact.Arrows do
 
   # -- encode --
 
-  defp encode(%Artefact{uuid: uuid, title: title, base_label: base_label, graph: graph}) do
+  defp encode(%Artefact{uuid: uuid, title: title, description: description, base_label: base_label, graph: graph}) do
     %{
       "uuid" => uuid,
       "title" => title,
+      "description" => description,
       "base_label" => base_label,
       "style" => %{},
       "nodes" => Enum.map(graph.nodes, &encode_node(&1, base_label)),
diff --git a/artefact/lib/artefact/mermaid.ex b/artefact/lib/artefact/mermaid.ex
new file mode 100644
index 0000000..2bda4d6
--- /dev/null
+++ b/artefact/lib/artefact/mermaid.ex
@@ -0,0 +1,162 @@
+# 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 `. + + When `artefact.description` is set, an `accDescr:` line follows the + `accTitle:` line. Multi-line descriptions use the block form + (`accDescr { ... }`); single-line descriptions use the inline form. A nil + description is omitted. Like `accTitle`, the description is screen-reader + only — Mermaid does not render it visually. + + 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, description: description, 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) + + accessibility = acc_title_lines(title) ++ acc_descr_lines(description) + body = ["graph #{direction}" | accessibility ++ 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)}"] + + defp acc_descr_lines(nil), do: [] + + defp acc_descr_lines(description) do + s = to_string(description) + + if String.contains?(s, "\n") do + indented = + s + |> String.split("\n") + |> Enum.map_join("\n", &" #{&1}") + + [" accDescr {", indented, " }"] + else + [" accDescr: #{s}"] + end + end + + # 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/mix.exs b/artefact/mix.exs index f194bcf..d96965b 100644 --- a/artefact/mix.exs +++ b/artefact/mix.exs @@ -5,7 +5,7 @@ defmodule Artefact.MixProject do @moduledoc false use Mix.Project - @version "0.1.2" + @version "0.1.3" @github_url "https://github.com/diffo-dev/artefactory" def project do diff --git a/artefact/test/artefact_test.exs b/artefact/test/artefact_test.exs index 84e5d4f..a4ff4ad 100644 --- a/artefact/test/artefact_test.exs +++ b/artefact/test/artefact_test.exs @@ -752,4 +752,342 @@ 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 + + describe "Artefact.new/1 — :description option" do + test "defaults to nil when not provided" do + a = Artefact.new() + assert a.description == nil + end + + test "stores the description when provided" do + a = Artefact.new(description: "the simplest true thing about us_two") + assert a.description == "the simplest true thing about us_two" + end + + test "description is independent of title" do + a = Artefact.new(title: "UsTwo", description: "Me toward You") + assert a.title == "UsTwo" + assert a.description == "Me toward You" + end + end + + describe "Artefact.Arrows round-trip — description" do + test "preserves a set description" do + original = + Artefact.new( + title: "UsTwo", + description: "the simplest true thing", + base_label: "UsTwo", + nodes: [a: [labels: ["Agent"]]], + relationships: [] + ) + + round_tripped = original |> Artefact.Arrows.to_json() |> Artefact.Arrows.from_json!() + assert round_tripped.description == "the simplest true thing" + end + + test "preserves a nil description" do + original = Artefact.new(title: "Bare", nodes: [], relationships: []) + assert original.description == nil + + round_tripped = original |> Artefact.Arrows.to_json() |> Artefact.Arrows.from_json!() + assert round_tripped.description == nil + end + end + + describe "Artefact.Mermaid.export/2 — description" do + test "emits accDescr inline when description is single-line" do + a = + Artefact.new( + title: "UsTwo", + description: "Me toward You", + nodes: [], + relationships: [] + ) + + assert String.contains?(Artefact.Mermaid.export(a), " accDescr: Me toward You") + end + + test "uses block form when description contains newlines" do + a = + Artefact.new( + title: "UsTwo", + description: "first line\nsecond line", + nodes: [], + relationships: [] + ) + + mmd = Artefact.Mermaid.export(a) + assert String.contains?(mmd, " accDescr {\n first line\n second line\n }") + refute String.contains?(mmd, "accDescr:") + end + + test "omits accDescr when description is nil" do + a = Artefact.new(title: "Titled but undescribed", nodes: [], relationships: []) + mmd = Artefact.Mermaid.export(a) + refute String.contains?(mmd, "accDescr") + end + + test "accDescr appears after accTitle and before nodes" do + a = + Artefact.new( + title: "Order", + description: "matters", + nodes: [n: [labels: ["X"]]], + relationships: [] + ) + + mmd = Artefact.Mermaid.export(a) + title_idx = :binary.match(mmd, "accTitle:") |> elem(0) + descr_idx = :binary.match(mmd, "accDescr:") |> elem(0) + node_idx = :binary.match(mmd, "n0((") |> elem(0) + assert title_idx < descr_idx + assert descr_idx < node_idx + end + end + + describe "Artefact.combine/3" do + @uuid_shared "019d0000-0000-7000-8000-000000000000" + + defp combine_artefact(base_label, uuid) do + %Artefact{ + id: Artefact.UUID.generate_v7(), + uuid: Artefact.UUID.generate_v7(), + title: base_label, + base_label: base_label, + style: nil, + metadata: %{}, + graph: %Artefact.Graph{ + nodes: [%Artefact.Node{id: "n0", uuid: uuid, labels: [], properties: %{}}], + relationships: [] + } + } + end + + test "combines two artefacts via auto-found bindings" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert length(result.graph.nodes) == 1 + end + + test "default base_label is portmanteau of heart + other" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert result.base_label == "KnowingValuing" + end + + test "title defaults to base_label when not given" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert result.title == "KnowingValuing" + end + + test "description defaults to nil when not given" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert result.description == nil + end + + test "applies title override from opts" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other, title: "Custom") + assert result.title == "Custom" + end + + test "applies description override from opts" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other, description: "yarned") + assert result.description == "yarned" + end + + test "applies title and description together" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other, title: "MeMind", description: "Mind of Me") + assert result.title == "MeMind" + assert result.description == "Mind of Me" + end + + test "chains in a pipeline" do + a = combine_artefact("Knowing", @uuid_shared) + b = combine_artefact("Valuing", @uuid_shared) + c = combine_artefact("Being", @uuid_shared) + + result = a |> Artefact.combine(b) |> Artefact.combine(c) + assert result.base_label == "KnowingValuingBeing" + assert length(result.graph.nodes) == 1 + end + + test "pipeline applies title and description on the final step" do + a = combine_artefact("Knowing", @uuid_shared) + b = combine_artefact("Valuing", @uuid_shared) + c = combine_artefact("Being", @uuid_shared) + + result = + a + |> Artefact.combine(b) + |> Artefact.combine(c, title: "MeMind", description: "Mind of Me") + + assert result.title == "MeMind" + assert result.description == "Mind of Me" + end + + test "records :harmonised provenance with calling module" do + heart = combine_artefact("Knowing", @uuid_shared) + other = combine_artefact("Valuing", @uuid_shared) + + result = Artefact.combine(heart, other) + assert %{provenance: %{source: :harmonised, module: ArtefactTest}} = result.metadata + end + + test "raises when artefacts have no shared nodes" do + heart = combine_artefact("Knowing", "019d0000-0000-7000-8000-000000000010") + other = combine_artefact("Valuing", "019d0000-0000-7000-8000-000000000020") + + assert_raise MatchError, fn -> Artefact.combine(heart, other) end + 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/CHANGELOG.md b/artefact_kino/CHANGELOG.md index feb76d2..41b18e7 100644 --- a/artefact_kino/CHANGELOG.md +++ b/artefact_kino/CHANGELOG.md @@ -5,6 +5,13 @@ SPDX-License-Identifier: MIT # Changelog +## 0.1.3 — 2026-04-30 + +- Compatible with `artefact ~> 0.1.3` +- MERMAID button on the export panel — pasteable Mermaid `graph` source alongside CREATE / MERGE / JSON +- Header bar renders `artefact.description` under `artefact.title` when set; multi-line descriptions preserve their newlines (`white-space: pre-line`) +- `description` row added to the Artefact tab in the Elixir inspector, alongside `title`, `base_label` and `metadata` + ## 0.1.2 — 2026-04-21 - Compatible with `artefact ~> 0.1.2` diff --git a/artefact_kino/artefact_kino.livemd b/artefact_kino/artefact_kino.livemd index e5f395f..59f72cb 100644 --- a/artefact_kino/artefact_kino.livemd +++ b/artefact_kino/artefact_kino.livemd @@ -7,7 +7,7 @@ SPDX-License-Identifier: MIT ```elixir Mix.install([ - {:artefact_kino, "~> 0.1.1"}, + {:artefact_kino, "~> 0.1.3"}, {:req, "~> 0.5"} ]) ``` diff --git a/artefact_kino/lib/artefact_kino.ex b/artefact_kino/lib/artefact_kino.ex index 406d43d..42b4b16 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,8 +36,10 @@ 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", + description: artefact.description, artefact_rows: artefact_rows(artefact), nodes_rows: nodes_rows(artefact), rels_rows: rels_rows(artefact) @@ -66,11 +69,12 @@ defmodule ArtefactKino do defp artefact_rows(%Artefact{} = a) do [ - %{key: "id", value: a.id}, - %{key: "uuid", value: a.uuid}, - %{key: "title", value: inspect(a.title)}, - %{key: "base_label", value: inspect(a.base_label)}, - %{key: "metadata", value: inspect(a.metadata, pretty: true)} + %{key: "id", value: a.id}, + %{key: "uuid", value: a.uuid}, + %{key: "title", value: inspect(a.title)}, + %{key: "description", value: inspect(a.description)}, + %{key: "base_label", value: inspect(a.base_label)}, + %{key: "metadata", value: inspect(a.metadata, pretty: true)} ] end @@ -185,10 +189,23 @@ defmodule ArtefactKino do export function init(ctx, data) { ctx.root.style.cssText = "font-family:monospace;background:#111;color:#e0e0e0;"; + const escapeHtml = (s) => String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + + const headerHtml = ` +
+
${escapeHtml(data.title)}
+ ${data.description + ? `
${escapeHtml(data.description)}
` + : ""} +
`; + ctx.root.innerHTML = ` -
- ${data.title} -
+ ${headerHtml}
@@ -217,6 +234,7 @@ defmodule ArtefactKino do +

@@ -239,9 +257,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(); }));
diff --git a/artefact_kino/mix.exs b/artefact_kino/mix.exs
index bd4e9fa..63a73e4 100644
--- a/artefact_kino/mix.exs
+++ b/artefact_kino/mix.exs
@@ -5,7 +5,7 @@ defmodule ArtefactKino.MixProject do
   @moduledoc false
   use Mix.Project
 
-  @version "0.1.2"
+  @version "0.1.3"
   @github_url "https://github.com/diffo-dev/artefactory"
 
   def project do
@@ -29,12 +29,28 @@ defmodule ArtefactKino.MixProject do
 
   defp deps do
     [
-      {:artefact, "~> 0.1"},
+      artefact_dep(),
       {:kino, "~> 0.14"},
       {:ex_doc, "~> 0.37", only: [:dev, :test], runtime: false}
     ]
   end
 
+  # Local path dep when running inside the monorepo, hex dep otherwise.
+  # `mix hex.publish` rejects path deps, so set HEX_PUBLISH=1 (or run from
+  # outside the repo) to force the hex form when shipping.
+  defp artefact_dep do
+    cond do
+      System.get_env("HEX_PUBLISH") == "1" ->
+        {:artefact, "~> 0.1.3"}
+
+      File.exists?(Path.join(__DIR__, "../artefact/mix.exs")) ->
+        {:artefact, path: "../artefact"}
+
+      true ->
+        {:artefact, "~> 0.1.3"}
+    end
+  end
+
   defp package do
     [
       licenses: ["MIT"],
diff --git a/artefact_kino/mix.lock b/artefact_kino/mix.lock
index 794bf51..34d8e38 100644
--- a/artefact_kino/mix.lock
+++ b/artefact_kino/mix.lock
@@ -1,5 +1,4 @@
 %{
-  "artefact": {:hex, :artefact, "0.1.0", "143825c5ceb2f66abac470e38568288ea9de8336ca1ad063141604256f79b369", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d54040f93f26eb0280f3187b9e8971968eea9a79840fd2c9c7bdbd27ddd0bfa4"},
   "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
   "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"},
   "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},