Flavour spec 1a: the directive grammar, canonical form, opaque carry #3

Merged
lilleman merged 7 commits from flavour-grammar into main 2026-08-24 00:49:26 +02:00
4 changed files with 166 additions and 13 deletions
+32 -3
View File
@@ -18,12 +18,15 @@ When losslessness and readability conflict, losslessness wins.
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way 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. back yields the library's canonical spelling, and that spelling round-trips byte-identically.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
merged, JSON number semantics — the only domain markdown can restore.
Round-trip equality is a property tested over a corpus, not a claim made in prose. Round-trip equality is a property tested over a corpus, not a claim made in prose.
## 3. Unknown input policy ## 3. Unknown input policy
- Unknown ADF node: carried opaquely — raw JSON rides a dedicated syntax in both formats, restored - Unknown ADF node: carried opaquely — raw JSON rides a dedicated syntax in both formats and
byte-for-byte. The round-trip holds for documents newer than the library. restores to a deep-equal node. The round-trip holds for documents newer than the library.
- Unmappable foreign HTML element: error result naming the element — never a silent drop. - 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 - 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. mention/emoji/media nodes; resolving names to ids needs I/O, which is the consumer's job.
@@ -32,7 +35,8 @@ Round-trip equality is a property tested over a corpus, not a claim made in pros
- Directives, one grammar for everything markdown lacks: `:::panel info``:::` blocks, - Directives, one grammar for everything markdown lacks: `:::panel info``:::` blocks,
`:mention[@Mikael]{id=5b10a2}` inline. Prior art: CommonMark's generic-directives proposal. `:mention[@Mikael]{id=5b10a2}` inline. Prior art: CommonMark's generic-directives proposal.
- Plain CommonMark is a subset: the flavour adds syntax, never changes CommonMark meaning. - Plain CommonMark is a subset, with one carve-out (`spec/flavour.md`): directive-shaped literal
text is claimed.
- Tables: one header row plus plain inline cells → pipe table; anything richer → directive form. - 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 - Identity-bearing nodes carry their ids in attributes; a document is only portable within its
site — accepted. site — accepted.
@@ -124,3 +128,28 @@ No wiki markup (§1), no network or filesystem I/O, no name→id resolution (§3
validation or exported validator, no shipped CSS (§4), no streaming APIs, no performance budget — validation or exported validator, no shipped CSS (§4), no streaming APIs, no performance budget —
conversions are O(n), real documents are kilobytes. A CLI is a later goal (`todo.md`), not a conversions are O(n), real documents are kilobytes. A CLI is a later goal (`todo.md`), not a
non-goal. 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. Per chunk:
1. Fresh worktree off updated `origin/main`; implement tests-first (§10).
2. 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.sh` or 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.
3. Merge the PR (standing authorization, this repo only, granted through the `0.1.0` release —
PR #3), check the box in `todo.md`, 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 stops when only maintainer-reserved acts remain.
+5 -3
View File
@@ -3,7 +3,8 @@
Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and Lossless conversion between **Atlassian Document Format** (ADF), an extended markdown flavour, and
an HTML dialect. an HTML dialect.
**Status: scaffold only, no conversion code yet.** Plan: `todo.md`. Decisions: `AGENTS.md`. **Status: scaffold only, no conversion code yet.** Plan: `todo.md`. Decisions: `AGENTS.md`. The
flavour's grammar: [`spec/flavour.md`](spec/flavour.md).
## What it is for ## What it is for
@@ -37,8 +38,9 @@ isAdfDocument(v: unknown): v is AdfDocument
(AGENTS.md §3). (AGENTS.md §3).
- `htmlToAdf(adfToHtml(doc))` equals `doc` — fidelity HTML cannot express rides `data-*` - `htmlToAdf(adfToHtml(doc))` equals `doc` — fidelity HTML cannot express rides `data-*`
attributes. attributes.
- Plain CommonMark is valid input to `markdownToAdf`; converting back yields the library's - Plain CommonMark is valid input to `markdownToAdf`, with one carve-out: literal text matching
canonical spelling, which round-trips byte-identically. directive syntax is claimed (escapable — `spec/flavour.md`). Converting back yields the
library's canonical spelling, which round-trips byte-identically.
- Foreign HTML maps a documented element set; an unmappable element is an error, never a silent - Foreign HTML maps a documented element set; an unmappable element is an error, never a silent
drop. Well-formed HTML only — no tag-soup recovery. drop. Well-formed HTML only — no tag-soup recovery.
- The emitted formats are semver surface (AGENTS.md §8). - The emitted formats are semver surface (AGENTS.md §8).
+113
View File
@@ -0,0 +1,113 @@
# The markdown flavour
The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain
CommonMark is a subset with one carve-out: literal text that matches directive syntax below is
claimed by the flavour (escape the `:` to keep it literal). The emitted form is contract
(AGENTS.md §8). Per-node syntaxes build on this grammar in sections that follow (todo.md 1b1c).
## Canonical form
`adfToMarkdown` emits exactly one spelling; every CommonMark variant of the same document
normalizes to it through the round-trip.
- Emphasis `_em_`, strong `**strong**`; `*` replaces `_` only where `_` cannot parse
(intra-word).
- Bullet lists `- `; ordered lists incrementing `1.` `2.` `3.`, the first number taken from the
node's `order` attribute. Continuation lines align with the first character after the marker
(two spaces for `- `, three for `1. `); blank lines inside an item are empty lines. Lists are
tight — blank lines between items normalize away; ADF does not record tightness.
- Blockquotes prefix lines with `> `; a blank line inside a blockquote is a bare `>`.
- ATX headings (`#``######`); setext input normalizes to ATX.
- Code fences ``` with the node's language as info string, the fence lengthened past any backtick
run in the content; indented-code input normalizes to fences.
- Thematic break `---`.
- Hard break: backslash at end of line (survives editors that trim trailing spaces). Where
CommonMark admits no spelling — the end of a block, inside a heading — it is `:hardBreak{}`.
- An empty paragraph — real payloads carry them — is `::paragraph`.
- Links `[text](url)`; `<…>` around a destination containing spaces; title in double quotes.
`<url>` autolink form only when the text equals the destination and the destination is a valid
CommonMark autolink (absolute URI).
- Paragraphs on one line — no soft wrapping; a soft line break in input becomes a single space.
- Entity references in input decode to their characters; output backslash-escapes only where text
would otherwise parse as syntax.
- Blocks separated by one blank line, no trailing whitespace, single trailing newline.
## Directives
One grammar for everything CommonMark lacks. A directive name is `[a-z][A-Za-z0-9]*` — the ADF
node names. Recognition is syntactic and name-set-independent: anything matching the forms below
parses as a directive regardless of whether the name is known, and an unknown name is an error
result naming it — so output an old emitter escaped stays escaped, and erroring input gaining
meaning later is MINOR, never a reparse (§8). The name `adf` is reserved for the opaque carry, as
both directive name and fence info string.
**Inline**: `:name[content]{attrs}`, on one line — an inline directive never spans lines.
`[content]` is inline markdown; brackets inside balance as in CommonMark link text, `\]` for a
literal bracket. Each node's section says whether content and attrs are required. `:` opens a
directive only when the name is followed immediately by `[` or `{`, and `{attrs}` must follow
`]` (or the name) with no gap — anything else (`10:30`, `:smile:`, a stray `{…}` in text) is
literal text.
**Container block**:
```
:::name arg {attrs}
block content
:::
```
The fence is three or more colons. `arg` is one optional bare token whose meaning each node
defines (e.g. the panel type). The body is block markdown. The closing fence is a line of at
least the opening's length, and a container's fence is longer than every directive fence line in
its body — counting only lines that parse as directive fences in the body's block structure; a
colon run inside a code fence or opaque carry is content. Canonical form uses minimal lengths.
Directive fence lines follow code-fence indentation (up to three spaces relative to their
container); trailing whitespace on a fence line is tolerated in input, never emitted.
**Leaf block**: `::name {attrs}` — a block-position node with no body.
**Claiming at block level**, symmetric with inline: a line whose leading run of two or more
colons is followed immediately by a name character is claimed and must parse fully as a container
opening or a leaf, else it is a named error. A bare colon-run line is a closing fence while a
container is open, a named error otherwise.
**Attributes**: `{key=value key2="two words"}`. `{attrs}` is optional in every form, and `{}` is
valid — no attributes. A bare value matches `[A-Za-z0-9_-]+`; any other value is double-quoted
with JSON string escaping (`\"` `\\` `\n` `\t` `\uXXXX`, …) — total over
Unicode, and raw newlines never appear inside quotes. All values are strings at the grammar
level; each node's section assigns types. Canonical form orders keys alphabetically, spells
values bare wherever allowed, inside quotes escapes only what it must using the shortest escape
form, and omits empty `{attrs}` except where the `{` itself claims the directive
(`:hardBreak{}`).
**Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as
directive syntax — the leading `:` of a would-be directive, `]` inside content; a backslash
before `:` in input always yields a literal colon.
**Malformed directives are error results**, named: an unclosed container at end of input, a body
fence line of the container's length or longer, a bare colon-run line outside any container or
shorter than the fence it would close, an inline `[content]` or `{attrs}` left unclosed at end of
line, unparseable or duplicate-keyed attrs, invalid JSON in an opaque carry. Never a silent
literal-text fallback — a typo that reparses as prose is the silent loss §2 refuses.
## The opaque carry (AGENTS.md §3)
A node type the library does not know rides as its raw JSON and restores to a deep-equal node.
Block and inline positions canonicalize differently, each fitting where it sits:
- **Block position**: a fenced code block with info string `adf`, body = the node's JSON —
two-space indent, object keys sorted.
- **Inline position**: `:adf{json="…"}` — compact serialization (keys sorted, no whitespace),
JSON-string-escaped into the attribute.
The info string `adf` is reserved: a genuine `codeBlock` whose `language` is exactly `adf` is
itself emitted through the opaque carry, so the reservation stays absolute and stays lossless.
In block-directive positions (`::adf`, `:::adf`) the reserved name is a named error — the
carry's block form is the fence.
## Raw HTML in input
CommonMark input may contain raw HTML. `markdownToAdf` routes each construct through the foreign
HTML element mapping (AGENTS.md §3; specified with the HTML dialect, todo.md milestone 6) — ADF
has no raw-HTML node, so a construct without a mapping, comments and processing instructions
included, is an error result naming it. The flavour never emits raw HTML.
+16 -7
View File
@@ -8,14 +8,23 @@ detail is settled at its own milestone.
- [x] **0 — Scaffold.** `package.json` per §6, `tsconfig.json`, `.npmrc` (`save-exact=true`), the - [x] **0 — Scaffold.** `package.json` per §6, `tsconfig.json`, `.npmrc` (`save-exact=true`), the
Docker tooling, `renovate.json` (§9), and `.gitea/workflows/ci.yml` gating branches: Docker tooling, `renovate.json` (§9), and `.gitea/workflows/ci.yml` gating branches:
`runs-on: docker-host`, actions pinned to semver tags. `runs-on: docker-host`, actions pinned to semver tags.
- [ ] **1 — The flavour spec.** The markdown flavour written as this repo's specification before - [x] **1a — The directive grammar** (`spec/flavour.md`): inline/block/leaf directive forms,
any implementation: the directive grammar (attributes, escaping, nesting), each node's attributes, escaping, nesting, canonical form, the opaque-carry spelling, the raw-HTML
syntax from the inventory below, the opaque-carry spelling, the pipe-vs-directive table input policy.
rule, and what CommonMark's raw-HTML constructs become in ADF, which has no raw-HTML node — - [ ] **1b — Block node syntaxes** in `spec/flavour.md`: panel, expand/nestedExpand, the media
likely the §3 element mapping, error otherwise. Start the corpus (§10) from this spec. family, the pipe-vs-directive table rule and the directive table form, task and decision
lists, layout, extensions, syncBlock.
- [ ] **1c — Inline node syntaxes and marks** in `spec/flavour.md`: mention, emoji, status, date,
inlineCard, mediaInline; underline, subsup, textColor, border; the spelling for text nodes
whose whitespace CommonMark cannot hold (literal newlines, leading or trailing spaces).
- [ ] **1d — Corpus start** (§10): checked-in ADF ↔ canonical-markdown fixture pairs per spec'd
node.
- [ ] **2 — `adfToMarkdown`.** First real code — decide here where §10's coverage check lives. - [ ] **2 — `adfToMarkdown`.** First real code — decide here where §10's coverage check lives.
- [ ] **3 — `markdownToAdf`.** The CommonMark parser is the largest single component. - [ ] **3 — `markdownToAdf`.** The CommonMark parser is the largest single component. The raw-HTML
- [ ] **4 — Round-trip property tests** over the corpus, both ways — the thing that proves 2 and 3. element mapping is empty until milestone 6, so at `0.1.0` every raw-HTML construct in input
is an error result.
- [ ] **4 — Round-trip property tests** over the corpus, both ways — the thing that proves 2 and
3. Generators emit editor-normal ADF (§2).
- [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret, - [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret,
the repo made public first (§6). `0.1.0` is the markdown round-trip: both markdown the repo made public first (§6). `0.1.0` is the markdown round-trip: both markdown
directions, the types, `isAdfDocument`. The build lands here: a build tsconfig emitting JS directions, the types, `isAdfDocument`. The build lands here: a build tsconfig emitting JS