View as Markdown

Exporting to Markdown

Tip

spec export markdown --target <target> --out <dir> writes every committed document as plain Markdown, with [[references]] turned into the target’s own links.

Targets

Target Layout Links
obsidian <owner>/<type>/<slug>.md [[file#Heading\|title]]
mkdocs <owner>/<type>/<slug>.md and index.md relative .md links; headings keep {#anchor} (enable attr_list)
docusaurus <owner>/<type>/<slug>.md and index.md relative .md links; headings keep {#anchor}
github-wiki flat <slug>.md, Home.md, _Sidebar.md [title](page#heading)
spec export markdown --target obsidian --out vault
spec export markdown --target mkdocs --out docs/specs --banner

What goes in

  • Committed documents of every status. Superseded and rejected documents carry a status line.
  • Documents in .spec.local/ only with --local. The same holds for exporter plugins: without --local they never see a local document, and a reference to one comes as unresolved.
  • Frontmatter is flattened: id, type, status, title, aliases, tags, created, updated and one rel_<kind> list per relation. Docusaurus pages also get id, specfold_id, sidebar_label and sidebar_position. GitHub wiki pages have no frontmatter.
  • --banner starts each page with a note naming its source file.

References that lead nowhere

A Markdown link whose target is a document id, [the plan](plan:root/20261030020644), is rewritten like a reference when it resolves and left as written when it does not; spec check warns about both and suggests [[id|text]].

A reference that does not resolve, names a remote, or names a document left out of the export is written as plain text and listed in the output. --fail-on unresolved writes nothing and exits 1 instead.

Secrets check and public guards

Before anything is written or a plugin is called, the built-in secrets check and every guard with public = true read what is leaving: the built-in tree, or the source files of the documents a plugin receives. The secrets check runs even when no guard is configured, and also reads the exported documents’ text attachments kept in the repository (up to [files] scan_max_bytes, binary files skipped). An error-level hit writes nothing and exits 1; warn-level hits are listed. --no-guard skips the check and the guards. Matched values are never printed.

The output directory

--out must be missing, empty, or an earlier export. Each export writes .specfold-export.json, listing its files; the next export removes those files and keeps everything else (an .obsidian/ folder, for example). Specfold refuses a directory that holds the repository or sits inside .spec, .spec.local or .git. Replacing an earlier export, or sending documents past a public guard, previews first and writes only with --yes.

Files are named after the slug. When two documents would get the same file name (compared without case), documents are taken in id order: the first keeps payment-rollout.md, the next gets payment-rollout-2.md, then -3. The names stay the same from one export to the next until a document with that slug is added, removed or renamed; spec mv <id> --slug <new> gives one a name of its own.

Exporter plugins

markdown is reserved for the built-in exporter. A plugin that wants more than the compact payload lists it in describe:

{"protocol": "specfold.plugin.v1", "name": "site", "wants": ["body", "links"],
 "provides": {"exporters": ["html"]}}

export.run then also sends, per document, hash (the source_rev hash), body (the text after the frontmatter) and links: each reference with its byte span in that body, raw, target_id, anchor, remote, resolved_path, title, status, and reason when it does not resolve. Bundle chapters come as chapter_bodies. Once the bodies pass --max-bytes, the rest are sent as body_path for the plugin to read itself. Plugins that ask for nothing get the same payload as before.