23 KiB
Working in this repo
Decisions a reader would otherwise relitigate, and the rules for every collaborator, human or
agent. Using the library: README.md. What is still to build: todo.md.
1. Three formats, ADF is the hub
ADF, one markdown flavour, one HTML dialect. Six directions exposed, but markdown↔HTML compose through ADF: four conversions exist to keep correct — never write a fifth. No fourth format, ever; each one doubles the directions.
2. The round-trip is the product
markdownToAdf(adfToMarkdown(doc)) and htmlToAdf(adfToHtml(doc)) must equal doc — anything
less silently destroys content an editor could not represent, in a document it did not author.
When losslessness and readability conflict, losslessness wins.
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
back yields the library's canonical spelling, and that spelling round-trips byte-identically —
where there is a way back. CommonMark spells link destinations the flavour has no escape for, so a
parse succeeding does not imply a spellable document; todo.md 3k's exception list names those.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks merged, JSON number semantics, an empty attrs object, marks array or content array the absent key — the only domain markdown can restore.
Round-trip equality is a property tested over a corpus, not a claim made in prose.
3. Unknown input policy
- Unknown ADF node: carried opaquely — raw JSON rides a dedicated syntax in both formats and
restores to a deep-equal node. The round-trip holds for documents newer than the library. So
does a known node no section spells where it stands: a markdown serializer spells a node by type
without checking its position, and refusing loses a document ADF itself keeps in an
unsupportedBlock. Where a container's own spelling cannot hold the child it has — abulletListoutsidelistItem, acodeBlockoutside text — the error result names that instead. - Unmappable foreign HTML element: error result naming the element — never a silent drop.
- Bare
@name/:smile:in typed text: stays a text node. Only directives produce mention/emoji/media nodes; resolving names to ids needs I/O, which is the consumer's job.
4. The flavour
- Directives, one grammar for everything markdown lacks:
:::panel info…:::blocks,:mention[@Mikael]{id=5b10a2}inline. Prior art: CommonMark's generic-directives proposal. - Plain CommonMark is a subset, with carve-outs (
spec/flavour.md): literal text shaped like a directive, a pipe table or a~~pair is claimed — plus one image gap. - Tables: one header row plus plain inline cells → pipe table; anything richer → directive form.
- Identity-bearing nodes carry their ids in attributes; a document is only portable within its site — accepted.
- The HTML dialect mirrors this: semantic elements, stable
adf-*classes,data-*for what HTML cannot express, text always escaped. No stylesheet ships.
5. Dependencies
dependencies is empty. A runtime dependency enters only through a decision entry here stating
why ~20 lines of own code cannot do the job, who maintains it, and what auditing it costs. So the
CommonMark and HTML parsers are written in this repo. A table a standard fixes is data rather than
a dependency: HTML5's 2125 semicolon-terminated character references ship packed in their own
module, so entity decoding is complete without one. devDependencies: few, each earning its keep;
they never reach a consumer.
6. The package contract
- Runs on any ES2022 engine, not only Node — a browser as readily as a server. The shipped source
is ECMAScript and nothing else: no host import, no host global, no DOM.
tsconfig.build.jsonis that gate, typechecking and emitting the shipped files alone, sonode:fs,processand an ES2024 method are compile errors here rather than a consumer's crash there. The standard is the line, never an engine list: one implementing it in part — Hermes is the live doubt, on §10's property escapes and on lookbehind — is out of scope rather than a bug. Node's test runner, the corpus reads and the build are the repo's own, never the library's, andengines.nodestates the floor the shipped JavaScript needs —>=18— never the higher one those repo-only tools want. - ESM only — no CommonJS build, no dual-package hazard.
- One entrypoint: built JavaScript,
.d.tsbeside it. Do not add a TypeScript-source entrypoint — Node refuses to type-strip undernode_modules(ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so it cannot serve an npm consumer. - Published to public npmjs as
@larvit/adf-codec. Public source: the Gitea repo goes public, LICENSE in place, before the first publish. - Exact versions:
save-exact=truein.npmrc.
7. Nothing about any consumer
No Jira client, no HTTP, no REST shapes, no issue keys, no actual consumer named anywhere. Design against the README's personas.
8. Semver: the formats are API
The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR. Pre-1.0, normal 0.x rules.
The error surface is a contract too. ConvertError is { code, message, path, position? } — the
code from a closed list a consumer may switch exhaustively, the message free text, the path the
node's place from the document root, the position where a parse read the refusal in its input.
A message names the violation, not the rule alone — a rule by itself states a truth the reader
must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose it
names the escape that unclaims the form claimed: \::: for a directive line, \| for every pipe
row, \: for an inline directive.
Adding, removing or renaming a code is breaking, so a milestone meeting a new failure cause
reuses a code where one fits; the list is complete at 0.1.0. A code names the
cause; where one cause recurs across node types, across one mark's attributes or across
directions, one code covers them all and
path and message say which — unsupported-nesting-depth is the 500-level guard whichever
direction hits it, unspellable-link the destination and the title alike. Where two codes stay
apart, the line between them is what they name: unspellable-character is a character CommonMark
rewrites wherever text holds it, unspellable-whitespace the newline no inline directive's
content slot spans, in either direction. A claim code names the spelling claimed, never the node that spelling would have built:
a malformed :::table is a malformed-directive, and an alignment colon a malformed-pipe-table —
the flavour's own delimiter row is - runs, so the grammar refuses the colon rather than ADF's
missing column model doing it. A refusal no spelling recovers from is a gap in the flavour rather
than a code: give the flavour the spelling and the code goes, which the freeze is the last moment
for. A cause the carry answers gets no code: a mark no
spelling writes rides the carry with its node. A directive whose name reads back to no node is
unknown-directive-name rather than a claim code — the spelling is well formed, and telling that
apart from a typo is what a consumer switches on when a later MINOR gives the name meaning. A
reserved name is a known name, so never that code, and the two the flavour reserves part on form:
a form the grammar does not have is a claim code — ::adf, whose carry is the fence — and a
well-formed form in the wrong place is unsupported-node-shape, ::listBreak parting anything
but two adjacent lists of one type. What
the grammar itself refuses stays a claim code, key order among it; a well-formed directive the
node tables refuse — an attribute a node does not hold or spells elsewhere, a value outside its
kind or its canonical spelling, an argument or a body its content model does not take — is
unsupported-node-shape, the emitter's code for the same mismatch read the other way — one code
across both directions for good, since the call site knows which direction it called and parting
them after 0.1.0 is MAJOR. unmappable-html names the version rather than the element: this one
converts no raw HTML, so at 0.3.0 the mapped elements stop erroring and the code stays for what
no ADF node carries. A refusal found before its path is known — the block walk's, a directive
reader's — is a ConvertFault, the code and message alone; the node walk attaches the path as it
descends, so a document reports its first error in document order. not-an-adf-document carries
the document's own path throughout: eight of the guard's nine branches read the document's own
shape, and threading a path to the ninth — a malformed node anywhere in the tree — wants the
manual stack §11's no-recursion rule forces, whose empty half no input reaches. The message names
the violation instead. Depth is not one of the nine: adfDocumentFault returns the code with the
message, so an attribute value past 500 levels is unsupported-nesting-depth from the emitter as
it already is from the parser, both directions refusing the same value. A node's attribute is
counted from the value itself, never from the attrs object holding it; a mark's is counted three
levels in, because the block directive spells the whole mark set as one JSON attribute and the
parser reads the value at the bottom of array, mark and attrs. isAdfDocument is true for a depth fault:
a deep document is a document, as the 2000-level blocks and the 600-deep marks the guard already
waves through are, and depth is the walks' answer rather than the shape's. A non-finite number
stays parted where depth is joined: the parse says unsupported-node-shape because the markdown is
at fault, the emit not-an-adf-document because the input is, and unlike depth nothing round-trips
inconsistently between them.
position is the parse side's alone: an emitter reads no source, so an emit error carries path
and nothing more. It is { line, offset } at the start of the line the block holding the refusal
begins on — the offset indexing the string the caller passed, the line counted from 1 — minted by
the block walk and attached as results return, so the innermost block wins, the emitter's own
refusals the parser re-enters for the CommonMark spelling included.
A parse names a position for every refusal it returns, so the type says so rather than the prose:
Result<T, E extends ConvertError = ConvertError>, and a direction reading a source returns
Result<T, ParseError> — ConvertError with position required. An optional field a direction
always fills is a branch a consumer cannot take, and the ! §11 bans is how they take it anyway.
htmlToAdf inherits this at 0.3.0; the composed markdownToHtml and htmlToMarkdown keep the
wide Result<T>, since half their refusals come from an emit stage that read no source.
9. Release automation
package.jsonversion onmainis the source of truth. CI onmain: tests green and version differs from npm → publish and tagvX.Y.Z. No bump, no deploy; the bump is each shipping PR's deliberate semver judgment.publish.shis that job, andprivate: truestops it before it reads the token, so the pipeline is live and silent until the maintainer's first bump drops the field.- The publish and the tag each observe their own end state — the version on npm, the tag on the
remote — and neither gates the other, so a run that dies between them converges on the next push
to
mainrather than leaving npm ahead of the tags. An unanswered registry reads the same as an unpublished version, which npm's own duplicate rejection is what catches. The job rebuilds rather than taking the gate'sdist: the lockfile is committed, the image is patch-pinned andtscis deterministic, so the two builds agree, and promoting an artifact would make the release path depend on a store that the gate would then have to keep. - Renovate watches devDependencies, Docker pins and action tags; automerges everything on green CI.
- Docker images pin the full patch version (
node:24.19.0-alpine3.24, nevernode:24), as specific as the publisher tags:oven/bun:1.4.0-alpinepins Bun's patch and leaves the base floating because Bun publishes nothing narrower. Actions pin semver tags.
10. Tests first, in Docker
Test for the behaviour wanted first, then implement until green. node --test, beside the code.
Node, tsc and npm never run on the host — only via the pinned images (§9). Tests are independent,
coverage does not decline, containers are torn down after a run.
The gate runs that same suite under Deno and Bun as well as Node, the three images pinned alike,
and neither extra leg is Node's proof twice. Deno refuses an extensionless or directory specifier,
so it holds the module graph to the fully-spelled form a browser can load; Bun runs
JavaScriptCore, the one engine of the three that is not V8, where the Unicode property escapes
emphasis matching leans on can disagree. Both refuse a run matching no test, so Node's is the only
vacuous-green guard, and a test may reach only for what all three node: shims carry — the price
of proving those engines over the corpus rather than over a smoke import.
The gate then packs the build and installs the tarball under package-tests/, so files,
exports and types are proved on the artifact that ships rather than on the source tree a
self-reference would resolve against. consumer.ts typechecks the emitted .d.ts from outside
tsconfig.build.json — declaration emit leaves the .ts specifiers
rewriteRelativeImportExtensions rewrites in the JavaScript, and this is what says a consumer's
resolver maps them, under NodeNext alone; a .d.ts reader that is not tsc stays unproven.
node-floor.js round-trips the installed package under a Node pinned to engines.node's floor.
A fourth engine reads the build rather than the source: a headless Firefox loads dist/index.js
over HTTP and converts the whole corpus, which is §6's browser half and the only SpiderMonkey
there is — the gate's other three engines are two V8s and a JavaScriptCore that is not Safari's.
geckodriver is what carries a verdict back out, the driver and the page's server sharing one
network namespace so each is the other's 127.0.0.1; --headless --screenshot has no such
channel, and loading dist/index.js in a globals-stripped realm buys one by not running a browser.
The floors live in the test script, so npm test and the gate are one path: 100% of lines and
functions, and a branch floor that only ever moves upward. It sits below 100 because the guards
noUncheckedIndexedAccess and ADF's optional keys force — ?? [], ?? {}, ?., an index
compared against undefined — have a half no valid document reaches.
The corpus, all checked in: hand-built fixtures per node and combination; real sanitized ADF from
live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite against
markdownToAdf and markdownToHtml.
spec/flavour.md is read as a source too, so the node tables cannot drift from the prose they
copy: each - bullet in ## Block nodes, ## Inline nodes and ## Marks declares the nodes
named before its first em dash, with the attributes following Attributes: — a parenthesized
value set reading string — and must equal the tables in adf/. Keep prose in those sections out
of a bullet; fenced examples are skipped. It guards the attributes alone: nodes that differ in
content model share a bullet, and the argument attribute is spelled ahead of Attributes: , so
both answer to the round-trip corpus and to nothing else where a node has no fixture.
11. Code rules
- Two-space indent, strict TypeScript, English everywhere. Alphabetical order wherever order carries no meaning.
- Failures are values: everything returns
Result<T>—{ ok: true; value } | { ok: false; error: ConvertError }— nothing throws.try/catchonly wrapped tightly around a call that genuinely throws, converted to a result on the spot. A reader with no path to name returnsRead<T>instead, the same two arms over aConvertFault, andfaultedattaches the path where the walk knows it. - Only the hard break's inline segment holds a raw newline — every other spelling escapes one or refuses it — which is how the whitespace carry finds a line edge.
- Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text
escapes wherever CommonMark could open or close with it, leaving the emitter's own delimiters the
only ones in play, and a pair that matching hands to another delimiter rides the carry instead.
matchEmphasistranscribes the referenceprocess_emphasisline for line, and its closer walk and opener search stay whole: broken into named steps they drift from the algorithm being faithful is the whole point of. - A readable spelling tried ahead of a general one — a CommonMark block, the image, the pipe
table, a pipe cell — gives way with
undefinedfor every shape it cannot spell, and fails only where the general form fails on the same node. Refusing there refuses a document the general form spells, so a refusal the general form does not share belongs in the general form or nowhere — save the nested list a tight spelling would swallow, whose refusal the tight-versus-blank answer owns (todo.md2b). - Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an
attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a
Resultrather than the stack overflow that waits near 2000. - A reader takes the text and an index — a sticky regex whose
lastIndexthe caller sets on the line before it reads,indexOf— never a fresh slice per character, and a per-character walk hoists the scan that does not vary with the character. The pipeline persona feeds documents nobody typed, and a megabyte through a quadratic walk is a minute rather than a millisecond. - No casts:
as,as unknown as, non-null!. A boundary owes a type guard validating the fields it claims (isAdfDocument); past it everything is typed. Make invalid states unrepresentable. src/adf/holds ADF's own knowledge and imports no format. Each format directory (markdown/,html/) parts intoemit/(ADF→format) andparse/(format→ADF), its root holding what both directions read. A construct's reader lives in that root beside the regex the emitter escapes against, so the two cannot drift; a reader with no emit counterpart goes inparse/, unless it is part of a construct the root already holds — a grammar stays in one file rather than splitting across the seam. A rule both directions must answer alike — whether a list marker interrupts a paragraph — is one function there too, never a copy per direction, however conservative the copy would be. Where the rule is the emitter's own choice, input consults it rather than restating it: the parser askscommonMarkSpellingwhich form the emitter picks, so no fixture the emitter writes can be refused, and a spelling the emitter refuses gives its own error rather than a second name for it.- The attribute vocabulary is ADF's:
adf/walks it and narrows each value to its kind, and a format spells the narrowed value. A spelling that re-checks the type is the check's second copy. Reading a spelling back is the format's own: the reader sits beside the spelling it inverts, so decode-respell-compare cannot drift, and each format writes its own — canonical JSON for a number is the markdown flavour's choice, not ADF's. - Explicit over implicit; descriptive names; no catch-all files (
utils,helpers,misc); a file does not repeat its directory in its name —adf/document.ts, neveradf/adf-document.ts. - Reuse before adding; the smallest sufficient diff is the benchmark; no speculative generality — a second consumer, or it goes.
12. Prose to a minimum
Applies everywhere: comments, every markdown file in this repo (this one included), PR text.
- Default is no comment. One earns its single line only by naming an invariant, footgun or external constraint the code cannot show — never restatement, history, absence or arrangement. A second line belongs in the commit message or a decision entry here.
- Every prose comment in a diff is a review question; the default answer is delete.
- A doc paragraph says what the repo cannot say for itself, or it goes. The fix for a redundant one is deletion, not trimming. A false claim in any doc is a bug, fixed where found.
- Published text — npm README, error messages, API docs — never references internal systems, tickets or repos.
13. Commits and PRs
One-line commit messages and PR titles; short PR summaries. No AI-attribution markers, ever.
14. Non-goals
No wiki markup (§1), no network or filesystem I/O, no name→id resolution (§3), no ADF schema
validation or exported validator — a refusal that keeps the round-trip is not schema validation,
so the one a spelled node carrying the same mark type twice earns stays, and input nesting a
spelling inside its own kind (*(*a*)*) names that mark once, no shipped CSS (§4), no
streaming APIs, no performance budget past §11's scanning rule — nothing here is tuned, and no
figure is promised. A CLI is a later goal (todo.md), not a non-goal.
15. The working loop
One unchecked todo.md item per session, in the smallest PR-able chunk — split a big milestone
into sub-items in todo.md before starting it. A chunk running a little over or under that is not
worth deliberating; what matters is that nothing is left undone in the end. Per chunk:
- Fresh worktree off updated
origin/main; implement tests-first (§10). - Run the larv-review flow until it passes and CI is green. A reviewer launch states the latest
gate result (commit and outcome); a reviewer does not re-run
ci.shor the tests when a result exists for the commit under review, or when the diff since that result cannot affect it (docs-only) — re-run only what its own findings or fixes invalidate. - Merge the PR (standing authorization, this repo only, granted through the
0.1.0release — PR #3), check the box intodo.mdand move the item's text totodo-history.md, leaving its title behind, report, stop. The next chunk gets a fresh session.
Ask, don't guess: any choice where what the maintainer would pick is not near-certain gets asked, and the answer lands as a decision in this file. The confidence bar is very high — asking too often is the accepted cost, guessing wrong is not.
Reserved for the maintainer, never the agent: changing version in package.json (a bump on
main publishes, §9 — every release including 0.1.0 is the maintainer's), making the repo
public, and creating the NPM_TOKEN secret.
A continuous loop session (/loop) counts as a chain of sessions: one chunk per iteration, each
iteration starting by re-reading AGENTS.md and todo.md and trusting them over anything
remembered from earlier iterations. The loop session is a thin driver: each chunk's work runs in
a fresh-context subagent holding this file as its charter, and the driver only relays maintainer
questions, runs the review flow, merges, and cleans up. The loop stops when only
maintainer-reserved acts remain.