Argdown in Org-Mode
Fleetinghttps://argdown.org/syntax/#relations-between-statements
I keep wanting to map arguments the way I write code: in plain text, in
my .org files, versioned and linked into the rest of my notes, with a
rendered graph falling out when I need to see the shape of a debate.
Argdown is exactly that for the syntax — a Markdown-ish language where
[statements] and <arguments> connect with + (support) and -
(attack), and a Graphviz-layouted map drops out the other end. What it
isn’t, yet, is org-mode-friendly: there’s no ob-argdown, the CLI is
file-centric, and it isn’t packaged for Nix.
This note is the bridge. The pieces: a decision (keep Argdown as a parser,
don’t rebuild it), a Nix flake that gives me an argdown binary, the
org-babel layer KONIX_argdown.el (this note is its literate source) so a
#+begin_src argdown block renders inline — through Argdown’s own renderer
with clickable source links injected, as a map for publishing, or to a local
:file map for editing — a collector that aggregates argdown fragments
scattered across many notes into a synthesis map, and the polish around it
(:argdown-include composition, Hugo-export coloring, M-q wrapping).
Choice of direction
Three ways to get org-friendly argument maps: lean on Argdown as the engine and write a thin babel wrapper; reach for a different text→graph tool; or build my own argument-mapping notation from scratch. I want the first, and the reasoning is worth pinning so I don’t relitigate it in six months.
Why not rebuild my own. The hard part of argument mapping isn’t the syntax — it’s the layout: placing premises above conclusions, grouping pro/con branches, routing edges without crossings. Argdown delegates that to Graphviz and has spent years getting the argument-specific layering right. Reinventing it is months of work for a worse result. The syntax I could rewrite in an afternoon; the layout I could not.
Why not a generic tool. The only mature text→graph tools that slot
into org-babel cleanly are general-purpose — Graphviz (ob-dot),
PlantUML, Mermaid. None of them understands the semantics of an
argument: premise/conclusion structure, support vs attack,
reconstruction into inference steps. I’d be hand-encoding all of that
into raw DOT, which is precisely the work Argdown already did.
Why Argdown is a safe bet right now. I checked its health before committing:
@argdown/clishipped v2.0.0 on 2026-04-27 — a real major release after a long quiet stretch (the prior release was v1.7.5 in Sept 2021). The repo had a push the day I looked. So it’s actively maintained again, though still essentially a single-maintainer project (Christian Voigt) — the one real risk to weigh.- v2 requires Node ≥ 22.11. Fine; the flake pins its own Node.
- It is not in nixpkgs and ships no official flake. Hence the next chapter.
- The CLI reads a file, not stdin, so the bridge writes each block to a temp
file, then takes Argdown’s SVG (
argdown map -f svg --stdout) and its model (argdown json --stdout) — the latter only to look up the source URLs we splice into the SVG (see The org-babel bridge and Clickable links).
So: keep Argdown, package it, wrap it. The lock-in I accept is a Node runtime dependency — paid for once by the flake.
Packaging argdown with Nix
Argdown isn’t in nixpkgs, so I package the published npm CLI myself.
The clean idiom for “I just want this registry package as a Nix
binary” is buildNpmPackage wrapping a tiny throw-away package whose
only dependency is @argdown/cli. Nix resolves the dependency tree
from a lockfile, builds it offline, and I wrap the resulting
.bin/argdown with Node on its PATH.
The throw-away package is two lines of intent: depend on the CLI at the version I vetted.
{
"name": "argdown-env",
"version": "2.0.0",
"dependencies": { "@argdown/cli": "2.0.0" }
}
The lockfile is generated, not authored — npm resolves the full
transitive tree. --ignore-scripts stops any postinstall (notably the
Puppeteer/Chromium download path) from firing during resolution, and
--package-lock-only means nothing is actually installed, just the
lock written. Run this once after tangling package.json:
npm install --package-lock-only --ignore-scripts
echo "wrote $(pwd)/package-lock.json"
up to date, audited 139 packages in 7s
38 packages are looking for funding
run `npm fund` for details
12 vulnerabilities (4 moderate, 8 high)
To address issues that do not require attention, run:
npm audit fix
To address all issues (including breaking changes), run:
npm audit fix --force
Run `npm audit` for details.
wrote /home/sam/prog/devel/flakes/argdown/package-lock.json
Now the flake. dontNpmBuild because there’s nothing to compile — I
only want the dependencies materialised. installPhase copies the
resolved node_modules into the store and wraps the CLI’s bin entry
so it finds Node at runtime regardless of Argdown’s internal dist
path. npmDepsHash starts as fakeHash; the first build fails and
hands me the real one to paste in (cf. Dev environment).
{
description = "Argdown CLI (SVG/DOT/PDF, no Puppeteer)";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAll = f: nixpkgs.lib.genAttrs systems (s: f nixpkgs.legacyPackages.${s});
in {
packages = forAll (pkgs: {
default = pkgs.buildNpmPackage {
pname = "argdown-cli";
version = "2.0.0";
src = ./.;
# First build: leave fakeHash, copy the printed `got:` hash here.
npmDepsHash = "sha256-npIaF9t8ySovuhwYYumNb0mS9qdhjkq9AdMDZ6rROnk=";
dontNpmBuild = true;
npmFlags = [ "--ignore-scripts" ];
nativeBuildInputs = [ pkgs.makeWrapper ];
installPhase = ''
runHook preInstall
mkdir -p $out/lib $out/bin
cp -r node_modules $out/lib/node_modules
makeWrapper $out/lib/node_modules/.bin/argdown $out/bin/argdown \
--prefix PATH : ${pkgs.nodejs}/bin
runHook postInstall
'';
};
});
devShells = forAll (pkgs: {
default = pkgs.mkShell {
packages = [ self.packages.${pkgs.system}.default ];
};
});
};
}
Getting the hash is a one-time fakeHash dance: build once, the
build fails and prints the real got: sha256-…, paste it into
flake-nix above. After that the hash is fixed and builds are
reproducible. This block extracts the printed hash:
nix build 2>&1 | sed -n 's/.*\(got:.*sha256-[A-Za-z0-9+/=]*\).*/\1/p'
# paste that hash into flake-nix, then re-run: nix build && ./result/bin/argdown --version
The org-babel bridge
Three pieces beyond the bare renderer: render-to-:file
(svg/dot/pdf/json), the cross-note collector, and
clickable source links in Argdown’s own map.
The shape of every call: Argdown can’t read stdin (its input is a
file/glob), so the body goes through a temp .argdown file. From there
argdown map -f svg --stdout gives the rendered SVG (Argdown lays it out with
its bundled Graphviz) and argdown json --stdout gives the model we read the
source URLs from; --silent keeps both outputs free of log noise. PDF goes
through a temp folder (Argdown refuses stdout for it); PNG is an ImageMagick
step on the SVG.
First the major mode and font-lock, kept verbatim from the original.
;;; KONIX_argdown.el --- Argdown mode + org-babel -*- lexical-binding: t; -*-
;; Copyright (C) 2021 konubinix
;; Author: konubinix <konubinixweb@gmail.com>
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;;; Commentary:
;;; Code:
(require 'ob)
(require 'cl-lib)
(defface argdown-supportive-claim-face '((t :foreground "green"))
"Face for argdown supportive claims."
:group 'argdown)
(defface argdown-unsupportive-claim-face '((t :foreground "red"))
"Face for argdown unsupportive claims."
:group 'argdown)
(defface argdown-countradict-claim-face '((t :foreground "red"))
"Face for argdown countradict claims."
:group 'argdown)
(defvar
argdown-highlights
'(
("\\[\\([^]]+\\)\\]:?" (1 font-lock-function-name-face))
("<\\([^>]+\\)>:?" (1 font-lock-function-name-face))
("^ +\\(\\+\\) " (1 'argdown-supportive-claim-face))
("^ +\\(\\-\\) " (1 'argdown-unsupportive-claim-face))
("^ +\\(><\\) " (1 'argdown-countradict-claim-face))
)
"Specific argdown construct to highlight."
)
;;;###autoload
(define-derived-mode argdown-mode markdown-mode "argdown"
"Major mode for editing argdown document."
(setq font-lock-defaults '(argdown-highlights))
(setq-local
markdown-asymmetric-header t
markdown-unordered-list-item-prefix " + "
)
)
The rendering helpers. argdown--render writes a block to a file
(svg/dot/pdf/png) via Argdown’s renderer with links injected, for the
:file path. The publish path instead serves Argdown’s own web-component
(argdown web-component): the interactive viewer the site used to have —
map plus a toolbar (zoom-lock, fullscreen, source toggle) — which it loads
from a CDN. argdown--web-component takes that HTML and injects the source
href into its slotted SVG (the same <a xlink:title> nodes). We own only
the link; the whole viewer is Argdown’s — no homegrown pan/zoom to maintain.
The publish path then wraps that web-component in a srcdoc iframe, and
that’s not cosmetic. Hugo’s Goldmark reparses any raw HTML it finds in the
Markdown body, and the component is a multi-line blob of <link>=/=<script>=/ =<figure>=/=<svg>. <iframe> is a Goldmark type-6 HTML tag, so a multi-line
iframe passes through verbatim — right up until a blank line, which would
end the block and throw the rest back into Markdown. So argdown--iframe-wrap
entity-escapes the blob into the srcdoc attribute and collapses blank
lines, keeping real newlines so the emitted attribute stays readable in the
source rather than one giant line. The browser peels exactly one entity layer
before parsing srcdoc as a document, so the escaping is invisible inside the
frame, and the iframe also isolates the component’s CDN css/js from the page.
The escaping is load-bearing against Hugo’s link rewriter — a blunt
s|target="_blank" href=|target"_blank" target="_blank" href=|= sed. Escaping <=/=> keeps it from finding
the source <a> tags inside the attribute, but the component also pulls a
CDN <link target="_blank" href=…> stylesheet, and the literal text target="_blank" href= survives a mere
<-escape. So argdown--srcdoc-escape also encodes every \= as =:
nothing for the sed to match, and the browser decodes it back in the same
pass. Without this the srcdoc shipped truncated once.
Why an iframe with a fixed height:70vh (min 320px) stage: the maps get
large (verbatim-source nodes especially), and the web-component’s own zoom and
fullscreen turn a dense map into something explorable within that stage —
exactly the affordance the site had before. allowfullscreen on the iframe
lets its fullscreen button break out of the stage.
But allowfullscreen isn’t always enough: depending on the browser and the
embedding page’s permissions policy, the component’s fullscreen only fills the
70vh stage rather than the whole screen — leaving no real fullscreen view. So
argdown--document also paints a small open-in-tab button
(argdown--open-in-tab): clicked, it serialises the iframe’s own document to a
blob: URL and opens it as a top-level tab, where the map gets the full viewport
(and the component’s fullscreen then works against the whole screen). The publish
path has no standalone URL — the map lives only inside srcdoc — so
re-serialising the live document is the way out. The button is guarded on
window.top != window.self=: it shows only when framed, and removes itself in
the resulting top-level tab (and in konix/argdown-preview, already top-level),
so it never clutters a full view.
The one behaviour we graft onto the component is a tiny click guard
(argdown--iframe-click-guard): the component pans on pointer-drag but lets
the trailing click through, so releasing a pan over a source node opened its
link mid-pan. A capture-phase listener vetoes the click that ends a drag
(pointer moved past ~6px), leaving a still click to follow the link. It rides
on top of the component — not a pan/zoom reimplementation — the single thing
we own besides the link injection and the affordance CSS.
(defun argdown--require-bin ()
"Error unless the `argdown' CLI is reachable (it ships its own Graphviz)."
(unless (executable-find "argdown")
(error "argdown: not on PATH — its hardlias lazily installs the flake; \
tangle/build it (see argdown_in_org_mode.org)")))
(defun argdown--with-input (body fn)
"Write BODY to a temp .argdown file and call FN with its absolute path."
(let ((in (org-babel-temp-file "argdown-" ".argdown")))
(with-temp-file in (insert body))
(funcall fn (expand-file-name in))))
(defconst argdown--iframe-reset-style
"<style>html,body{margin:0;padding:0;height:100%}.argdown-figure{margin:0;height:100%}argdown-map{display:block;height:100%}svg a[*|href]{cursor:pointer}svg a[*|href] text[font-weight=\"bold\"]{fill:#1a73e8;text-decoration:underline}</style>"
"Strip the iframe's default body margin and let Argdown's web-component
fill the fixed viewport (its toolbar — zoom-lock, fullscreen, source toggle —
is the component's own). The rest is the *link affordance*: the component
draws every node alike, so a node we made clickable (its `<a>' got an injected
`xlink:href') is otherwise indistinguishable. We mark it — the bold title
line turns blue + underlined, the cursor a pointer — by styling `a[*|href]'
(the `*|' matches the namespaced `xlink:href'); plain nodes and edge anchors
have no href and stay untouched. This document-level CSS reaches the SVG even
though it is *slotted* light-DOM inside the component (slotted content is
styled by the host document, not the shadow tree).")
(defconst argdown--iframe-click-guard
;; concat + \n: short source lines, and a multi-line script in the emitted
;; #+RESULTS rather than one giant line.
(concat
"<script>\n"
"(function(){\n"
" var sx=null, sy=null, moved=false;\n"
" addEventListener('pointerdown', function(e){ sx=e.clientX; sy=e.clientY; moved=false; }, true);\n"
" addEventListener('pointermove', function(e){\n"
" if(sx!==null && Math.hypot(e.clientX-sx, e.clientY-sy) > 6) moved=true; }, true);\n"
" addEventListener('click', function(e){\n"
" if(moved){ e.preventDefault(); e.stopPropagation(); moved=false; } }, true);\n"
"})();\n"
"</script>")
"The one bit of behaviour we add to the web-component: cancel the click that
*ends a drag*. Argdown's component pans the map on pointer-drag but doesn't
suppress the trailing `click', so releasing a pan over a source node fired its
link — opening Légifrance mid-pan. This capture-phase guard remembers whether
the pointer moved past ~6px between `pointerdown' and `click'; if so it
`preventDefault'+`stopPropagation's the click (no navigation) — a still click
passes through and follows the link. Capture phase + a window listener run
before the component's own handlers, so it works without touching the
component. It is NOT a pan/zoom reimplementation — the component still does
all the panning and zooming; we only veto the spurious end-of-drag click.")
(defconst argdown--open-in-tab
;; concat + \n: short source lines, and a readable multi-line script in the
;; emitted #+RESULTS rather than one giant line.
(concat
"<button id=\"argdown-open-tab\" title=\"open this map in a new tab\""
" style=\"position:fixed;top:8px;right:8px;z-index:2147483647;"
"font:13px/1 sans-serif;padding:6px 9px;border:1px solid #ccc;border-radius:6px;"
"background:#fff;color:#1a73e8;cursor:pointer;display:none\">"
"⛶ open in new tab</button>\n"
"<script>\n"
"(function(){\n"
" var b=document.getElementById('argdown-open-tab');\n"
" if(!b) return;\n"
" if(window.top===window.self){ b.remove(); return; }\n"
" b.style.display='block';\n"
" b.addEventListener('click', function(){\n"
" var html='<!DOCTYPE html>\\n'+document.documentElement.outerHTML;\n"
" var url=URL.createObjectURL(new Blob([html],{type:'text/html'}));\n"
" window.open(url,'_blank');\n"
" });\n"
"})();\n"
"</script>")
"An *open-in-tab* button for the publish iframe. The web-component's own
fullscreen button is not always a real fullscreen: depending on the browser and
the embedding page's permissions policy it only fills the 70vh `srcdoc' *stage*,
not the screen — so there is no fullscreen view. This button is the way out: on
click it serialises the iframe's *own* document to a `blob:' URL and opens it as
a top-level tab, where the map fills the whole viewport (and the component's
fullscreen then works against the full screen). The publish path has no
standalone URL — the map lives only inside `srcdoc' — so re-serialising the live
document is the only handle we have. Guarded on `window.top !== window.self': it
shows only when framed, and removes itself in the resulting top-level tab (and in
`konix/argdown-preview', already top-level), so it never clutters a full view.")
(defun argdown--srcdoc-escape (html)
"Escape HTML for a double-quoted `srcdoc' attribute value.
Escape `&', `<', `>', `\"' and `=' (escape `&' first) — the browser peels
off exactly one layer before parsing the value as a document, so inner
entities like `'' survive and the tags come back intact. `<'/`>' must
go too: leaving them raw lets Hugo's link rewriter find tags *inside* the
attribute and corrupt them. And `=' must go because that rewriter is a
blunt `s|target="_blank" href=|target=\"_blank\" target="_blank" href=|' sed: the web-component pulls a CDN
`<link target="_blank" href=…>' stylesheet, and even with the `<' escaped the literal text
`target="_blank" href=' is still there for the sed to match — injecting raw quotes that
truncate the srcdoc. Encoding every `=' as `=' leaves nothing for it
to match; the browser decodes `='→`=' in the same single pass. Runs of
blank lines are collapsed to a single newline (a blank line ends a Goldmark
type-6 HTML block); real newlines stay, so the emitted attribute is readable
in the org source rather than one giant line."
(let* ((s (replace-regexp-in-string "&" "&" html t t))
(s (replace-regexp-in-string "<" "<" s t t))
(s (replace-regexp-in-string ">" ">" s t t))
(s (replace-regexp-in-string "\"" """ s t t))
(s (replace-regexp-in-string "=" "=" s t t))
(s (replace-regexp-in-string "\n\\(?:[ \t]*\n\\)+" "\n" s)))
s))
(defun argdown--document (content-html)
"Wrap CONTENT-HTML in a standalone HTML document.
This is the single source of truth for both destinations: the publish path
embeds it in the iframe (`argdown--iframe-wrap'), and `konix/argdown-preview'
opens it in a browser — so the preview is faithful by construction. CONTENT
is Argdown's web-component (it carries its own CDN css/js and toolbar); the
document adds a margin reset + link affordance (`argdown--iframe-reset-style')
and the end-of-drag click guard (`argdown--iframe-click-guard'), plus the
framed-only open-in-tab escape hatch (`argdown--open-in-tab'). Newlines
between parts keep the embedded form readable in the org source."
(concat "<!DOCTYPE html>\n<html><head><meta charset=\"utf-8\">\n"
argdown--iframe-reset-style "\n</head>\n<body>\n"
content-html "\n" argdown--iframe-click-guard
"\n" argdown--open-in-tab
"\n</body></html>"))
(defun argdown--iframe-wrap (content-html)
"Wrap CONTENT-HTML in a srcdoc iframe (the publish path), entity-escaping
`argdown--document' so it survives Goldmark and the link rewriter (see
`argdown-helpers' narrative). `allowfullscreen' lets the web-component's
fullscreen button expand the map out of its 70vh stage."
(format
"<iframe class=\"argdown-frame\" allowfullscreen style=\"width:100%%;height:70vh;min-height:320px;border:0\" srcdoc=\"%s\"></iframe>"
(argdown--srcdoc-escape (argdown--document content-html))))
(defun argdown--run (cmd)
"Run argdown shell CMD, returning stdout. On a non-zero exit or empty
output, signal an error carrying argdown's OWN diagnostics — stderr, or a
re-run without `--silent' — instead of letting a downstream JSON/SVG parse
fail with a cryptic \"End of file while parsing JSON\"."
(let ((err (make-temp-file "argdown-err")) out code stderr)
(unwind-protect
(progn
(with-temp-buffer
(setq code (call-process-shell-command
cmd nil (list (current-buffer) err) nil))
(setq out (buffer-string)))
(setq stderr (with-temp-buffer
(insert-file-contents err) (buffer-string)))
(when (or (not (eq code 0)) (string-empty-p (string-trim out)))
(let ((diag (string-trim stderr)))
(when (string-empty-p diag) ; --silent can swallow the error
(setq diag (string-trim
(shell-command-to-string
(concat (replace-regexp-in-string " --silent\\b" "" cmd)
" 2>&1")))))
(error "argdown failed (exit %s): %s" code
(if (string-empty-p diag) out diag))))
out)
(delete-file err))))
(defun argdown--stdout (fmt in)
"Return Argdown's own map of INPUT file exported in FMT, via --stdout."
(argdown--run
(format "argdown map -f %s --stdout --silent %s"
(shell-quote-argument fmt) (shell-quote-argument in))))
(defun argdown--map-svg (in)
"Return Argdown's own SVG map of INPUT file with a clickable source link
injected into each node that cites one (matched by `xlink:title' against the
`argdown json' link model — see `argdown--inject-links'). We ride Argdown's
renderer — its layout, argument→conclusion edges, styling — and only splice
in the href. The `<?xml?>'/doctype prolog is stripped so the SVG embeds in
an HTML body."
(let* ((raw (argdown--stdout "svg" in))
(svg (if (string-match "<svg" raw) (substring raw (match-beginning 0)) raw)))
(argdown--inject-links svg (argdown--source-urls in))))
(defun argdown--web-component (in)
"Return Argdown's own web-component HTML for INPUT file, with a clickable
source link injected into each node that cites one. `argdown web-component'
emits the CDN `<link>'/`<script>' tags plus a `<figure>'/`<argdown-map>'
whose `<div slot=\"map\">' holds the very SVG `argdown map' produces — same
`<a xlink:title>' nodes `argdown--inject-links' splices an `xlink:href' into.
We ride the whole viewer (its map, zoom, fullscreen and source-toggle
toolbar); the href is the only thing that's ours."
(argdown--inject-links
(org-babel-eval
(format "argdown web-component --stdout --silent %s" (shell-quote-argument in))
"")
(argdown--source-urls in)))
(defun argdown--publish-content (in)
"The published view of INPUT file: Argdown's web-component, whose slotted
map nodes carry the clickable source links (`[label](url)' → real `<a href>')."
(argdown--web-component in))
(defun argdown--publish-iframe (in)
"Return the published map for INPUT file as a web-component srcdoc iframe."
(argdown--iframe-wrap (argdown--publish-content in)))
(defun argdown--render (fmt in out)
"Render INPUT file's map to file OUT in FMT, using Argdown's renderer.
`svg' goes through `argdown--map-svg' (so the saved map is clickable too);
`dot'/`gv' write Argdown's DOT; `pdf' uses Argdown's bundled Graphviz (it
refuses stdout, so via a temp folder); png/jpg/webp are an ImageMagick step
on the svg."
(pcase fmt
("svg" (with-temp-file out (insert (argdown--map-svg in))))
((or "dot" "gv") (with-temp-file out (insert (argdown--stdout "dot" in))))
("pdf"
(let ((dir (make-temp-file "argdown-pdf" t)))
(unwind-protect
(progn
(org-babel-eval
(format "argdown map -f pdf --silent %s %s"
(shell-quote-argument in) (shell-quote-argument dir)) "")
(let ((made (car (directory-files dir t "\\.pdf\\'"))))
(unless made (error "argdown: pdf export produced no file"))
(copy-file made out t)))
(delete-directory dir t))))
((or "png" "jpg" "jpeg" "webp")
(let ((svg (org-babel-temp-file "argdown-" ".svg"))
(magick (or (executable-find "magick") (executable-find "convert"))))
(unless magick (error "argdown: need ImageMagick (magick/convert) for %s" fmt))
(with-temp-file svg (insert (argdown--map-svg in)))
(org-babel-eval (format "%s %s %s" magick
(shell-quote-argument (expand-file-name svg))
(shell-quote-argument (expand-file-name out))) "")))
(_ (error "argdown: unsupported :file format %s" fmt))))
(defun argdown--to-ipfs (file suffix)
"Upload FILE to IPFS via `konix/ipfa-buffer', return the URL plus SUFFIX."
(with-temp-buffer
(set-buffer-multibyte nil)
(insert-file-contents-literally file)
(concat (konix/ipfa-buffer nil) suffix)))
The dispatcher, plus a preview command. :file wins (render there, return
nil so Org inserts the link); otherwise :results output html gives the
map iframe (clickable links), and :results … pdf|png upload to IPFS — exactly the
modes the existing notes already use. argdown--compose prepends the
:argdown-include / :argdown-collect fragments first (see the
collector). konix/argdown-preview reuses that same compose and the same
standalone argdown--document to open the block at point in a browser —
what you preview is what you publish — so I can eyeball a map before it
ships.
(defconst argdown--epistemic-tag-colors
'(;; GENERIC ladder of proof — by warrant TYPE, weakest→strongest. Grounded
;; in the zététique « échelle de la preuve » (Durand) and the AFIS
;; « niveaux de preuve » (Caroti), themselves resting on Hume/Laplace and
;; the GRADE evidence hierarchy — not invented here.
("bare assertion" . "#a50026")
("interested testimony" . "#d73027")
("anecdote" . "#f46d43")
("received opinion" . "#fdae61")
("disinterested testimony" . "#fee08b")
("expert judgment" . "#d9ef8b")
("convergent testimony" . "#a6d96a")
("documented observation" . "#66bd63")
("reproducible study" . "#1a9850")
("established consensus" . "#006837")
;; Evidence-LAW aliases — the same rungs in legal vocabulary, at the
;; matching colour, so law reads as one INSTANTIATION of the generic
;; ladder and existing legal notes keep rendering. (présomption is
;; legacy: a derivation, not a warrant — new notes let PCS propagation
;; colour the conclusion instead of tagging it.)
("affirmation péremptoire" . "#a50026") ; = bare assertion
("témoignage d'une partie" . "#d73027") ; = interested testimony
("témoignage de tiers" . "#fee08b") ; = disinterested testimony
("présomption" . "#a6d96a") ; legacy (a derivation)
("constat" . "#66bd63") ; = documented observation
("acte authentique" . "#006837")) ; = established consensus
"House epistemic-strength scale for argument-map tags, weakest→strongest, as
a dialed-back red→green (RdYlGn) ramp. A statement/argument tagged
`#(<level>)' takes that colour as its node border; the *pure* red/green are
left to the relation edges (attack/support), so the tags use the muted RdYlGn
hues. The rungs are a GENERIC ladder of proof (by warrant type), so the scale
serves any domain; the legal terms are aliases mapping evidence-law's types
onto the same rungs/colours. A cross-note convention — injected into *every*
map by `argdown--frontmatter', never redefined per note. See the \"Epistemic
nuance scale\" section.")
(defconst argdown--epistemic-ramp
'("#a50026" "#d73027" "#f46d43" "#fdae61" "#fee08b"
"#d9ef8b" "#a6d96a" "#66bd63" "#1a9850" "#006837")
"The dialed-back red→green ramp, indexed by epistemic RANK 0 (weakest) → 9
(strongest) — the colour carrier for `argdown--epistemic-tag-rank' and for the
propagated conclusion/argument colours. Pure #ff0000/#00ff00 stay reserved for
the relation edges, so these are the muted RdYlGn hues.")
(defconst argdown--epistemic-tag-rank
'(("bare assertion" . 0)
("interested testimony" . 1)
("anecdote" . 2)
("received opinion" . 3)
("disinterested testimony" . 4)
("expert judgment" . 5)
("convergent testimony" . 6)
("documented observation" . 7)
("reproducible study" . 8)
("established consensus" . 9)
;; legal aliases → the rank of their generic rung
("affirmation péremptoire" . 0)
("témoignage d'une partie" . 1)
("témoignage de tiers" . 4)
("présomption" . 6)
("constat" . 7)
("acte authentique" . 9))
"Tag → epistemic RANK (0–9) on `argdown--epistemic-ramp'; legal aliases share
their generic rung's rank. Used by `argdown--strength-colors' to seed and
propagate weakest-link strength. (`argdown--epistemic-tag-colors' is the same
mapping pre-resolved to colours, for the `tagColors' frontmatter.)")
(defun argdown--yaml-key (s)
"Quote S as a YAML mapping key. Statement/argument titles carry spaces,
`≠', `:', `« »'… which a bare key cannot; double-quote and escape any `\"'."
(concat "\"" (replace-regexp-in-string "\"" "\\\\\"" s) "\""))
(defun argdown--color-map (key colors)
"A `color:' sub-block KEY (e.g. \"statementColors\") for COLORS (alist
title→hex), or nil when empty. Titles are quoted YAML keys (`argdown--yaml-key')."
(when colors
(concat "\n " key ":\n"
(mapconcat (lambda (c) (format " %s: \"%s\""
(argdown--yaml-key (car c)) (cdr c)))
colors "\n"))))
(defun argdown--frontmatter (mode &optional statement-colors argument-colors)
"The single frontmatter block prepended to every composed Argdown document:
the house epistemic tag colours (`argdown--epistemic-tag-colors', always); the
propagated conclusion border colours STATEMENT-COLORS and the per-argument fill
colours ARGUMENT-COLORS (alists title→hex, when given — see
`argdown--strength-colors'); and `model.mode: strict' when MODE is \"strict\".
Everything under one `===' block — Argdown accepts frontmatter only at the very
top and only once, so colours + mode must share it (a second block, or one
lower down, is a parse error)."
(concat
"===\ncolor:\n tagColors:\n"
(mapconcat (lambda (tc) (format " %s: \"%s\"" (car tc) (cdr tc)))
argdown--epistemic-tag-colors "\n")
(argdown--color-map "statementColors" statement-colors)
(argdown--color-map "argumentColors" argument-colors)
(and (equal mode "strict") "\nmodel:\n mode: strict")
"\n==="))
(defun argdown--compose (body params &optional statement-colors argument-colors)
"Prepend the frontmatter + :argdown-include / :argdown-collect fragments to
BODY per PARAMS — frontmatter first, then included premises, then collected
notes, then BODY — joined so Argdown merges them by title. Shared by
`org-babel-execute:argdown' and `konix/argdown-preview', so a preview composes
its sources exactly as the published render does. `argdown--frontmatter'
always leads with the house epistemic tag colours, optionally the propagated
conclusion border colours STATEMENT-COLORS and per-argument fill colours
ARGUMENT-COLORS (see `argdown--render-input'), and — when :argdown-mode is
\"strict\" — folds `model.mode: strict' into that same single block (Argdown
requires one frontmatter, at the very top, else a parse error): in strict mode
+ / - / >< between statements then read as logical entails / contrary /
contradictory instead of dialectical support / attack, while an argument's
+ / - stay support / attack."
(let ((inc (let ((c (cdr (assq :argdown-include params)))) (and c (format "%s" c))))
(col (let ((c (cdr (assq :argdown-collect params)))) (and c (format "%s" c))))
(mode (let ((c (cdr (assq :argdown-mode params)))) (and c (format "%s" c)))))
(mapconcat #'identity
(delq nil (list (argdown--frontmatter mode statement-colors argument-colors)
(and inc (konix/argdown--expand-includes inc))
(and col (konix/argdown-collect col))
body))
"\n\n")))
(defun org-babel-execute:argdown (body params)
"Render an Argdown BODY. Dispatch on headers:
- :file F -> write the map to F (svg/dot/pdf/png/jpg/webp),
return nil so Org inserts the [[file:F]] link
- :results output html -> web-component map iframe (interactive viewer:
zoom/fullscreen/source toggle), links clickable
- :results ... pdf|png -> render and upload to IPFS, return the URL
Composition (prepended to BODY via `argdown--compose'): :argdown-include REFS
pulls named blocks (local or `file.org:name', recursive); :argdown-collect SPEC
pulls whole linked notes. Argdown then merges everything by title."
(argdown--require-bin)
(let* ((full (argdown--render-input body params))
(rp (cdr (assq :result-params params)))
(file (cdr (assq :file params))))
(argdown--with-input
full
(lambda (in)
(cond
(file
(let ((fmt (let ((e (downcase (or (file-name-extension file) "svg"))))
(pcase e ("gv" "dot") ("jpeg" "jpg") (_ e)))))
(argdown--render fmt in (expand-file-name file))
nil))
((member "html" rp) (argdown--publish-iframe in))
((member "pdf" rp)
(let ((out (org-babel-temp-file "argdown-" ".pdf")))
(argdown--render "pdf" in out)
(argdown--to-ipfs out "?a.pdf")))
((member "png" rp)
(let ((out (org-babel-temp-file "argdown-" ".png")))
(argdown--render "png" in out)
(argdown--to-ipfs out "?a.png")))
(t (error "argdown: give :file F, or :results output html|pdf|png")))))))
(defun konix/argdown-preview ()
"Open the argdown src block at point as a standalone HTML document in a
browser — the very document the publish path embeds in its iframe, so what
you see is what you'll publish. Sources are composed (:argdown-include /
:argdown-collect) exactly as on render. The viewer is Argdown's
web-component, which loads its toolbar/zoom/fullscreen from a CDN — so the
interactive map needs the network; offline, only the static slotted SVG
paints."
(interactive)
(argdown--require-bin)
(let ((info (org-babel-get-src-block-info 'light)))
(unless (and info (equal (nth 0 info) "argdown"))
(user-error "Point is not in an argdown src block"))
(let* ((full (argdown--render-input (nth 1 info) (nth 2 info)))
(html (argdown--with-input
full (lambda (in)
(argdown--document (argdown--publish-content in)))))
(file (make-temp-file "argdown-preview-" nil ".html")))
(with-temp-file file (insert html))
(shell-command (format "clk ipfs browse '%s' &" file))
(message "argdown preview → %s" file))))
Defining org-babel-execute:argdown is enough for C-c C-c to
dispatch — not org-babel-do-load-languages, which would
require 'ob-argdown, a file that doesn’t exist.
Clickable links in Argdown’s own map
Argdown’s map can’t make a source link [label](url) clickable — it flattens
it to plain text. But it already wraps every node (and every edge) in <a xlink:title"…">= for the hover tooltip — just without an href. So the cheap,
faithful fix is to render with Argdown’s own argdown map -f svg — keeping
its tuned layout, its argument→conclusion edges, its styling — and merely
inject the href.
argdown--source-urls reads the model (argdown json) into a map of each
node’s description text → its first source url. Both consumers — the publish
path’s argdown--web-component and the :file path’s argdown--map-svg —
hand their SVG to argdown--inject-links, which walks each <a xlink:title>,
decodes Graphviz’s entities (argdown--svg-unescape), matches the title to a
url, and splices in xlink:href — making the whole node a clickable link to
its source. It also adds target"_blank"= (rel“noopener”): the published map lives in a =srcdoc iframe, and a source like Légifrance answers with
X-Frame-Options: DENY, so following the link inside the frame only shows
“refused to connect” — the source has to open at the top level, in a new tab.
The same <a xlink:title> nodes appear in Argdown’s standalone SVG and in the
web-component’s slotted SVG, so one injector serves both. Edge anchors (title
"support" …) don’t match and are left alone. A Graphviz node has a single
<a>, so it’s one link per node by construction (the first the statement
carries). The web-component draws every node alike, so the visual indicator
that a node is clickable — its bold title turned blue + underlined, the cursor
a pointer — is our own CSS on a[*|href] in argdown--iframe-reset-style, not
the component’s.
The match rides two assumptions about Argdown’s current output, and both fail
silently (a missing link, never a wrong one): the node anchor is exactly
<a xlink:title"…">= (a future Argdown adding another attribute to it would
break the regex), and argdown--svg-unescape covers only the entities Graphviz
emits today. If links ever stop appearing, look here first.
This is a deliberate un-rewrite. An earlier version generated the DOT itself (HTML-like labels) to carry the links — which reimplemented Argdown’s map layout and dropped its argument edges. Injecting into Argdown’s own SVG is less code, rides its renderer, and the link is the only thing that’s ours.
(require 'json)
(require 'cl-lib)
(defun argdown--json (in)
"Parse the `argdown json' model of INPUT file into an alist tree."
(json-parse-string
(argdown--run (format "argdown json --stdout --silent %s" (shell-quote-argument in)))
:object-type 'alist :array-type 'list :null-object nil :false-object nil))
(defun argdown--source-urls (in)
"Hash of each statement/argument description *text* → its first source url
(the `[label](url)' a node carries), from the `argdown json' model — the
lookup table for link injection. One source per node: only the first link a
statement carries is kept."
(let ((model (argdown--json in)) (h (make-hash-table :test 'equal)))
(dolist (key '(statements arguments))
(dolist (e (alist-get key model))
(let* ((m (car (alist-get 'members (cdr e))))
(url (and m (cl-some (lambda (r)
(and (equal (alist-get 'type r) "link")
(alist-get 'url r)))
(alist-get 'ranges m)))))
(when url (puthash (or (alist-get 'text m) "") url h)))))
h))
(defun argdown--svg-unescape (s)
"Decode the entities Graphviz writes into an SVG attribute, so an
`xlink:title' can be compared to the model's plain text."
(let* ((s (replace-regexp-in-string "'" "'" s t t))
(s (replace-regexp-in-string "-" "-" s t t))
(s (replace-regexp-in-string """ "\"" s t t))
(s (replace-regexp-in-string "<" "<" s t t))
(s (replace-regexp-in-string ">" ">" s t t)))
(replace-regexp-in-string "&" "&" s t t)))
(defun argdown--inject-links (svg urls)
"Splice xlink:href into each node <a> of SVG whose `xlink:title' matches a
text in URLS (text→url), making the whole node a clickable link to its
source. Argdown wraps every node — and every edge — in `<a xlink:title=…>'
with no href; an edge title (e.g. \"support\") has no model match and is left
untouched. Argdown's own layout, argument edges, and styling are preserved.
The link gets `target=\"_blank\"' (with `rel=\"noopener\"'): the map lives in
a srcdoc iframe, and a source like Légifrance sends `X-Frame-Options: DENY'
— navigating *inside* the frame would just show \"refused to connect\", so the
source must open at the top level, in a new tab."
(with-temp-buffer
(insert svg)
(goto-char (point-min))
(while (re-search-forward "<a xlink:title=\"\\([^\"]*\\)\">" nil t)
(let* ((attr (match-string 1))
(url (gethash (argdown--svg-unescape attr) urls)))
(when url
(replace-match
(concat "<a target=\"_blank\" rel=\"noopener\" xlink:href=\""
url "\" xlink:title=\"" attr "\">")
t t))))
(buffer-string)))
Color in the Hugo export
In Emacs the argdown-highlights font-lock above paints [statements],
<arguments> and the +=/-= relations. None of that survives ox-hugo:
a #+begin_src argdown block is emitted as a plain fenced code block, and
Hugo hands fenced blocks to Chroma, which has no argdown lexer (and
can’t load a custom one without recompiling). So the published block is
monochrome — the font-lock only ever colored the Emacs buffer.
Rather than teach Chroma argdown, I let Emacs itself colorize the block,
reusing the very font-lock I already wrote: org-html-fontify-code runs
argdown-mode over the body through htmlize, and — with
org-html-htmlize-output-type at inline-css — returns spans carrying
inline color: styles, theme-independent and needing no extra CSS. One
:around advice on org-hugo-src-block swaps in that path for the
argdown language; every other language still flows through Chroma
untouched. The editing colors and the published colors now share a single
source: the faces.
Two caveats. org-html-fontify-code strips the enclosing <pre>, so I
re-wrap it. And Goldmark must run with unsafe = true so the raw <pre>
reaches the page — the same setting the TODO/tag recoloring in
KONIX_AL-ox-hugo.el already relies on.
(defun konix/ox-hugo--argdown-html (src-block info)
"Return SRC-BLOCK fontified as inline-styled argdown HTML.
Chroma has no argdown lexer, so colorize with `argdown-mode' + htmlize.
`org-html-fontify-code' strips the enclosing <pre>, so re-wrap it."
(format "<pre class=\"src src-argdown\">\n%s</pre>\n"
(org-html-fontify-code
(org-export-format-code-default src-block info)
"argdown")))
(defun konix/ox-hugo-src-block--argdown (orig src-block contents info)
"Fontify argdown src blocks with Emacs; defer everything else to ORIG."
(if (string= (org-element-property :language src-block) "argdown")
(konix/ox-hugo--argdown-html src-block info)
(funcall orig src-block contents info)))
(with-eval-after-load 'ox-hugo
(advice-add 'org-hugo-src-block :around #'konix/ox-hugo-src-block--argdown))
Epistemic nuance scale
When I reconstruct an argument, a premise is rarely just true or false — it
sits somewhere on a scale of evidential weight. A bare assertion is not a
disinterested witness; a witness is not a documented record; none of them is a
reproducible study. I want that nuance visible on the map, not buried in
prose. So this is a small controlled vocabulary of #(tags) — a GENERIC ladder
of proof, ranked by the TYPE of warrant backing a claim, weakest → strongest. It
is grounded in the zététique « échelle de la preuve » (Thomas Durand) and the
AFIS « niveaux de preuve » (Denis Caroti), which rest in turn on Hume/Laplace and
the GRADE evidence hierarchy — not invented for this note:
| tag | what backs the claim |
|---|---|
#(bare assertion) |
nothing — stated as obvious, or untraceable rumour; worth ~0 |
#(interested testimony) |
a party with a stake in it asserts it |
#(anecdote) |
one uncontrolled first-hand report |
#(received opinion) |
a widely-held belief or tradition, not specifically sourced |
#(disinterested testimony) |
a neutral third party reports it |
#(expert judgment) |
a recognised authority in the domain assesses it |
#(convergent testimony) |
several independent sources concur |
#(documented observation) |
a recorded measurement, artefact or primary record |
#(reproducible study) |
methodical, peer-reviewed, reproducible work / experiment |
#(established consensus) |
converging replicated evidence — the domain’s gold standard |
The ladder is generic BY DESIGN: each domain plugs its own gold standard into
the top rungs — in law an authenticated deed, in medicine a meta-analysis, in
maths a proof, in history converging primary sources. Evidence-law vocabulary is
kept as aliases at the matching colour — #(affirmation péremptoire) = bare
assertion, #(témoignage d'une partie) = interested testimony, #(témoignage de tiers) = disinterested testimony, #(constat) = documented observation,
#(acte authentique) = established consensus — so legal notes render unchanged.
(#(présomption) is legacy: it is a derivation, not a warrant type — let a PCS
propagate strength to its conclusion rather than tagging it.)
The colour is the carrier: each tag paints its node’s border on a
dialed-back red→green (RdYlGn) ramp, weak (rouge) → strong (vert). Two design
choices are load-bearing. First, the pure #ff0000 / #00ff00 are reserved
for the relation edges (attack / support — Argdown’s own convention), so the
tags use the muted RdYlGn hues to stay distinguishable from the arrows; and the
ramp then harmonises with the edges (a weak claim in red attacking in red, a
strong one in green supporting in green). Second, red→green is not
colour-blind-safe — a deliberate trade for the “vert = solide/vrai” intuition
that matches the edge colours; an RdYlBu ramp is the fallback if that ever
matters.
This vocabulary is cross-note, so it lives in the tool, not in any single
note: argdown--epistemic-tag-colors defines the scale and
argdown--frontmatter injects it (as Argdown color.tagColors) at the top of
every composed map — see the org-babel bridge. So a note never declares the
palette; it just tags a statement and the colour follows:
[fence maintained 30y]: One neighbour has maintained the fence for 30 years. #(interested testimony)
Untagged nodes keep Argdown’s default colour — the scale only marks what I’ve
weighed. Adding/retuning a level is a one-line edit to
argdown--epistemic-tag-colors, and it re-colours every map at once (the
epistemic-colors-test in A worked example pins that the frontmatter is
injected at the top, and that a tagged node takes its colour).
The scale rendered (the borders are the babel-injected tagColors; the chain’s
support arrows are only there because Argdown drops a relationless statement):
Propagating strength to conclusions
A premise wears its evidential weight; a conclusion should wear the weight of the argument that carries it — a chain is only as strong as its weakest link. So conclusion nodes are coloured automatically, by propagating the tag scale through the argument structure, with no per-note declaration.
The rule (argdown--strength): an asserted tag always wins; otherwise a
conclusion’s strength is the best (max) over the arguments concluding it
of the weakest (min) of that argument’s premises — recursively, so a
premise that is itself a conclusion inherits its computed strength. Untagged
premises don’t count; a conclusion with no tagged support keeps Argdown’s
default colour. What this paints is the evidential force of the best
supporting argument, not net acceptability — attacks stay the business of the
red edges, they don’t lighten a well-sourced-but-rebutted conclusion.
A second, independent axis: how strongly the premises bring the conclusion —
the inference. Gold premises behind a non-sequitur still yield a weak
conclusion. Mark it on the `—-’ as data — -- {force: "<level>"} -- — on the
same argdown--inference-force-ranks scale (non sequitur … déductive), and it
becomes one more link in the weakest-link min: an argument’s strength is the
weakest of its premises and its inference. So a perfect premise under a weak
inference drags the conclusion down, exactly as it should. The inference is not
a node of its own, so each argument box is also tinted (a lightened shade,
`argdown–lighten’, so the black label stays readable) with that argument’s own
strength — you see the inference’s grade where the inference lives.
Mechanism: argdown--strength-colors reads the argdown json model (each
argument’s pcs gives premise / main-conclusion members by role and the
inference’s data.force nested under a member; each statement its tags and
isUsedAs…Conclusion flags), computes ranks, and returns a cons of two alists —
conclusion→colour (borders) and argument→*lightened* colour (fills).
argdown--render-input then composes twice — once to read the model, once more
injecting those as Argdown’s own color.statementColors (which outrank
tagColors, so only untagged conclusions are touched) and color.argumentColors
— skipping the extra model pass entirely when the input carries no #(tag). No
SVG post-processing: Argdown colours the nodes itself.
(defconst argdown--inference-force-ranks
'(("non sequitur" . 0) ("ténue" . 2) ("plausible" . 4)
("solide" . 6) ("forte" . 8) ("déductive" . 9))
"How strongly the premises bring the conclusion — a scale for the *inference*
(the `----'), independent of the premises' evidential weight, spread onto the
same 0–9 rank as `argdown--epistemic-ramp' so the two combine by weakest link
(`déductive' = 9 never caps a premise-driven rank; `non sequitur' = 0 collapses
it). Marked as inference data: `-- {force: \"<level>\"} --'.")
(defun argdown--lighten (hex frac)
"Blend HEX (\"#rrggbb\") toward white by FRAC (0.0–1.0). For argument fills:
the whole box takes the colour, so a lightened shade keeps the black label
readable while still reading as the rank's hue."
(let* ((r (string-to-number (substring hex 1 3) 16))
(g (string-to-number (substring hex 3 5) 16))
(b (string-to-number (substring hex 5 7) 16))
(mix (lambda (c) (round (+ c (* (- 255 c) frac))))))
(format "#%02x%02x%02x" (funcall mix r) (funcall mix g) (funcall mix b))))
(defun argdown--arg-inference-rank (pcs)
"Weakest inference-force rank among PCS's inference steps (`data.force' →
`argdown--inference-force-ranks'), or nil if none is marked."
(let ((r nil))
(dolist (m pcs)
(let* ((inf (alist-get 'inference m))
(force (and inf (alist-get 'force (alist-get 'data inf))))
(fr (and force (cdr (assoc force argdown--inference-force-ranks)))))
(when fr (setq r (if r (min r fr) fr)))))
r))
(defun argdown--strength (title tag-rank concludes memo inprog)
"Propagated epistemic rank of statement TITLE — an index into
`argdown--epistemic-ramp' (lower = weaker) — or nil if undetermined.
An asserted tag wins; else the best (max) supporting argument's weakest (min)
link — the links being the argument's premises AND its inference force.
Recursive over chains, memoised in MEMO, cycle-guarded by INPROG. CONCLUDES
maps a conclusion title to a list of (premise-titles . inference-rank), one per
concluding argument; TAG-RANK maps a tagged statement title to its rank."
(let ((cached (gethash title memo 'unset)))
(cond
((not (eq cached 'unset)) (and (numberp cached) cached))
((gethash title inprog) nil) ; cycle: break the back-edge
(t
(puthash title t inprog)
(let ((result
(or (gethash title tag-rank) ; asserted tag wins
(let ((best nil))
(dolist (arg (gethash title concludes))
(let ((mn (cdr arg))) ; seed with the inference rank
(dolist (p (car arg))
(let ((ps (argdown--strength p tag-rank concludes memo inprog)))
(when ps (setq mn (if mn (min mn ps) ps)))))
(when mn (setq best (if best (max best mn) mn)))))
best)))) ; best argument across the lot
(remhash title inprog)
(puthash title (or result 'none) memo)
result)))))
(defun argdown--strength-colors (in)
"Propagate the epistemic scale through INPUT file's argument structure
(weakest-link over premises AND inference force, see `argdown--strength').
Return (STATEMENT-COLORS . ARGUMENT-COLORS): conclusion title→border hex (only
UNTAGGED conclusions — asserted tags keep their own colour, and `statementColors'
would otherwise override them), and argument title→*lightened* fill hex (that
argument's own weakest link)."
(let* ((model (argdown--json in))
(tag-rank (make-hash-table :test 'equal))
(concludes (make-hash-table :test 'equal))
(memo (make-hash-table :test 'equal))
(inprog (make-hash-table :test 'equal))
(args nil) (scolors nil) (acolors nil))
(dolist (s (alist-get 'statements model))
(let* ((st (cdr s))
(title (alist-get 'title st))
(tags (alist-get 'tags st))
(rank (cl-some (lambda (tag)
(cdr (assoc tag argdown--epistemic-tag-rank)))
tags)))
(when (and title rank) (puthash title rank tag-rank))))
(dolist (a (alist-get 'arguments model))
(let* ((arg (cdr a))
(atitle (alist-get 'title arg))
(pcs (alist-get 'pcs arg))
(concl (cl-some (lambda (m) (and (equal (alist-get 'role m) "main-conclusion")
(alist-get 'title m)))
pcs))
(premises (delq nil (mapcar (lambda (m)
(and (equal (alist-get 'role m) "premise")
(alist-get 'title m)))
pcs)))
(inf (argdown--arg-inference-rank pcs)))
(when (and concl (or premises inf))
(puthash concl (cons (cons premises inf) (gethash concl concludes)) concludes))
(when atitle (push (list atitle premises inf) args))))
;; conclusion border colours — untagged conclusions only
(dolist (s (alist-get 'statements model))
(let* ((st (cdr s))
(title (alist-get 'title st)))
(when (and title
(or (alist-get 'isUsedAsMainConclusion st)
(alist-get 'isUsedAsIntermediaryConclusion st))
(not (gethash title tag-rank)))
(let ((rank (argdown--strength title tag-rank concludes memo inprog)))
(when rank
(push (cons title (nth rank argdown--epistemic-ramp)) scolors))))))
;; argument fill colours — each argument's own weakest link, lightened
(dolist (a args)
(let ((atitle (nth 0 a)) (mn (nth 2 a))) ; seed with inference rank
(dolist (p (nth 1 a))
(let ((ps (argdown--strength p tag-rank concludes memo inprog)))
(when ps (setq mn (if mn (min mn ps) ps)))))
(when mn
(push (cons atitle (argdown--lighten
(nth mn argdown--epistemic-ramp) 0.7))
acolors))))
(cons scolors acolors)))
(defun argdown--render-input (body params)
"Composed Argdown for BODY/PARAMS, with conclusion borders and argument fills
coloured by propagated epistemic strength (weakest-link over premises AND
inference force; see `argdown--strength-colors'). Two-pass: compose once, read
the model, recompose injecting the colours as `statementColors' / `argumentColors'.
The model pass is skipped when the input carries no tag nor inference force."
(let ((full (argdown--compose body params)))
(if (not (string-match-p "#(\\|force:" full))
full
(let* ((colors (argdown--with-input full #'argdown--strength-colors))
(scolors (car colors)) (acolors (cdr colors)))
(if (or scolors acolors)
(argdown--compose body params scolors acolors)
full)))))
Aggregating across notes
The reason I want this in my zettelkasten at all: an argument doesn’t
live in one note. I jot a fragment where the thought lands — a claim
in one note, an objection in another — and later I want a synthesis
note that shows the whole debate as one map. The covid note already
does the small version of this with in-file noweb (<<belief>>); the
collector does it across notes.
The trick is Argdown’s own semantics: statements [Title] and
arguments <Title> merge by title across the whole input, and
relations are additive. So aggregation is just concatenation: gather
the argdown blocks from a set of notes, paste them together, let
Argdown weave them into one graph. A fragment that says [X] + <Y> in
one note and [X] - <Z> in another yields a single [X] with both
branches. No merge logic of my own — titles are the join keys.
Which notes? Four ways to name the set: the notes this synthesis links to (id:
links anywhere in the buffer, links), the notes linked within the
current heading subtree (subtree — the section that owns the
block, matching my per-section “n’utilise en source que les notes
mentionnées ici” convention), the notes that link here
(backlinks), or every note carrying a tag. A synthesis note is
then a single block:
#+begin_src argdown :results output html :argdown-collect subtree
[thèse]: my central claim, tying the fragments together.
#+end_src
konix/argdown-collect resolves the set to a list of files, reads
every argdown src block out of each (via org-element, so it
doesn’t depend on org’s flaky cross-file noweb), and concatenates
their bodies. The synthesis note’s own body is appended last by the
dispatcher, so its [thèse] sits with the gathered fragments.
(defun konix/argdown--bodies-in-file (file)
"Return the bodies of every argdown src block in FILE.
Common leading indentation is stripped (`org-remove-indentation') so the
fragment's top-level statements land in column 0 — Argdown is
indentation-sensitive, and blocks are often indented under a heading."
(with-temp-buffer
(insert-file-contents file)
(delay-mode-hooks (org-mode))
(org-element-map (org-element-parse-buffer) 'src-block
(lambda (sb)
(when (string= (org-element-property :language sb) "argdown")
(string-trim-right
(org-remove-indentation (or (org-element-property :value sb) ""))))))))
(defun konix/argdown--link-ids (&optional subtree)
"Return the `id:' link targets in the current buffer, in order.
With SUBTREE non-nil, restrict to the heading subtree at point (the section
that owns the block being rendered) — this honours the per-section
\"n'utilise en source que les notes mentionnées ici\" convention."
(save-excursion
(save-restriction
(when (and subtree (not (org-before-first-heading-p)))
(org-back-to-heading t)
(org-narrow-to-subtree))
(let (ids)
(org-element-map (org-element-parse-buffer) 'link
(lambda (l)
(when (string= (org-element-property :type l) "id")
(push (org-element-property :path l) ids))))
(nreverse ids)))))
(defun konix/argdown--linked-files (&optional subtree)
"Files of the `id:' links in the current buffer (or SUBTREE at point)."
(delete-dups
(delq nil
(mapcar (lambda (id)
(when-let* ((node (org-roam-node-from-id id)))
(org-roam-node-file node)))
(konix/argdown--link-ids subtree)))))
(defun konix/argdown--backlink-files ()
"Files of notes that link to any node in the current file."
(delete-dups
(delq nil
(mapcar (lambda (bl)
(ignore-errors
(org-roam-node-file (org-roam-backlink-source-node bl))))
(apply #'append
(mapcar #'org-roam-backlinks-get
(konix/org-roam-nodes-in-file)))))))
(defun konix/argdown--tagged-files (tag)
"Files of notes carrying TAG."
(delete-dups
(delq nil
(mapcar (lambda (row)
(when-let* ((node (org-roam-node-from-id (car row))))
(org-roam-node-file node)))
(org-roam-db-query
[:select [node_id] :from tags :where (= tag $s1)] tag)))))
(defun konix/argdown-collect (spec)
"Concatenate argdown fragments from notes selected by SPEC.
SPEC is \"links\" (notes this one links to), \"subtree\" (notes linked within
the current heading subtree), \"backlinks\" (notes linking here), or a tag
name. The current file is always excluded. Argdown merges
statements/arguments by title, so the concatenation renders as one map."
(require 'org-roam)
(let* ((files (pcase spec
("links" (konix/argdown--linked-files))
("subtree" (konix/argdown--linked-files t))
("backlinks" (konix/argdown--backlink-files))
(_ (konix/argdown--tagged-files spec))))
(self (buffer-file-name))
(files (cl-remove-if (lambda (f) (and self (file-equal-p f self))) files)))
(mapconcat (lambda (f)
(mapconcat #'identity (konix/argdown--bodies-in-file f) "\n\n"))
files "\n\n")))
;;; :argdown-include — precise composition by named block (« notre mode »)
(defun konix/argdown--parse-include (params)
"Extract the :argdown-include value from a src-block PARAMS string, or nil.
Stops at the next ` :key', so values may contain colons (file.org:name)."
(when (and params
(string-match
":argdown-include[ \t]+\\(.*?\\)\\(?:[ \t]+:[a-zA-Z]\\|$\\)" params))
(match-string 1 params)))
(defun konix/argdown--resolve-file (file)
"Resolve a .org FILE ref to an absolute path among the roam notes."
(or (and (file-name-absolute-p file) file)
(and (boundp 'org-roam-directory)
(let ((p (expand-file-name file org-roam-directory)))
(and (file-exists-p p) p)))
(expand-file-name file)))
(defun konix/argdown--named-block (name &optional file)
"Return (VALUE . PARAMS) of the argdown src block named NAME in FILE
\(or the current buffer when FILE is nil). VALUE is dedented."
(let ((find
(lambda ()
(org-element-map (org-element-parse-buffer) 'src-block
(lambda (sb)
(when (and (string= (org-element-property :language sb) "argdown")
(equal (org-element-property :name sb) name))
(cons (org-remove-indentation
(or (org-element-property :value sb) ""))
(org-element-property :parameters sb))))
nil t))))
(if file
(with-temp-buffer
(insert-file-contents file)
(delay-mode-hooks (org-mode))
(funcall find))
(funcall find))))
(defun konix/argdown--expand-into (spec seen context-file)
"Resolve SPEC (refs string) to concatenated argdown.
Local refs resolve against CONTEXT-FILE (nil = current buffer); a `file.org:name'
ref switches the context to that file for its own sub-includes. SEEN is a hash
table keying (file . name) to break cycles. Included premises come first."
(let (out)
(dolist (ref (split-string (or spec "") "[ \t\n,]+" t))
(let* ((m (string-match "\\`\\(.+\\.org\\):\\(.+\\)\\'" ref))
(file (if m (konix/argdown--resolve-file (match-string 1 ref))
context-file))
(name (if m (match-string 2 ref) ref))
(key (format "%s\0%s" (or file "") name)))
(unless (gethash key seen)
(puthash key t seen)
(let ((blk (konix/argdown--named-block name file)))
(if (not blk)
(push (format "// [argdown-include introuvable : %s]" ref) out)
(let ((sub (konix/argdown--parse-include (cdr blk))))
(when sub
(push (konix/argdown--expand-into sub seen file) out)))
(push (car blk) out))))))
(mapconcat #'identity (nreverse out) "\n\n")))
(defun konix/argdown--expand-includes (spec)
"Public entry: resolve SPEC to concatenated argdown (recursive, cycle-safe)."
(konix/argdown--expand-into spec (make-hash-table :test 'equal) nil))
;;; Editing comfort — wrap long statement lines, on M-q
(defconst konix/argdown--marker-re
"[ \t]*\\(\\[[^]]*\\]\\|<[^>]*>\\|([0-9]+)\\|[-+]\\|><\\|=+\\|----\\|#\\)"
"Regexp matching the start of an argdown structural line (statement,
argument, premise number, relation, inference…), anchored at point via
`looking-at'. Lines that don't match are description continuations.")
(defun konix/argdown--stmt-bounds ()
"Return (BEG . END) of the argdown statement paragraph at point, or nil.
BEG is the bol of its structural start line, END the eol of its last
continuation line."
(save-excursion
(beginning-of-line)
(while (and (not (bobp))
(not (looking-at konix/argdown--marker-re))
(looking-at "[ \t]*\\S-"))
(forward-line -1))
(when (looking-at konix/argdown--marker-re)
(let ((beg (line-beginning-position)))
(forward-line 1)
(while (and (not (eobp))
(looking-at "[ \t]*\\S-")
(not (looking-at konix/argdown--marker-re)))
(forward-line 1))
(cons beg (line-end-position 0))))))
(defun konix/argdown-fill-paragraph (&optional _justify)
"Fill the argdown statement at point: merge its lines, re-wrap to
`fill-column' with continuation lines indented 4 more than the title (so
Argdown reads them as description continuations). Returns t, so it serves
as a `fill-paragraph-function'."
(interactive)
(let ((b (konix/argdown--stmt-bounds)))
(when b
(let* ((beg (car b)) (end (cdr b))
(lines (split-string (buffer-substring-no-properties beg end) "\n"))
(indent (progn (string-match "\\`[ \t]*" (car lines))
(match-string 0 (car lines))))
(text (mapconcat #'string-trim lines " "))
(cont (concat indent " "))
(fill (or fill-column 78))
(out '()) (curpref indent) (cur '()))
(dolist (w (split-string text " " t))
(let ((cand (concat curpref
(mapconcat #'identity (reverse (cons w cur)) " "))))
(if (and cur (> (length cand) fill))
(progn (push (concat curpref
(mapconcat #'identity (reverse cur) " ")) out)
(setq curpref cont cur (list w)))
(push w cur))))
(when cur
(push (concat curpref (mapconcat #'identity (reverse cur) " ")) out))
(delete-region beg end)
(goto-char beg)
(insert (mapconcat #'identity (reverse out) "\n")))))
t)
(defun konix/argdown-fill-buffer (&optional fill)
"Re-wrap every argdown statement of the current buffer's src blocks."
(interactive)
(let ((fill-column (or fill fill-column 78)))
(save-excursion
(goto-char (point-min))
(while (re-search-forward "^[ \t]*#\\+begin_src argdown" nil t)
(forward-line 1)
(let ((end (save-excursion
(and (re-search-forward "^[ \t]*#\\+end_src" nil t)
(copy-marker (match-beginning 0))))))
(when end
(while (< (point) (marker-position end))
(if (looking-at konix/argdown--marker-re)
(let ((b (konix/argdown--stmt-bounds)))
(konix/argdown-fill-paragraph)
(let ((b2 (konix/argdown--stmt-bounds)))
(goto-char (if b2 (cdr b2) (or (cdr b) (line-end-position))))
(forward-line 1)))
(forward-line 1)))
(goto-char (marker-position end))))))))
(defun konix/argdown--in-src-p ()
"Non-nil when point is inside an argdown src block of an org buffer."
(and (derived-mode-p 'org-mode)
(let ((el (org-element-context)))
(and (memq (org-element-type el) '(src-block inline-src-block))
(equal (org-element-property :language el) "argdown")))))
;; M-q inside argdown-mode (e.g. the C-c ' edit buffer, or .argdown files)
(add-hook 'argdown-mode-hook
(lambda ()
(setq-local fill-paragraph-function #'konix/argdown-fill-paragraph)))
;; M-q directly on a statement inside an argdown src block of an org note
(with-eval-after-load 'org
(advice-add 'org-fill-paragraph :before-until
(lambda (&rest _)
(and (konix/argdown--in-src-p)
(konix/argdown-fill-paragraph)))))
(provide 'KONIX_argdown)
;;; KONIX_argdown.el ends here
Editing comfort: wrap on M-q
A statement’s text is one long sentence, so a freshly-typed premise is one
very long line — annoying to read in the note and in the :exports code
output. Argdown accepts multi-line statement descriptions: a continuation
line indented more than the title is folded into the same description (it
even wraps in the map). So I wrap the source.
konix/argdown-fill-paragraph merges the statement at point and re-wraps it
to fill-column, continuations indented +4 (so the dedent done by
the collector / org-babel still lands the title in column 0). It’s wired
as fill-paragraph-function in argdown-mode and, via a :before-until
advice on org-fill-paragraph, runs when point is inside an argdown src
block of an org note — so plain M-q wraps a premise, both in the C-c '
edit buffer and directly in the note. konix/argdown-fill-buffer re-wraps
every block at once.
A worked example
The whole point is to write an argument in prose-ish text and see the map. Here’s a small one — should I keep relying on Argdown, given the single-maintainer risk? — that doubles as the fixture for the smoke test.
[Keep Argdown]: I should build on Argdown rather than roll my own.
+ <Layout is the hard part>: Argument layout (premise/conclusion
layering, pro/con grouping) is years of work I'd otherwise redo.
+ <Actively maintained>: v2.0.0 shipped in 2026 after a long pause.
- <Bus factor>: It is essentially a single-maintainer project.
+ <Fork is cheap>: MIT-licensed, and my Nix flake pins a known version.
The first pinned test, in the spirit of TDD: render the sample through
argdown--map-svg (Argdown’s own SVG + our link injection) and assert a
non-empty, well-formed SVG. Green means the chain holds — Argdown parses, lays
out, and renders the SVG with its bundled Graphviz (no system dot).
(if (not (executable-find "argdown"))
" SKIP: argdown not on PATH"
(let* ((body (concat
"[Keep Argdown]: I should build on Argdown rather than roll my own.\n"
" + <Layout is the hard part>: layout is years of work I'd redo.\n"
" - <Bus factor>: It is essentially a single-maintainer project.\n"))
(svg (argdown--with-input body #'argdown--map-svg)))
(cl-assert (and svg (> (length svg) 0)) nil "empty svg")
(cl-assert (string-match-p "<svg" svg) nil "not well-formed svg: %S" svg)
" PASS: argdown map -f svg + link injection renders a non-empty SVG"))
" PASS: argdown map -f svg + link injection renders a non-empty SVG"
The second test pins the aggregation core: two argdown fragments in
a note that share the title [Thèse], pulled out by
konix/argdown--bodies-in-file and concatenated, must render as one
map carrying both branches (<Pour> and <Contre>). This exercises
the extraction + Argdown’s merge-by-title without needing the org-roam
db, which only supplies the file list.
(let* ((dir (make-temp-file "argdown-collect" t))
(note (expand-file-name "frags.org" dir))
(out (expand-file-name "merged.svg" dir)))
(unwind-protect
(progn
(with-temp-file note
(insert "#+begin_src argdown\n[Thèse]: centrale.\n + <Pour>: un appui.\n#+end_src\n\n"
"#+begin_src argdown\n[Thèse]\n - <Contre>: une objection.\n#+end_src\n"))
(let* ((bodies (konix/argdown--bodies-in-file note))
(merged (mapconcat #'identity bodies "\n\n")))
(cl-assert (= (length bodies) 2) nil "expected 2 fragments, got %S" bodies)
(argdown--with-input merged (lambda (in) (argdown--render "svg" in out)))
(let ((svg (with-temp-buffer (insert-file-contents out) (buffer-string))))
(cl-assert (string-match-p "Pour" svg) nil "merged map missing <Pour>")
(cl-assert (string-match-p "Contre" svg) nil "merged map missing <Contre>")
" PASS: two fragments merged into one map (Pour + Contre under Thèse)")))
(delete-directory dir t)))
" PASS: two fragments merged into one map (Pour + Contre under Thèse)"
The third test pins the subtree scope of konix/argdown--link-ids:
with point inside a section, it must see the id: links of that
section (including its sub-headings) and not those of a sibling
section. This needs no org-roam — it checks the id targets the
collector would resolve, which is exactly where over-collection would
leak in.
(with-temp-buffer
(insert "* H1\nintro [[id:AAA][a]]\n** H1a\n[[id:BBB][b]]\n* H2\n[[id:CCC][c]]\n")
(delay-mode-hooks (org-mode))
(goto-char (point-min))
(forward-line 1) ; inside H1's body, before H1a
(let ((sub (konix/argdown--link-ids t))
(all (konix/argdown--link-ids nil)))
(cl-assert (equal sub '("AAA" "BBB")) nil "subtree ids: %S" sub)
(cl-assert (member "CCC" all) nil "buffer ids: %S" all)
(cl-assert (not (member "CCC" sub)) nil "subtree leaked sibling: %S" sub)
" PASS: subtree scope keeps the section's links, drops the sibling's"))
" PASS: subtree scope keeps the section's links, drops the sibling's"
The fourth test pins :argdown-include (« notre mode ») — composition by
named block, recursive and cross-file, without the name-ex() wrapper
boilerplate. A premise prem-a and an argument arg-b that declares
:argdown-include prem-a ; pulling arg-b must transitively bring prem-a,
and the rendered map must link [A] to <B>.
(let* ((dir (make-temp-file "argdown-include" t))
(f (expand-file-name "frags.org" dir))
(out (expand-file-name "m.svg" dir)))
(unwind-protect
(progn
(with-temp-file f
(insert "#+NAME: prem-a\n#+BEGIN_SRC argdown :eval no\n[A]: une prémisse.\n#+END_SRC\n\n"
"#+NAME: arg-b\n#+BEGIN_SRC argdown :argdown-include prem-a\n<B>: un argument.\n + [A]\n#+END_SRC\n"))
(let ((merged (konix/argdown--expand-includes (concat f ":arg-b"))))
(cl-assert (string-match-p "\\[A\\]: une prémisse" merged) nil
"include n'a pas tiré prem-a (récursif) : %S" merged)
(cl-assert (string-match-p "<B>: un argument" merged) nil
"include n'a pas tiré arg-b : %S" merged)
(argdown--with-input merged (lambda (in) (argdown--render "svg" in out)))
(let ((svg (with-temp-buffer (insert-file-contents out) (buffer-string))))
(cl-assert (string-match-p ">A<\\|>A \\|A</text>\\|une prémisse" svg) nil
"carte sans [A]")
" PASS: :argdown-include tire arg-b et, récursivement, sa prémisse prem-a (cross-fichier)")))
(delete-directory dir t)))
" PASS: :argdown-include tire arg-b et, récursivement, sa prémisse prem-a (cross-fichier)"
The fifth test pins the Hugo color path: exporting an argdown block
through the hugo backend must yield Emacs-fontified HTML — our
src-argdown wrapper carrying inline color: spans — and not a bare
Chroma fence. It drives the :around advice end to end, so green there
means the published map’s source listing is colored the way it is while
editing.
(progn
(require 'ox-hugo)
(let ((html (org-export-string-as
"#+begin_src argdown\n[Keep Argdown]: build on it.\n + <Layout>: the hard part.\n#+end_src\n"
'hugo t)))
(cl-assert (string-match-p "src-argdown" html) nil "no argdown wrapper: %S" html)
(cl-assert (string-match-p "color:" html) nil "no inline color span: %S" html)
(cl-assert (not (string-match-p "^```" html)) nil "leaked a chroma fence: %S" html)
" PASS: hugo export colorizes argdown via Emacs font-lock (inline css)"))
" PASS: hugo export colorizes argdown via Emacs font-lock (inline css)"
The sixth test pins the publish-path iframe against the two ways the
embed broke in the wild. The hostile blob carries a blank line, an indented
line, a quote and an <svg>. argdown--iframe-wrap must emit a multi-line
but blank-line-free <iframe srcdoc=…> (Goldmark passes a type-6 <iframe>
through, but a blank line would end the block), with <=/=>=/\"= all
entity-escaped so the only real tag is the outer iframe (else Hugo’s link
rewriter injects raw quotes and truncates the srcdoc); one browser
unescape pass must restore the component. It needs neither the CLI nor
ox-hugo.
(let* ((blob (concat
;; the CDN stylesheet the web-component pulls: a *bare* `target="_blank" href='
;; (not `xlink:href') — the exact shape Hugo's link-rewriting
;; sed `s|([^:])target="_blank" href=|...|' would corrupt unless `=' is encoded.
"<link rel=\"stylesheet\" target="_blank" href=\"https://cdn.example/x.css\">"
"<figure><argdown-map><pre><code>"
"[x]: a statement\n"
" indented continuation\n" ; indentation must survive untouched
"\n" ; a blank line — the one Goldmark trigger
"[y]: another\n</code></pre>"
"<svg><g id=\"graph0\"></g></svg>"
"</argdown-map></figure>"))
(frame (argdown--iframe-wrap blob)))
(cl-assert (string-prefix-p "<iframe" frame) nil "not an iframe: %S" frame)
;; Readable: keeps real newlines — no soup, no one giant line.
(cl-assert (string-match-p "\n" frame) nil "iframe collapsed to one line: %S" frame)
;; But carries NO blank line — the only thing that ends a Goldmark type-6
;; HTML block (<iframe> is a type-6 tag, so multi-line passes through).
(cl-assert (not (string-match-p "\n[ \t]*\n" frame)) nil "blank line would break Goldmark: %S" frame)
(cl-assert (string-match-p "srcdoc=\"" frame) nil "no srcdoc: %S" frame)
;; Unescape the srcdoc the way the browser would (one pass): everything
;; but the collapsed blank line survives — indentation, the svg, intact.
(string-match "srcdoc=\"\\(\\(?:.\\|\n\\)*\\)\"></iframe>\\'" frame)
(let* ((v (match-string 1 frame))
(un (replace-regexp-in-string
"&" "&"
(replace-regexp-in-string
""" "\""
(replace-regexp-in-string
">" ">"
(replace-regexp-in-string
"<" "<"
;; decode the numeric `=' entity FIRST (before `&'),
;; the way the browser's single pass does.
(replace-regexp-in-string "=" "=" v t t) t t) t t) t t) t t)))
;; The ONLY real tag is the outer <iframe>: no raw <, > inside the
;; attribute. Else the publish pipeline's link rewriter (it adds
;; target="_blank" to <a>/<link>) edits tags *inside* srcdoc and
;; injects raw quotes that truncate it — exactly what shipped once.
(cl-assert (not (string-match-p "[<>]" v)) nil "raw angle brackets in srcdoc: %S" v)
;; And NO literal `target="_blank" href=' survives: every `=' is encoded as `=', so
;; Hugo's `s|([^:])target="_blank" href=|...|' sed finds nothing to match in the CDN
;; `<link target="_blank" href=…>' (escaping `<' alone leaves the text `target="_blank" href=' intact —
;; that shipped a truncated srcdoc once). Reverting the `=' step in
;; `argdown--srcdoc-escape' turns this assertion red.
(cl-assert (not (string-match-p "target="_blank" href=" v)) nil
"literal target="_blank" href= survived — Hugo's sed would inject raw quotes and truncate: %S" v)
;; One browser unescape pass restores the component verbatim — including
;; the CDN link's href, decoded back from `='.
(cl-assert (string-search " indented continuation" un) nil "indentation lost: %S" un)
(cl-assert (string-search "id=\"graph0\"" un) nil "svg lost: %S" un)
(cl-assert (string-search "target="_blank" href=\"https://cdn.example/x.css\"" un) nil
"cdn link href lost on roundtrip: %S" un)
(cl-assert (not (string-match-p "\n[ \t]*\n" un)) nil "blank line survived into srcdoc: %S" un)
" PASS: srcdoc fully entity-escaped (no raw tags/quotes, no literal target="_blank" href=), roundtrips, multi-line, no blank lines"))
" PASS: srcdoc fully entity-escaped (no raw tags/quotes, no literal target="_blank" href=), roundtrips, multi-line, no blank lines"
The seventh test pins konix/argdown-preview’s contract: what you preview
is what you publish. argdown--document builds one standalone HTML doc;
the preview opens it in a browser, and the publish path embeds that same doc
in the iframe. So unescaping the iframe’s srcdoc must give back exactly the
document the preview shows — no drift between the two paths. It also pins the
open-in-tab escape hatch: the doc carries a button that re-opens the map as a
top-level tab (the component’s fullscreen can be stage-only), guarded so it
shows only when framed — never in the top-level preview.
(let* ((component "<figure><argdown-map><svg><g id=\"graph0\"></g></svg></argdown-map></figure>")
(doc (argdown--document component))
(frame (argdown--iframe-wrap component))
(v (progn (string-match "srcdoc=\"\\(\\(?:.\\|\n\\)*\\)\"></iframe>\\'" frame)
(match-string 1 frame)))
(un (replace-regexp-in-string
"&" "&"
(replace-regexp-in-string
""" "\""
(replace-regexp-in-string
">" ">"
(replace-regexp-in-string
"<" "<"
(replace-regexp-in-string "=" "=" v t t) t t) t t) t t) t t)))
(cl-assert (string-prefix-p "<!DOCTYPE html>" doc) nil "preview not a standalone doc: %S" doc)
(cl-assert (string-search component doc) nil "preview doc missing the component")
;; The web-component pans/zooms itself: the ONLY script we add is the
;; end-of-drag click guard (so a pan doesn't open the link). Pin that it
;; ships, and that we did NOT reimplement pan/zoom (no wheel handler).
(cl-assert (string-match-p "pointerdown" doc) nil "preview doc missing the drag click-guard: %S" doc)
(cl-assert (not (string-match-p "wheel" doc)) nil "preview doc reimplements pan/zoom: %S" doc)
;; The open-in-tab escape hatch: a button that re-opens the map as a
;; top-level tab (the component's fullscreen can be stage-only), guarded so
;; it shows ONLY when framed — never in this top-level preview.
(cl-assert (string-search "id=\"argdown-open-tab\"" doc) nil "preview doc missing the open-in-tab button: %S" doc)
(cl-assert (string-match-p "window\\.top===window\\.self" doc) nil "open-in-tab not guarded to framed-only: %S" doc)
(cl-assert (string-match-p "createObjectURL" doc) nil "open-in-tab does not open a new tab: %S" doc)
;; One document, two destinations: the iframe embeds exactly what preview shows.
(cl-assert (string-equal doc un) nil "preview/publish drift:\n%S\n---\n%S" doc un)
" PASS: preview builds the standalone doc the iframe embeds (preview == publish), with the framed-only open-in-tab button")
" PASS: preview builds the standalone doc the iframe embeds (preview == publish), with the framed-only open-in-tab button"
The core test of the renderer is the link injection (see We render the map):
Argdown’s SVG wraps each node in <a xlink:title"…">= with no href, so
argdown--inject-links splices xlink:href into the node whose title matches
a source (and leaves edge anchors like "support" alone). A pure unit test —
no CLI, deterministic — feeding a fake SVG + a text→url map; it also exercises
argdown--svg-unescape (the ' must decode to match the model text).
(let* ((svg (concat
"<g class=\"node\"><a xlink:title=\"l'art. 666 (art.666).\">"
"<text>x</text></a></g>"
"<g class=\"edge\"><a xlink:title=\"support\"></a></g>"))
(urls (let ((h (make-hash-table :test 'equal)))
(puthash "l'art. 666 (art.666)." "https://ex.test/666" h) h))
(out (argdown--inject-links svg urls)))
;; the matching node <a> becomes a whole-node clickable link that opens the
;; source in a new top-level tab (target=_blank: the iframe can't frame
;; Légifrance), with the title kept
(cl-assert (string-search
"<a target=\"_blank\" rel=\"noopener\" xlink:href=\"https://ex.test/666\" xlink:title=\"l'art. 666 (art.666).\">"
out)
nil "node href not injected: %S" out)
;; edge anchors (no model match) are untouched
(cl-assert (string-search "<a xlink:title=\"support\">" out) nil "edge anchor altered: %S" out)
" PASS: injects xlink:href into the matching node, leaves edge anchors alone")
" PASS: injects xlink:href into the matching node, leaves edge anchors alone"
The eighth test pins the publish path itself, end to end: :results output html ships Argdown’s web-component (the interactive viewer — map plus
zoom/fullscreen/source toolbar) whose slotted map nodes carry the clickable
source links — a link [Src](url) becomes a real <a href> in the map. It
renders a statement with a link through argdown--publish-iframe, unescapes
the srcdoc, and asserts the map <svg>, the clickable anchor, the CDN
bundle and the <argdown-map> custom element are all present. CLI-backed.
(if (not (executable-find "argdown"))
" SKIP: argdown not on PATH"
(let* ((body "[Critère]: l'art. 666, see [Src](https://example.com/x).\n + <Appui>: un appui.")
(frame (argdown--with-input body #'argdown--publish-iframe))
(v (progn (string-match "srcdoc=\"\\(\\(?:.\\|\n\\)*\\)\"></iframe>\\'" frame)
(match-string 1 frame)))
(un (replace-regexp-in-string
"&" "&"
(replace-regexp-in-string
""" "\""
(replace-regexp-in-string
">" ">"
(replace-regexp-in-string
"<" "<"
(replace-regexp-in-string "=" "=" v t t) t t) t t) t t) t t)))
(cl-assert (string-search "<svg" un) nil "map svg missing from publish output: %S" un)
(cl-assert (string-match-p "xlink:href=\"https://example.com/x\"" un) nil
"source link not clickable in the map: %S" un)
;; The source opens in a NEW TAB, not inside the frame (Légifrance & co.
;; send X-Frame-Options: DENY — in-frame navigation just \"refused to
;; connect\"). The anchor must carry target=_blank.
(cl-assert (string-match-p "<a target=\"_blank\"[^>]*xlink:href=\"https://example.com/x\"" un) nil
"source link does not open in a new tab (no target=_blank): %S" un)
;; The published view IS Argdown's web-component (its toolbar/zoom/
;; fullscreen): the CDN bundle and the custom element must be present.
(cl-assert (string-match-p "web-components" un) nil
"web-component bundle missing — toolbar/zoom would be gone: %S" un)
(cl-assert (string-search "<argdown-map" un) nil
"argdown-map custom element missing from publish output: %S" un)
" PASS: publish path ships Argdown's web-component with a clickable source link in a map node"))
" PASS: publish path ships Argdown's web-component with a clickable source link in a map node"
The ninth test pins :argdown-mode strict. By default + between two
statements reads as support (dialectical); in strict mode it becomes
entailment (logical) — and an argument’s +=/-= stay support/attack. The
test composes the same [B] + [A] both ways through argdown--compose and
asserts the relation’s relationType flips from support to entails,
pinning both the header plumbing and that the frontmatter reaches the very top
of the composed input (Argdown errors if it is anywhere else). The loose
control is what makes the test discriminate — a no-op would fail it.
(if (not (executable-find "argdown"))
" SKIP: argdown not on PATH"
(let* ((body "[A]: a.\n\n[B]: b.\n + [A]")
(reltype
(lambda (params)
(argdown--with-input
(argdown--compose body params)
(lambda (in)
(alist-get 'relationType
(car (alist-get 'relations (argdown--json in)))))))))
(let ((strict (funcall reltype '((:argdown-mode . "strict"))))
(loose (funcall reltype '())))
(cl-assert (equal strict "entails") nil
"strict: + between statements should entail, got %S" strict)
(cl-assert (equal loose "support") nil
"loose: + between statements should support, got %S" loose)
" PASS: :argdown-mode strict makes + between statements entail (loose: support)")))
" PASS: :argdown-mode strict makes + between statements entail (loose: support)"
The tenth test pins the epistemic nuance scale: the house tag colours are
a cross-note convention, so argdown--compose must inject them — as Argdown
color.tagColors, in a frontmatter at the very top — into every map without
the note declaring anything. It checks the composed input leads with the
color: block carrying a known mapping, that strict mode folds model.mode
into that same single block (not a second ===, which Argdown rejects), and
— CLI-backed — that a tagged statement’s node actually takes the scale’s colour.
(let* ((loose (argdown--compose "[A]: a. #(constat)" nil))
(strict (argdown--compose "[A]: a." '((:argdown-mode . "strict")))))
;; injected, at the very top, carrying the scale (here: constat → #66bd63)
(cl-assert (string-prefix-p "===\ncolor:\n tagColors:\n" loose) nil
"colour frontmatter not injected at top: %S" loose)
(cl-assert (string-match-p "constat: \"#66bd63\"" loose) nil
"scale mapping missing: %S" loose)
;; strict mode folds into the SAME single block: one `===' … `===', and it
;; carries both color and model.mode (a second frontmatter would not parse).
(cl-assert (string-prefix-p "===\ncolor:" strict) nil "strict lost colours: %S" strict)
(cl-assert (string-match-p "model:\n mode: strict" strict) nil
"strict mode dropped from merged frontmatter: %S" strict)
(cl-assert (= 2 (cl-count-if (lambda (l) (string= l "===")) (split-string strict "\n"))) nil
"frontmatter is not exactly one ===…=== block (color+mode must share it): %S" strict)
(if (not (executable-find "argdown"))
" PASS (CLI skipped): colour frontmatter injected at top, strict folds in"
;; a tagged statement takes the scale's colour (needs a relation — Argdown
;; drops a relationless statement from the map)
(let ((svg (argdown--with-input
(argdown--compose "[A]: a. #(constat)\n\n[B]: b.\n + [A]" nil)
#'argdown--map-svg)))
(cl-assert (string-match-p "stroke=\"#66bd63\"" svg) nil
"tagged node did not take the scale colour: %S" svg)
" PASS: epistemic colours injected at top, strict folds in, tagged node coloured")))
" PASS: epistemic colours injected at top, strict folds in, tagged node coloured"
The eleventh test pins the strength propagation: a conclusion must inherit
the colour of its best supporting argument’s weakest premise, recursively
through chains, while an asserted tag on a conclusion is left untouched.
arg1 concludes [C1] from a constat and an affirmation péremptoire (so
C1 should go péremptoire, #a50026); arg2 concludes [C2] from [C1] and a
témoignage de tiers (so C2 inherits the weakest, péremptoire, through C1);
arg3 concludes a tagged [Ctag] which must NOT be recoloured. Checks the
statementColors argdown--render-input injects, and — CLI-backed — that a node
really renders the propagated colour.
(if (not (executable-find "argdown"))
" SKIP: argdown not on PATH"
(let* ((body (concat
"<arg1>\n\n"
"(1) [P1]: forte. #(constat)\n"
"(2) [P2]: faible. #(affirmation péremptoire)\n"
"----\n"
"(3) [C1]: première conclusion.\n\n"
"<arg2>\n\n"
"(1) [C1]\n"
"(2) [P3]: tiers. #(témoignage de tiers)\n"
"----\n"
"(3) [C2]: conclusion finale.\n\n"
"<arg3>\n\n"
"(1) [Px]: faible. #(affirmation péremptoire)\n"
"----\n"
"(2) [Ctag]: conclusion taguée. #(acte authentique)"))
(full (argdown--render-input body nil)))
(cl-assert (string-match-p "statementColors:" full) nil
"no statementColors injected: %S" full)
;; weakest link: C1 = min(constat, péremptoire) = péremptoire (#a50026)
(cl-assert (string-match-p "\"C1\": \"#a50026\"" full) nil
"C1 not coloured by its weakest premise: %S" full)
;; recursive: C2 inherits péremptoire THROUGH C1 (vs its own témoignage de tiers)
(cl-assert (string-match-p "\"C2\": \"#a50026\"" full) nil
"C2 did not inherit weakest strength through the chain: %S" full)
;; asserted tag wins: a tagged conclusion is never in statementColors
(cl-assert (not (string-match-p "\"Ctag\"" full)) nil
"tagged conclusion was overridden by propagation: %S" full)
(let ((svg (argdown--with-input full #'argdown--map-svg)))
(cl-assert (string-match-p "stroke=\"#a50026\"" svg) nil
"propagated colour not rendered on a node: %S" svg)
" PASS: weakest-link propagation to conclusions, recursive through chains, tagged conclusions kept")))
" PASS: weakest-link propagation to conclusions, recursive through chains, tagged conclusions kept"
The twelfth test pins the inference-force axis: `– {force: “<level>”} –’ must
enter the weakest-link min, so a gold premise (acte authentique) behind a
non sequitur still yields a weak (#a50026) conclusion; an argument with no
inference marker keeps its premise strength (backwards-compatible); and each
argument box is tinted with its own weakest link (argdown--lighten). Checks
the injected statementColors=/=argumentColors and the rendered nodes.
(if (not (executable-find "argdown"))
" SKIP: argdown not on PATH"
(let* ((body (concat
"<arg>\n\n"
"(1) [P1]: solide. #(acte authentique)\n"
"-- {force: \"non sequitur\"} --\n"
"(2) [C]: conclusion mal amenée.\n\n"
"<argok>\n\n"
"(1) [Q1]: solide aussi. #(acte authentique)\n"
"----\n"
"(2) [D]: conclusion bien amenée."))
(full (argdown--render-input body nil))
(light-red (argdown--lighten "#a50026" 0.7)))
;; gold premise + non-sequitur inference ⇒ weak (bare-assertion-red) conclusion
(cl-assert (string-match-p "\"C\": \"#a50026\"" full) nil
"inference force did not drag the conclusion down: %S" full)
;; no inference marker ⇒ conclusion keeps the premise strength (acte = green)
(cl-assert (string-match-p "\"D\": \"#006837\"" full) nil
"an unmarked inference changed the conclusion: %S" full)
;; the argument box is tinted (lightened) by its own weakest link
(cl-assert (string-match-p "argumentColors:" full) nil "no argumentColors: %S" full)
(cl-assert (string-match-p (regexp-quote light-red) full) nil
"argument box not tinted by its weakest link: %S" full)
(let ((svg (argdown--with-input full #'argdown--map-svg)))
(cl-assert (string-match-p "stroke=\"#a50026\"" svg) nil
"weak conclusion border not rendered: %S" svg)
(cl-assert (string-match-p (concat "fill=\"" (regexp-quote light-red) "\"") svg) nil
"argument box fill not rendered lightened: %S" svg)
" PASS: inference force folds into the weakest link (gold premise + non-sequitur ⇒ weak), argument box tinted, unmarked inference inert")))
" PASS: inference force folds into the weakest link (gold premise + non-sequitur ⇒ weak), argument box tinted, unmarked inference inert"
Writing reconstructed arguments: tips and pitfalls
Hard-won while reconstructing a chain of PCS arguments (premise-conclusion structures). The syntax reference is argdown.org; these are the traps that actually bite in this org-babel + strict-mode setup.
-
Inline the conclusion when arguments chain. A reconstruction’s conclusion can be written inline on its numbered line —
(3) [C]: texte— or as a separate[C]: textedefinition placed after the----. The separate form parses for a standalone argument (EOF follows it), but the moment[C]is reused as a premise of another PCS in the same composed input, Argdown aborts withExpecting token of type --> EOF. Cure: put the conclusion (and any premise) text inline on its(n)line; don’t leave a bare[C]: …definition dangling after the structure. -
In strict mode,
+/-between statements are strong claims. With:argdown-mode strict, between two[statements]+means entails,-contrary,><contradiction (an<argument>’s+/-stay support / attack). So never use+for mere corroboration — it asserts entailment, which a vetter will (rightly) reject. And a doubt — a meta-statement of uncertainty — is neither a proposition nor its negation: it cannot be wired as-on a conclusion; record it as its own reservation statement instead. -
Flag a premise that isn’t a quote. A premise that is the author’s legal characterization rather than a verbatim source belongs in a PCS only if it says so — e.g.
[X]: … (caractérisation, non sourcée)— so it is not read as a sourced statement. (Cf. every semantic claim is an Argdown node.) -
Titles are the join keys; references are silent.
:argdown-includeis transitive and Argdown merges[statements]/<arguments>by title, so pulling the same block in via two paths is deduped (harmless). The flip side: a mistyped title in a relation mints a new empty node instead of erroring — so check references explicitly (note_grep) when renaming or deleting a node. Titles tolerate:and« », but keep them short; the body carries the verbatim. -
Make the parser talk.
argdown--run(see the org-babel bridge) now raises Argdown’s own diagnostic — line and unexpected token — instead of a cryptic downstreamEnd of file while parsing JSON. If a block won’t render, re-run it and read that message first.