The export publishes what it parsed
- Comparing
- v1 to v2
- Current
- Read the document
Abstract
archdoc export replaces its sections object with contents, a flat ordered array of every heading and the prose beneath it. Links gain a type, the envelope gains the revision it describes and the repository's configuration, and --out removes the files a previous export wrote and this one does not. The shape moves to schema: 2, amending the rule in RFC-0004 that it only ever gains optional fields.
Motivation
The export publishes everything ArchDoc derives and almost nothing it parses. Every body is already walked into an ordered list of headings carrying levels, anchors and line numbers, and that walk is discarded. What reaches the JSON is an object keyed by anchor holding level two headings only, each carrying its subsections' prose inside its own.
A consumer rendering a document therefore parses the Markdown a second time, because the export does not tell it what order the document is in. A JSON object has no order, and a Go map marshals with its keys sorted, so a document written Abstract, Motivation, Proposal arrives as abstract, alternatives-considered, motivation. Nor does the export mention any heading below level two, so a page structured entirely in level three headings exports no structure at all, and neither a table of contents nor a link to a subsection can be built from the data.
Re-parsing is not only duplicated effort. Where a section ends depends on fenced blocks and HTML comments having been masked first, which is why the body walk exists and why an empty section is judged from masked text rather than from the text as written. A consumer splitting a body by heading gets that wrong the first time a document contains a ## inside a fenced block, and nothing reports it. The rule for splitting a document is the tool's knowledge, and publishing the result is the same undertaking as publishing the identifier rules and the relationship graph.
Three narrower gaps have the same character. An image is emitted as an ordinary link, so a consumer rewriting destinations cannot tell one that becomes an <img> source from one that becomes an anchor, and a list of a document's outgoing links includes its diagrams. Nothing records which revision an export describes, so a consumer cannot state what it is showing or decide whether a re-ingest is due. --out writes files and removes none, so a document deleted from the repository is served from the exported tree for as long as that tree survives.
Proposal
Concepts
| Concept | Meaning |
|---|---|
| Contents | A document as a flat ordered list of its headings |
| Entry | One heading: its level, text, anchor, line and body |
| Body | The lines from a heading to the next heading of any level, exactly as written |
| Source | A document as it is on disk, front matter included |
| Prune | Removing a file a previous export wrote and this one does not |
Components
| Component | Responsibility |
|---|---|
internal/repo |
Walks a body into headings, which the export publishes rather than discards |
internal/export |
The types, Build, and the embedded schema |
archdoc export |
Writes one document, a tree, or the schema |
The envelope
{
"schema": 2,
"commit": "49a14a5c2d3f9ac640bf7db8d88208254b9af105",
"config": {
"name": "ArchDoc",
"branch": "main",
"root": ".",
"strict": true,
"ref_stale_days": 180
},
"has_glossary": true,
"documents": [],
"glossary": []
}
| Field | Content |
|---|---|
schema |
2 |
commit |
The revision given by --commit, absent otherwise |
config |
Every setting archdoc.json holds |
has_glossary |
Whether the repository has a glossary page |
documents |
Every document |
glossary |
The parsed glossary, absent when the page is |
name moves into config and is no longer a field of its own. config is the file's five settings and not the loaded struct, so the absolute path the configuration was read from is not among them: it describes the machine that ran the command rather than the repository.
has_glossary is present in every export. glossary answers the same question in the single-document form and is deliberately absent from index.json, so pruning, which reads index.json to learn what the last run wrote, needs a field that survives there.
Contents
"contents": [
{ "level": 1, "text": "RFC-0001: Database", "anchor": "rfc-0001-database",
"line": 12, "body": "\n" },
{ "level": 2, "text": "Abstract", "anchor": "abstract",
"line": 14, "body": "\nStore data in a database.\n" },
{ "level": 3, "text": "The rule", "anchor": "the-rule",
"line": 18, "body": "\nOne paragraph.\n" }
]
| Rule | Reason stated in the design |
|---|---|
| Entries appear in document order | The order is the document's; an object keyed by anchor cannot carry it |
| Every heading appears, whatever its level | A table of contents and a link to a subsection both need the headings the export omitted |
level is carried rather than inferred from nesting |
Heading levels need not descend one at a time, and a document going from level one to level three would render at a level its author did not write |
| A body runs to the next heading of any level | No prose appears twice, and nesting is read from level |
| A body is the lines as written, untrimmed | line plus one is the first line of body, so a position inside an entry needs no further arithmetic |
| Content before the first heading is not carried | It belongs to no heading. --source is what recovers it |
anchor is unique within a document |
A repeated heading takes a numbered suffix, as it already does |
required_sections is unchanged. It names the level two headings a type calls for, which is what a section is everywhere else in this specification, and a contents entry is a different unit.
Links
Each entry gains type.
| Type | Written as |
|---|---|
inline |
[text](destination) |
image |
 |
definition |
[label]: destination |
A definition's text is its label. Lint already checks an image's destination resolves, because it reads the same list, so this changes what the export says and not what lint reports.
Source
--source adds source to each document: the file exactly as it is on disk, front matter included. It is the only field that reproduces the document, and it is the only place the front matter appears as bytes rather than as parsed fields.
Nothing carries a document's prose more than once. The three modes are:
| Invocation | Prose carried |
|---|---|
archdoc export |
Each contents entry's body |
archdoc export --no-bodies |
None. Every heading, anchor and line survives |
archdoc export --source |
Each entry's body, and the file itself |
index.json carries neither bodies nor source, whatever was asked.
--commit
--commit <revision> records the revision the export describes, as the envelope's commit. The value is taken as given and is not validated against a repository.
The command asks git nothing. A revision read from git would be attached to a working tree that may hold uncommitted edits, stamping the output with a revision that does not describe its contents, which a consumer could not detect. The caller that wants provenance is continuous integration, which holds the revision already.
--out
The tree is unchanged: index.json carrying every document without bodies, one file per document beside it, and glossary.json when there is a glossary.
Pruning proceeds:
- Read
index.jsonin the target directory. Where it is absent, or does not parse as an ArchDoc export, write the tree and report that nothing was pruned. - Derive the previous run's files from it:
index.json, one<path>.jsonper document it lists, andglossary.jsonwhere it recorded a glossary. - Write this run's tree.
- Remove each previous file this run did not write, and print each removal.
The command therefore only ever removes a file a previous ArchDoc export wrote into that directory. A mistyped destination removes nothing, because it holds no index to read.
Errors
| Condition | Result |
|---|---|
No repository, without --schema |
The usual not-found error |
--out names an unwritable directory |
The write error |
| A previous file cannot be removed | The removal error, after the tree is written |
Out of scope
- A document's former identifiers.
renumberrewrites every reference inside the repository and none outside it, so a consumer holding published URLs has no redirect to follow. Recording them means the tool keeping history rather than publishing what it already knows, which is a larger design than this one. - Reference uses.
[text][label]carries a label and not a destination, so a consumer that rewrites definitions carries every use with them. Extracting uses would also make lint report a broken reference link twice, at the use and at the definition. - Anything before the first heading, as a field of its own. It is a line ending in every document that exists, and
--sourcerecovers it where it is not. - Reading the revision from git. See
--commit. - Rendering. Unchanged from RFC-0004: there is no renderer.
Alternatives considered
- Shipping
contentsbesidesections. The shape would only gain fields and no consumer could break. Every section's prose would then be emitted twice, exclusive under one key and inclusive under the other, and two encodings of one fact are what the index's implemented column andImplementedInexist to prevent. Nothing consumes the export, which is published six days before this document, so the cost of breaking it is bounded and this is the last point at which it is. - An
orderfield on each section, keeping the object. It answers the ordering question and not the others: level three headings stay invisible, so a table of contents is still unavailable. Combined with an outline it would be a second encoding of the same order. - Nesting contents instead of carrying
level. A tree is what most consumers build, and the tool would build it once rather than each of them building it. Heading levels need not descend one at a time, and a document going from level one to level three has no honest depth: either the level three heading sits at depth two and renders as something its author did not write, or depth and level disagree and a consumer must choose between them. A tree is derivable from the list and the list is not always derivable from the tree. - Keeping the existing
body. It is neither the structure, whichcontentsnow carries, nor the document, which it cannot reproduce because it begins after the front matter.sourceis the useful half of it. - Pruning every file the run did not write. One rule, and a mistyped
--outdeletes a directory the user did not mean to name. - Refusing
--outunless the directory is empty. Safe, and it breaks exporting into a build directory that already exists, which is the ordinary arrangement.
The design rests on ADR-0005, which is why the schema is checked against the exported structs by reflection rather than by validating output against it, and on ADR-0006, which is why bodies are raw Markdown.
Backwards compatibility
The shape breaks. sections is removed, body is removed, and name moves into config, so a consumer reading any of them stops working. schema moves to 2, which is what it is for: it "changes only for a break, which the rule above is meant to prevent ever being necessary".
That rule is amended rather than abandoned. The shape gains only optional fields from schema: 2 onwards, and this break is taken once, while the export is six days old and nothing reads it. A second break would be a decision of a different kind, shaping every published shape rather than this one, and belongs in an ADR rather than in an RFC amending a predecessor.
sections is removed rather than left to rot because it is not a smaller version of contents but a worse one: level two only, unordered, and carrying its subsections' prose inside its parent.
The command surface only gains flags. --out, --no-bodies and --schema keep their names and meanings, and --source and --commit are new. --out removing files is a behaviour change and is the one part of this that is not additive at the command line.
Open questions
Changelog
Source rfc/0006-the-export-publishes-what-it-parsed.md · commit fd11f56 · published