Compare commits
149 Commits
cf9b12b973
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f689a51b0 | |||
| ddc5dfa1be | |||
| 6c589ecabc | |||
| da63faa4a4 | |||
| d0f0873ca9 | |||
| 058a5fd2f8 | |||
| e84cd47f08 | |||
| 7adab8e19e | |||
| dff4983d4a | |||
| d9bacc4072 | |||
| 58a7bf91b0 | |||
| 91adec5797 | |||
| 2252232fd6 | |||
| 4d3231c86b | |||
| 95af770e0a | |||
| 361139a4d4 | |||
| dccd8dcf3a | |||
| cd2b573372 | |||
| 94eaee6bae | |||
| e949f2099c | |||
| 32df104649 | |||
| 1df58cf33e | |||
| e17e247351 | |||
| 5f3705c430 | |||
| 7b344334b6 | |||
| 8d9d1ecb5b | |||
| 3f2f63aac0 | |||
| 8cecf27757 | |||
| 1f7d11ea3e | |||
| 050e52296a | |||
| bd8d240712 | |||
| 3edc12aaa9 | |||
| c81bda9bfb | |||
| 160c2a7052 | |||
| e2a660ee8e | |||
| f105bc9a57 | |||
| 6c499127b5 | |||
| 151bf38398 | |||
| a4b6b48635 | |||
| 1a266d5661 | |||
| 9bdbcd880e | |||
| c3e86f8430 | |||
| 2395cf077e | |||
| 95ff9ee910 | |||
| 6681bec391 | |||
| 6c8ed03d36 | |||
| 43869a7219 | |||
| 33f3bf7ee4 | |||
| 1d369ec5c5 | |||
| 52cf681a1c | |||
| 06103ea2ba | |||
| 7c15b02258 | |||
| 692433b712 | |||
| b9e2842310 | |||
| 75c5917712 | |||
| daaa2134b3 | |||
| af7658a863 | |||
| 13b04e46d3 | |||
| 474e6a2991 | |||
| 23804a5b3a | |||
| 8d2d2405f3 | |||
| 4443ea57af | |||
| 09d2e54f5a | |||
| 2701e165c4 | |||
| 2a3c90d63b | |||
| bbfc8724fc | |||
| fa6df33987 | |||
| 8a898436ea | |||
| 621dc75f86 | |||
| d5dae87c9b | |||
| 348865398c | |||
| ff23145a5a | |||
| d2a3d74030 | |||
| 50f97513e8 | |||
| e5771bc60b | |||
| fb97285899 | |||
| c64eed9301 | |||
| d10da170b1 | |||
| c694896090 | |||
| becd12e294 | |||
| f855e1b348 | |||
| c6781aa9b3 | |||
| 740a7b1e59 | |||
| a74e8742ff | |||
| 11e55732e7 | |||
| 8e09cd2214 | |||
| 5d5952c576 | |||
| 1271365b38 | |||
| 1f4a94974a | |||
| 326352fa98 | |||
| 9e2c0fda15 | |||
| f594dafc5b | |||
| 613dc0edbb | |||
| 2f03e20549 | |||
| ae2dc6054b | |||
| 7a53b6f7a0 | |||
| 1eb7b54f19 | |||
| 4df17055ef | |||
| 8cb85f3d27 | |||
| 969326b776 | |||
| b42218e655 | |||
| c1fed0885b | |||
| bf87e6ea18 | |||
| 53ff7e0204 | |||
| d1a208146f | |||
| 687ba9bf90 | |||
| 0094b361ab | |||
| dbd80b98c3 | |||
| 8572d76ee9 | |||
| 62ece3f9ac | |||
| 8f33f67f72 | |||
| 3a18627389 | |||
| dc11daa540 | |||
| 7b814e02dc | |||
| 5e55ce2a8e | |||
| cf9a737e71 | |||
| 1c0a6526b1 | |||
| 8418150cd3 | |||
| d35637c8fa | |||
| c46b8243ba | |||
| 69f116db1b | |||
| 1104c57c0e | |||
| c6acef081f | |||
| 59a37d1945 | |||
| 37abc1ddc1 | |||
| cb30512a90 | |||
| 3fce43e884 | |||
| 0644d1bc5d | |||
| b1dc6a1f45 | |||
| 0fd412e426 | |||
| 3d0f82cbd4 | |||
| 68942c9919 | |||
| d5370b126d | |||
| 15fb7fca81 | |||
| b90baa257c | |||
| d0088fc922 | |||
| 2b15530a57 | |||
| 132f9f7467 | |||
| 7be4f59f0b | |||
| d3a2129967 | |||
| 6715c97599 | |||
| 75791783d8 | |||
| 6944d505f1 | |||
| 754f1e3b33 | |||
| 2f7005590c | |||
| 67c3345fd2 | |||
| 7f290d220d | |||
| c2acee7a1d | |||
| e964abaa4b |
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"categories": { "correctness": "off" },
|
||||
"ignorePatterns": ["src/**/*.test.ts", "src/property-harness.ts"],
|
||||
"ignorePatterns": ["src/**/*.test.ts", "src/conformance/property-harness.ts"],
|
||||
"rules": {
|
||||
"eslint/max-lines-per-function": ["error", { "IIFEs": true, "max": 52, "skipBlankLines": false, "skipComments": false }]
|
||||
}
|
||||
|
||||
@@ -1,246 +1,83 @@
|
||||
# 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`.
|
||||
The rules for every collaborator, human or agent, and an index of the decisions a reader would
|
||||
otherwise relitigate. Using the library: `README.md`. What is still to build: `todo.md`.
|
||||
|
||||
## 1. Three formats, ADF is the hub
|
||||
## Decisions
|
||||
|
||||
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.
|
||||
In `docs/decisions.md`:
|
||||
|
||||
## 2. The round-trip is the product
|
||||
- Plain markdown is a flavour of the grammar
|
||||
- The round-trip is the product
|
||||
- Markdown in is a canonical fixpoint
|
||||
- Equality is editor-normal
|
||||
- Unknown nodes ride the carry
|
||||
- Foreign HTML sorts three ways
|
||||
- Names stay text
|
||||
- Directives under `!adf:`
|
||||
- CommonMark is a subset
|
||||
- Tables
|
||||
- Links
|
||||
- Ids stay site-local
|
||||
- Plain task ids come from position
|
||||
- A callout title keeps its link targets
|
||||
- The plain flavour's spellings
|
||||
- The HTML dialect
|
||||
- No runtime dependencies
|
||||
- Standards ship as data
|
||||
- fast-check
|
||||
- Any ES2022 engine
|
||||
- ESM only
|
||||
- One built entrypoint
|
||||
- Public on npm
|
||||
- The formats are API
|
||||
- The code list
|
||||
- Which code a cause takes
|
||||
- `message` and `path`
|
||||
- Publish on a version bump
|
||||
- Docs describe the release being built
|
||||
- No schema validation
|
||||
- The gate runs on Deno and Bun
|
||||
- The gate installs the tarball
|
||||
- Firefox reads the build
|
||||
- The coverage floors
|
||||
- The size ratchet
|
||||
- Properties on a fixed seed
|
||||
- The CommonMark suite checks three ways
|
||||
- The flavour spec is read as a source
|
||||
- The node tables answer to Atlassian's schema
|
||||
- Nothing recurses unbounded
|
||||
- Nothing spreads an unbounded array
|
||||
- A retry loop checks its own termination
|
||||
- Readers scan by index
|
||||
- The spelling memo
|
||||
- Cost fixes are measured, never timed
|
||||
- Only the hard break holds a raw newline
|
||||
- Emphasis follows CommonMark's matching
|
||||
- Readable spellings take the `try` prefix
|
||||
- The attribute vocabulary is ADF's
|
||||
- The source parts by ADF and format
|
||||
|
||||
`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 some things the flavour has no escape for — a
|
||||
paragraph opening with a code span whose backticks read back as a fence — so a parse succeeding
|
||||
does not imply a spellable document;
|
||||
`corpus/commonmark-spec/exceptions.json` names those.
|
||||
|
||||
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
|
||||
and no attributes 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 — a
|
||||
`bulletList` outside `listItem`, a `codeBlock` outside 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, namespaced under `!adf:`: `!adf:panel info`
|
||||
… `!adf:/panel` blocks, `!adf:mention[@Mikael]{id=5b10a2}` inline, `\!adf:` the one escape. Not
|
||||
CommonMark's generic-directives proposal: its `:::` claims a form prose writes, and its
|
||||
fence-length discipline ties a container's opener to its own body, where closing from the opener
|
||||
nests by itself and leaf versus container falls out of the node's content model.
|
||||
- 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.
|
||||
- Links: `[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark
|
||||
spells the mark; `!adf:link[text]{attrs}` where it does not — an attribute CommonMark cannot
|
||||
hold, an `href` or `title` no canonical escape spells, a paragraph opening whose CommonMark
|
||||
spelling would read as a link reference definition — and a directive link CommonMark could spell
|
||||
is refused (the maintainer, 2026-09-13). No link wraps a link — the bracket form goes literal,
|
||||
the directive form refused — which is CommonMark's prose where its reference implementation
|
||||
nests one `<a>` in another (the maintainer, 2026-09-17).
|
||||
- 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. The CommonMark spec suite is the same shape of
|
||||
data and ships vendored at `corpus/commonmark-spec/` rather than as the `commonmark-spec` dev
|
||||
dependency — that package is CommonJS-only, and Renovate auto-bumping a spec version would silently
|
||||
point the vendored exception list's example numbers at a renumbered suite. A spec bump is a
|
||||
deliberate re-pin, exceptions re-derived by hand beside it. Atlassian's ADF JSON Schemas ship
|
||||
vendored the same way, at `spec/adf-schema/`, rather than as the `@atlaskit/adf-schema` dev
|
||||
dependency — CommonJS-only, some fifty packages with React among them, and a release most days for
|
||||
Renovate to automerge — re-pinned by hand when a payload or a report shows the need.
|
||||
`devDependencies`: `fast-check` earns its place shrinking a failing generated document to the nodes
|
||||
that break it, `oxlint` measuring §10's size ratchet — TypeScript 7 is a native compiler publishing
|
||||
no in-process parser, only the `unstable/` AST surface an out-of-process handshake reaches.
|
||||
|
||||
## 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.json` is
|
||||
that gate, typechecking and emitting the shipped files alone, so `node:fs`, `process` and 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, and `engines.node` states 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.ts` beside it. Do not add a TypeScript-source entrypoint —
|
||||
Node refuses to type-strip under `node_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=true` in `.npmrc`.
|
||||
|
||||
## 7. Nothing about any consumer
|
||||
## 1. 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
|
||||
## 2. Release automation
|
||||
|
||||
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. A spelled node's content model is part of that contract — leaf or
|
||||
container is the model, not the syntax — so giving a spelled node's model content it had not, or
|
||||
taking it away, is MAJOR whatever ADF's own schema does.
|
||||
|
||||
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: `\!adf:` for a directive, block line and inline
|
||||
alike, `\|` for every pipe row.
|
||||
Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose
|
||||
name reads true of it in both directions; where none does and a plain name exists, a new code — in
|
||||
any 0.x minor, and after 1.0 only in a MAJOR (the maintainer, 2026-09-18). A refusal whose cause is
|
||||
this library's own invariant rather than the input takes the existing code nearest what the consumer
|
||||
sees — a document that does not convert is `unsupported-node-shape` — since a code no input reaches
|
||||
is one no consumer can switch on (the maintainer, 2026-09-20). 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-character` the text node and the code block 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 `!adf: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 — `unspellable-link` went at `0.2.0`, the directive link spelling the `href` and `title` it
|
||||
refused and the attributes the carry held (the maintainer, 2026-09-13). 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:carry`, whose carry is the fence — and a
|
||||
well-formed form in the wrong place is `unsupported-node-shape`, `!adf:listBreak` parting anything
|
||||
but two adjacent lists of one type. What
|
||||
the grammar itself refuses stays a claim code, key order among it, and a leaf given a body is refused
|
||||
at its opener, as a container missing its closer is (the maintainer, 2026-09-16); 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 of a shape 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.2.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.2.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.json` version on `main` is the source of truth. CI on `main`: tests green and version
|
||||
differs from npm → publish and tag `vX.Y.Z`. No bump, no deploy; the bump is each shipping PR's
|
||||
deliberate semver judgment. `publish.sh` is that job, and `private: true` stops it before it
|
||||
reads the token, so the pipeline is live and silent until the maintainer's first bump drops the
|
||||
field.
|
||||
- Docs on `main` describe the release being built rather than the version npm holds, so they match
|
||||
it the moment the bump publishes; add no interim note marking the gap (the maintainer,
|
||||
2026-09-16).
|
||||
- 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 `main` rather 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's `dist`: the lockfile is committed, the image is patch-pinned and `tsc` is
|
||||
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.
|
||||
- The bump commit renames `CHANGELOG.md`'s `## Unreleased` to the version.
|
||||
- Exact versions: `save-exact=true` in `.npmrc`.
|
||||
- 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`, never `node:24`), as
|
||||
specific as the publisher tags: `oven/bun:1.4.0-alpine` pins Bun's patch and leaves the base
|
||||
floating because Bun publishes nothing narrower. Actions pin semver tags.
|
||||
|
||||
## 10. Tests first, in Docker
|
||||
## 3. 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,
|
||||
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 round-trip, normalization and error fixtures and the real payloads —
|
||||
the `commonmark-spec` sort is the Node suite's to check — 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.
|
||||
A WebDriver session 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 leg re-checks the conversions and nothing else — each fixture's emitted markdown, its parsed
|
||||
document, its error code — leaving the corpus's pairing, uniqueness, source positions and
|
||||
byte-level equality to the Node suite that owns them.
|
||||
Node, tsc and npm never run on the host — only via the pinned images (§2). Tests are independent,
|
||||
containers are torn down after a run. A test reaches only for what Node's, Deno's and Bun's `node:`
|
||||
shims all carry.
|
||||
|
||||
Every leg announces its name and, where a container is in play, the image, before it runs and its
|
||||
elapsed time after, `publish.sh` alongside `ci.sh`, so a long run reads as progress rather than as
|
||||
@@ -248,211 +85,93 @@ a hang. A leg added later owes the same marker, and a function a leg reaches cha
|
||||
with `&&`, because the `||` that captures the leg's status suspends `set -e` for everything it
|
||||
calls. A leg whose output is both streamed and grepped keeps the copy in a `mktemp`
|
||||
file: `tee /dev/stderr` reopens fd 2, and under `./ci.sh > log 2>&1` the two offsets punch NUL
|
||||
holes through each other's lines (4d).
|
||||
holes through each other's lines.
|
||||
|
||||
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.
|
||||
`PROPERTY_RUNS=<runs>` raises the property runs and randomizes the seed for local digging. The
|
||||
generators and run parameters properties share live in `src/conformance/property-harness.ts`,
|
||||
outside the build and coverage.
|
||||
|
||||
The size ratchet is the other such number, `.oxlintrc.json`'s single rule over the files
|
||||
`tsconfig.build.json` builds: a per-function line ceiling, set at that set's worst and moving only
|
||||
downward. It covers the built files alone, since one ceiling over the tests too would have to be
|
||||
their worst, loosening the guard over the shipped code. It guards against drift and never drives a
|
||||
refactor, so no cyclomatic rule and no second lint rule join it: neither measure picked out what
|
||||
nine readers found hard (the comprehension panel, 2026-09-20). Three switches guard a silent green:
|
||||
`IIFEs: true`, since oxlint exempts an IIFE otherwise; an explicit `-c`, so a config gone missing
|
||||
fails the leg instead of falling back to oxlint's own defaults; and `--deny-warnings`, since a rule
|
||||
from a category this config never names arrives as a warning it exits 0 on.
|
||||
Each `- ` bullet in `spec/flavour.md`'s `## 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`; fenced examples are skipped. Keep prose out of a bullet.
|
||||
|
||||
The corpus, all checked in: hand-built fixtures per node and combination; real ADF Atlassian's
|
||||
editor wrote; the CommonMark spec suite against `markdownToAdf` and `markdownToHtml`.
|
||||
## 4. Code rules
|
||||
|
||||
Beside the corpus, properties run over documents generated from the node tables and over generated
|
||||
markdown, on a fixed seed in the gate; `PROPERTY_RUNS=<runs>` raises the runs and randomizes the
|
||||
seed for local digging, and a counterexample found becomes a round-trip fixture. The generators and
|
||||
run parameters properties share live in `src/property-harness.ts`, outside the build and coverage.
|
||||
|
||||
`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.
|
||||
|
||||
The tables answer to Atlassian's schema too (§5): for every node and mark they spell, the attribute
|
||||
names and kinds equal what `full.json` and `stage-0.json` hold between them. Value sets stay
|
||||
documentation, since any value round-trips. What the schema holds and the tables do not spell is
|
||||
pinned by name — an attribute as a gap, a type as carried — so a re-pin adding either goes red until
|
||||
someone spells it or pins it.
|
||||
|
||||
## 11. Code rules
|
||||
|
||||
- Two-space indent, strict TypeScript, English everywhere. Alphabetical order wherever order
|
||||
carries no meaning, keyed on the name a line introduces rather than where it came from: an
|
||||
import sorts on its first binding, type imports ahead of value imports, so moving or renaming a
|
||||
module reorders nothing (the maintainer, 2026-09-18).
|
||||
- Failures are values: everything returns
|
||||
`Result<T>` — `{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws.
|
||||
`try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on
|
||||
the spot. A reader with no path to name returns `Read<T>` instead, the same two arms over a
|
||||
`ConvertFault`, and `faulted` attaches 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.
|
||||
`matchEmphasis` transcribes the reference `process_emphasis` line 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 `undefined` for 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.md` 2b). A readable spelling that must spell its subtree
|
||||
before it can give way — the list, whose thematic-break first line and blank lines exist only
|
||||
spelled — hands that one walk to the general form instead: giving way after the walk walks
|
||||
again at every level, doubling per level (4b).
|
||||
- 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
|
||||
`Result` rather than the stack overflow that waits near 2000. A level is one block-list
|
||||
recursion in either direction: a readable list's items sit one below it, its directive
|
||||
spelling's two. So a list giving way after its walk owes the directive form a level the walk
|
||||
did not count, and the walk reports its headroom — the least slack any depth guard below it
|
||||
has — for the fallback to refuse at zero rather than walk again; counting every list twice
|
||||
halved the list limit, counting the directive form once doubled the parser's frames per level
|
||||
(the maintainer, 2026-09-18).
|
||||
- Nothing spreads an unbounded array into a call — a node's siblings, a code block's held lines, a
|
||||
mark run's segments: the argument list caps near 125k and throws a `RangeError` where a `Result`
|
||||
is owed. A walk pushes one at a time. A literal spread (`[...value]`) is not the same thing and
|
||||
is fine (4c).
|
||||
- A loop retrying an input until a fallback spells it refuses the pass taking no fallback, so its
|
||||
termination is the loop's own check (28).
|
||||
- A reader takes the text and an index — a sticky regex whose `lastIndex` the 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. A
|
||||
scan may keep what it read for a later walk of the same text, and the fallback where it kept
|
||||
nothing must be the same reader over the same text at the same index, so the two cannot disagree
|
||||
— which is what makes the kept value a memo rather than a second spelling (4c).
|
||||
- The parse keeps each node's readable spelling in a memo, so the `commonMarkSpelling` ask stops
|
||||
spelling a node once per level above it (18). The node reference is the key, which holds
|
||||
because the parse builds one object per position; `adfToMarkdown` passes no memo, where a
|
||||
consumer's document may hold one node at two positions (4b). `text` and `spelling` carry no depth
|
||||
and `headroom` is affine in it, so a read at or above the depth that filled the entry rebases; a
|
||||
read below re-spells, because a hit skips the depth guards the walk it replaces runs and an
|
||||
ordered list past the marker cap gives way, spending two emitter levels where the parser spent
|
||||
one. A give-way is kept too and serves any depth, reading the node's shape alone. Only what
|
||||
succeeded is kept, so no path minted at another position is ever read.
|
||||
- Two-space indent, English everywhere. Alphabetical order wherever order carries no meaning,
|
||||
keyed on the name a line introduces: an import sorts on its first binding, type imports ahead of
|
||||
value imports, so moving or renaming a module reorders nothing.
|
||||
- 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, imports no format, and is where a construct both formats
|
||||
read lives: the question is answered in ADF's vocabulary — a node type, an attribute kind, a
|
||||
content model — and no delimiter, element name or escape reaches it. A helper that cannot answer
|
||||
that way is two constructs, the ADF question there and the spelling in each format, the seam
|
||||
`markAttributes` and `markSpellings` already draw; one that cannot be split is a gap to ask (§15).
|
||||
`markdown/` and `html/` are peers: neither imports the other, and no third directory sits between
|
||||
them. A primitive knowing neither ADF nor a format — `result.ts`, `json-value.ts`, `nesting.ts`,
|
||||
`canonical-json.ts` — stays at `src/` root. A construct rises to `adf/` on its second consumer,
|
||||
not in anticipation of one (the maintainer, 2026-09-21).
|
||||
- Each format directory (`markdown/`, `html/`) parts into `emit/` (ADF→format) and `parse/`
|
||||
(format→ADF), the rest of it holding what both directions read. A construct's reader lives there
|
||||
beside the regex the emitter escapes against, so the two cannot drift; a reader with no emit
|
||||
counterpart goes in `parse/`, unless it is part of a construct that side 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 asks `commonMarkSpelling` which
|
||||
form the emitter picks, and `openingLinkTakesDirective` whether the line a paragraph's opening
|
||||
link starts forces the directive link, 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`, never
|
||||
`adf/adf-document.ts`. A name is the noun `spec/flavour.md` or ADF's schema uses for the
|
||||
thing; a directory follows a split the spec draws; a placement these rules leave open goes
|
||||
beside its only reader, or in what both read where there are two (the maintainer, 2026-09-18).
|
||||
- Failures are values: everything returns `Result<T>`, nothing throws. `try/catch` only wrapped
|
||||
tightly around a call that genuinely throws, converted to a result on the spot. A reader with no
|
||||
path to name returns `Read<T>`, and the walk attaches the path where it knows it.
|
||||
- Reuse before adding; the smallest sufficient diff is the benchmark; no speculative generality —
|
||||
a second consumer, or it goes.
|
||||
- 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`, never `adf/adf-document.ts`. A name
|
||||
is the noun `spec/flavour.md` or ADF's schema uses for the thing.
|
||||
|
||||
## 12. Prose to a minimum
|
||||
## 5. 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.
|
||||
A second line belongs in the commit message or a `docs/decisions.md` entry.
|
||||
- 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
|
||||
## 6. Commits and PRs
|
||||
|
||||
One-line commit messages and PR titles; short PR summaries. No AI-attribution markers, ever.
|
||||
|
||||
## 14. Non-goals
|
||||
## 7. The working loop
|
||||
|
||||
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. The session stops there
|
||||
whatever it was asked to finish: a release is a chain of sessions, and `todo.md`'s "Next session" is
|
||||
the handover, so an instruction to work until a release is checked names the chain, not the session.
|
||||
A session works one chunk, starting from the first item in `todo.md` not waiting on an unmet "Lands
|
||||
after", and stops when that chunk merges, whatever it was asked to finish: a release is a chain of
|
||||
sessions, so an instruction to work until a release is done names the chain, not the session. An
|
||||
open PR is a chunk already in flight, and finishing it is the session.
|
||||
Per chunk:
|
||||
|
||||
1. Fresh worktree off updated `origin/main`; implement tests-first (§10).
|
||||
1. Fresh worktree off updated `origin/main`; implement tests-first (§3).
|
||||
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.2.0` release —
|
||||
the maintainer, 2026-09-13), check the box in `todo.md` and move the item's text to
|
||||
`todo-history.md`, leaving its title behind, report, stop.
|
||||
3. Merge the PR (standing authorization, this repo only, granted by the maintainer through the
|
||||
`0.2.0` release), report, stop.
|
||||
|
||||
Reserved for the maintainer whatever any rule here says: changing `version` in `package.json` (a
|
||||
bump on `main` publishes, §9 — every release is the maintainer's) and the `NPM_TOKEN` secret.
|
||||
bump on `main` publishes, `docs/decisions.md` §Publish on a version bump — every release is the
|
||||
maintainer's) and the `NPM_TOKEN` secret.
|
||||
|
||||
### 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.
|
||||
Any choice where what the maintainer would pick is not near-certain gets asked. The confidence bar
|
||||
is very high — asking too often is the accepted cost, guessing wrong is not.
|
||||
|
||||
An ask is a gap in this file, and its answer is the rule that closes the gap, never the instance
|
||||
alone. Before asking, name the class the question belongs to and the earlier `(the maintainer, …)`
|
||||
entries of that class; where a rule already decides it, apply it without asking, and where the rule
|
||||
reads two ways on this input, that reading is the ask. Never ask "A or B?": state the gap, the
|
||||
earlier asks of its class, the nearest text here, a candidate rule in this file's voice and section,
|
||||
and the instance it yields, and ask for the rule. The maintainer answers the rule, the rule lands
|
||||
here, and the instance follows from it in the chunk. A rule that keeps collecting instances is
|
||||
wrong: rewrite it rather than append to it.
|
||||
An ask is a gap in `docs/decisions.md`, and its answer is the entry that closes it, landing there —
|
||||
never the instance alone; an answer that is a goal lands in the README, one that is a working rule
|
||||
here. Before asking, name the class the question belongs to and the entries of that class; where
|
||||
one already decides it, apply it without asking, and where it reads two ways on this input, that
|
||||
reading is the ask. Never ask "A or B?": state the gap, the earlier entries of its class, the
|
||||
nearest text, a candidate entry in that file's voice, and the instance it yields. An entry that
|
||||
keeps collecting instances is wrong: rewrite it.
|
||||
|
||||
### Rules the loop has settled (the maintainer, 2026-09-18)
|
||||
Which output the audience expects — README goal 5 — is settled by a reader panel rather than
|
||||
asked: three fresh-context readers, one per README persona the conversion serves, each given only
|
||||
`## Audience` and the input, writing what they expect before picking among outputs the goals
|
||||
allow, rendered, shuffled, with no rationale and nothing saying what is implemented. Three agreeing
|
||||
settle it; otherwise four more read, five of seven settle it, and less is a missing goal, asked.
|
||||
The verdict lands in `docs/decisions.md`.
|
||||
|
||||
- A finding inside the chunk's item is fixed in the chunk. Outside it, a new `todo.md` item, always
|
||||
in a release, weighed against every item on that release by the personas and §1–§3 — an item it
|
||||
outweighs moves later. A weighing no rule decides is asked as a gap.
|
||||
- A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks,
|
||||
naming the number it can reach. A number the code needs and no rule states is a gap.
|
||||
- Where the shipping order names no release for the next unchecked item, the chunk is planning that
|
||||
release: every unscheduled item weighed as above, the order written in `todo.md`, and the
|
||||
maintainer's approval taken before any code.
|
||||
### Stated numbers
|
||||
|
||||
A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks, naming
|
||||
the number it can reach. A number the code needs and no entry states is a gap.
|
||||
|
||||
### The continuous loop
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **Breaking:** directives, the opaque carry among them (now `carry`), are spelled under an `!adf:`
|
||||
prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`, `!adf:name arg {attrs}`) in place
|
||||
of the `:::`/`::`/`:name` forms: text holding an unescaped `!adf:` is claimed, and `adf` is an
|
||||
ordinary code block language. Convert stored markdown per `MIGRATION.md`.
|
||||
- **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no
|
||||
CommonMark escape spells is written as `!adf:link[text]{attrs}`.
|
||||
- **Breaking:** some directive refusals carry `malformed-directive` where they carried
|
||||
`unsupported-node-shape`, and an empty node's leaf and closed spellings swap which one parses;
|
||||
`MIGRATION.md` lists each.
|
||||
- **Breaking:** a link whose text holds another link keeps the inner link and leaves the outer
|
||||
brackets literal text, where `0.1.0` split the outer link around it; see `MIGRATION.md`.
|
||||
- Add `adfToPlainMarkdown` and `plainMarkdownToAdf`, a lossy pair converting ADF to and from
|
||||
markdown GitHub, GitLab and Obsidian render: alerts, callouts, task lists, `==highlights==` and
|
||||
pipe tables.
|
||||
- Spell `rule`'s `color`, `style` and `weight`, `layoutSection`'s `columnRuleStyle` and a link's
|
||||
`collection`, `id` and `occurrenceKey` directly where they rode the opaque carry.
|
||||
- Fix an image inside another image's description: it flattens into the alt text, where it was
|
||||
refused.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- First release: lossless conversion between ADF and an extended markdown flavour —
|
||||
`adfToMarkdown`, `markdownToAdf` and `isAdfDocument`. Nothing throws, and every error carries a `code` from a
|
||||
closed list.
|
||||
@@ -5,7 +5,10 @@ an HTML dialect.
|
||||
|
||||
**Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at
|
||||
`0.2.0`.**
|
||||
Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar:
|
||||
Plan:
|
||||
[`todo.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/todo.md). Decisions:
|
||||
[`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md). Changes:
|
||||
[`CHANGELOG.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/CHANGELOG.md). The lossless flavour's grammar:
|
||||
[`spec/flavour.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/spec/flavour.md).
|
||||
Upgrading from `0.1.0`: [convert your markdown first](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/MIGRATION.md).
|
||||
|
||||
@@ -21,39 +24,33 @@ represent.
|
||||
|
||||
## Goals
|
||||
|
||||
In priority order.
|
||||
The most useful ADF conversion library available, judged by these goals, in priority order:
|
||||
|
||||
1. **Lossless first.** The round-trip holds for every document, node types this version does not
|
||||
know included; one that has no spelling is refused and says where, never silently reduced.
|
||||
Every goal below gives way to this one.
|
||||
2. **Three formats, ADF the hub.** ADF, one markdown flavour, one HTML dialect, markdown↔HTML
|
||||
composing through ADF — four conversions to keep correct, never a fifth, and never a fourth
|
||||
format.
|
||||
3. **Plain CommonMark is input.** Markdown written for something else converts — the exceptions
|
||||
below are the whole of them — and every spelling the flavour claims on top of CommonMark is
|
||||
escapable, so the flavour is opt-in.
|
||||
4. **Output a person can edit.** A node CommonMark can spell gets that spelling; the directive
|
||||
form carries only what CommonMark cannot hold.
|
||||
5. **Failures are values.** Nothing throws, and `code` is a closed list — as much a contract as
|
||||
the emitted formats are.
|
||||
6. **Nothing in the way.** No runtime dependencies, no I/O, no configuration, no host API: ESM on
|
||||
any ES2022 engine, in a browser as readily as on a server.
|
||||
1. **Lossless, and every call returns a result, never a throw.**
|
||||
2. **ADF is the hub.**
|
||||
3. **Each format reads and writes as its standard says.**
|
||||
4. **Our markdown is CommonMark, extended only where CommonMark has no spelling.**
|
||||
5. **No surprises: output reads and edits the way its audience expects.**
|
||||
6. **Lossy conversion drops form, never content.**
|
||||
7. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.**
|
||||
8. **Fast, and linear in the document's size.**
|
||||
9. **Easy to find, and clear at a glance what it does.**
|
||||
|
||||
## Audience
|
||||
|
||||
Application developers embedding the library, addressed as personas rather than named consumers
|
||||
(AGENTS.md §7). All four rely on the guarantees below and on `code` being a closed list; none may
|
||||
rely on an error message's wording, which is free text.
|
||||
Application developers embedding the library, in four personas. All four rely on the guarantees
|
||||
below and on an error's `code` being a closed list; none may rely on an error message's wording,
|
||||
which is free text.
|
||||
|
||||
- **Viewer/editor app** — shows a document, lets a human edit, posts it back. Relies on the
|
||||
round-trip holding for whatever the site's editor wrote, unknown node types included, and on a
|
||||
refusal arriving before the save rather than after.
|
||||
- **Bot posting content** — turns generated markdown into ADF. Relies on plain CommonMark being
|
||||
valid input, so nothing upstream has to learn the flavour.
|
||||
valid input, so nothing upstream has to learn a flavour.
|
||||
- **Export/indexing tool** — converts ADF to markdown or HTML in bulk. Relies on readable output
|
||||
and on every refusal being deterministic, so a document that fails fails the same way next run.
|
||||
- **LLM/agent pipeline** — hands documents to a model as markdown and writes the edits back.
|
||||
Relies on the round-trip and on markdown a reader half-knowing the flavour can still edit.
|
||||
Relies on the round-trip and on markdown a reader half-knowing the lossless flavour can still edit.
|
||||
|
||||
## The shape
|
||||
|
||||
@@ -73,25 +70,84 @@ if (result.ok) {
|
||||
}
|
||||
```
|
||||
|
||||
Pure functions, no I/O, no configuration. ADF is the hub: markdown↔HTML compose through it.
|
||||
Serves Goals 1, 2 and 7. Pure functions, each taking a whole document and returning a whole
|
||||
result; no I/O, no configuration. `markdownToHtml` and `htmlToMarkdown` convert through ADF:
|
||||
they keep only what ADF holds, and refuse what `markdownToAdf` or `htmlToAdf` refuses.
|
||||
|
||||
```ts
|
||||
adfToMarkdown(doc: AdfDocument): Result<string>
|
||||
markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
|
||||
isAdfDocument(v: unknown): v is AdfDocument
|
||||
|
||||
adfToPlainMarkdown(doc: AdfDocument): Result<string>
|
||||
plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
|
||||
|
||||
adfToHtml(doc: AdfDocument): Result<string> // 0.2.0
|
||||
htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.2.0
|
||||
markdownToHtml(markdown: string): Result<string> // 0.2.0, via ADF
|
||||
htmlToMarkdown(html: string): Result<string> // 0.2.0, via ADF
|
||||
markdownToHtml(markdown: string): Result<string> // 0.2.0
|
||||
htmlToMarkdown(html: string): Result<string> // 0.2.0
|
||||
```
|
||||
|
||||
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
|
||||
|
||||
## Plain markdown
|
||||
|
||||
Serves Goal 6. Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` writes
|
||||
markdown other tools render — GitHub, GitLab, Obsidian and the like — keeping the content and
|
||||
dropping the rest: attributes, colours, layout, identity. Content is what a reader of the rendered
|
||||
document sees or follows: its text, images and link targets. It refuses only
|
||||
`not-an-adf-document`, `unsupported-document-version` and `unsupported-nesting-depth`, and writes
|
||||
no directive.
|
||||
|
||||
`plainMarkdownToAdf` reads what `markdownToAdf` reads and refuses what it refuses, and reads the
|
||||
conventions below as nodes, taking other tools' spellings too; a backslash keeps a marker as text:
|
||||
`\==x==`, `> \[!NOTE]`, `- \[x]`. Markdown `adfToPlainMarkdown` wrote reads back and writes again
|
||||
byte for byte; the document it came from does not come back.
|
||||
|
||||
To edit a document and save it back, use `adfToMarkdown` and `markdownToAdf`: saving what this pair
|
||||
read replaces mentions, attachments and macros with text.
|
||||
|
||||
| ADF | Written | Read back |
|
||||
| --- | --- | --- |
|
||||
| `panel` | a GitHub alert, `> [!WARNING]`: info `NOTE`, note `IMPORTANT`, tip and success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE` | `NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error, and Obsidian's: hint tip; success, check, done success; attention warning; danger, failure, fail, missing, bug, error error; any other word info — in any case; the rest of the marker's line is the first paragraph |
|
||||
| `expand`, `nestedExpand` | Obsidian's folded callout, `> [!NOTE]- Title` | `-` or `+` after any word, the rest of the marker's line the title; a link reads `text (target)`, or its text alone where the text is the target with or without `mailto:`; an expand inside an expand is a `nestedExpand` |
|
||||
| `taskList` | `- [x] Done`, `- [ ] Todo` | a bullet list whose every item is so marked, `[X]` too |
|
||||
| `backgroundColor` | `==text==` | `==text==` on one line, the text touching both delimiters, bounded outside by whitespace, punctuation or a line edge, or touching a Han, Hangul, Hiragana, Katakana, Thai, Lao, Khmer or Myanmar character on either side, in the editor's default highlight `#f8e6a0` |
|
||||
| `table` | a pipe table: the first row its header, a cell's blocks on one line, a span kept under its header by empty cells | — |
|
||||
| `decisionList` | a bullet list | — |
|
||||
| `mention`, `status`, `emoji`, `date` | their text: `@` kept, a mention with none `@` and its id, an emoji its `shortName` without, a date `2026-09-13` in UTC | — |
|
||||
| `inlineCard`, `blockCard`, `embedCard` | a link to the card's URL, else its name | — |
|
||||
| external `media` | `` in a block, `[alt](url)` inline | — |
|
||||
| stored `media`, `mediaInline`, `extension`, `inlineExtension` | their `alt` or `text` | — |
|
||||
| `layoutSection`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`, `extensionFrame`, `caption`, a node this version does not know | its blocks or its text | — |
|
||||
| `placeholder` | nothing | — |
|
||||
|
||||
- Content the document only references, with no text of its own to keep, leaves an italic note
|
||||
naming it where it stood: `_(image not included)_` for stored media with no `alt`,
|
||||
`_(link card not included)_` for a card with neither URL nor name, an extension with no `text`
|
||||
its key, `_(jira-issues-table not included)_`, or `_(extension not included)_` without one, and
|
||||
`_(synced block not included)_` for a `syncBlock`.
|
||||
- `backgroundColor`, `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops,
|
||||
keeping its text, and so does a mark the flavour cannot spell where it stands.
|
||||
- A newline in text is a hard break and in an expand's title a space, edge whitespace outside a
|
||||
link or code span is trimmed, carriage returns and null characters are removed, and an empty
|
||||
paragraph drops.
|
||||
- An ordered list numbered past `999999999`, or adjacent ordered lists whose numbering does not
|
||||
continue, is one bullet list keeping its numbers as text.
|
||||
- A task list beside a bullet or decision list, or holding a block other than a task, joins one
|
||||
bullet list keeping its states as text: `- \[x] Done`.
|
||||
- Text that would read as a marker takes a backslash: `==` wherever it could open or close a
|
||||
highlight, `[!…]` opening a quote, and `[x]` or `[ ]` opening any list item, since GitHub reads
|
||||
that marker per item.
|
||||
- A node read back carries no `localId` except a `taskList`, `taskItem` or `blockTaskItem`, which
|
||||
Atlassian's schema requires one on: each gets a UUID v4 hashed from the whole markdown and its
|
||||
position, the same on every read. Join markdown bound for one document and read it once: the same
|
||||
markdown read twice into one document repeats its ids.
|
||||
|
||||
## The errors
|
||||
|
||||
An ADF node type this version does not know is not an error: it is carried opaquely and restores
|
||||
unchanged (AGENTS.md §3).
|
||||
Serves Goal 1. An ADF node type this version does not know is not an error: the lossless pair
|
||||
carries it opaquely and restores it unchanged ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
|
||||
|
||||
`ConvertError` is `{ code, message, path, position? }`. `code` is the exported `ConvertErrorCode`,
|
||||
stable across minors and safe to `switch` on exhaustively with no `default`; `message` is free text
|
||||
@@ -111,14 +167,14 @@ UTF-16 code unit, a JavaScript string index rather than a codepoint or a byte of
|
||||
or before the refusal — currently the start of the line the enclosing block begins on; a later
|
||||
minor may narrow that, never widen it.
|
||||
|
||||
Parsing — `markdownToAdf`, and `htmlToAdf` at `0.2.0`:
|
||||
Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.2.0`:
|
||||
|
||||
| Code | Fires when | What you can do |
|
||||
| --- | --- | --- |
|
||||
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in a `carry` | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text |
|
||||
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
|
||||
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
|
||||
| `unmappable-html` | the input holds an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the flavour |
|
||||
| `unmappable-html` | the input holds an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the lossless flavour |
|
||||
| `unmappable-image` | an image sits inside other content that is not another image's description, or carries a title | give the image a paragraph of its own and drop the title |
|
||||
|
||||
Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`:
|
||||
@@ -138,12 +194,17 @@ emit refuses:
|
||||
| `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span |
|
||||
| `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines |
|
||||
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
|
||||
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body |
|
||||
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body |
|
||||
|
||||
## The guarantees
|
||||
|
||||
Serves Goals 1, 3 and 4.
|
||||
|
||||
- `markdownToAdf(adfToMarkdown(doc))` equals `doc` — unknown node types included, carried opaquely
|
||||
(AGENTS.md §3).
|
||||
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
|
||||
- Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from
|
||||
`0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
|
||||
name every exception.
|
||||
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
|
||||
names, with three carve-outs — literal text matching directive, pipe-table or strikethrough
|
||||
syntax is claimed (escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as
|
||||
@@ -169,9 +230,11 @@ emit refuses:
|
||||
error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in
|
||||
input.
|
||||
- Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist
|
||||
is the `taskList` directive.
|
||||
- A document nested deeper than 500 levels is an error result, not a stack overflow.
|
||||
- The emitted formats are semver surface (AGENTS.md §8).
|
||||
is the `taskList` directive — `plainMarkdownToAdf` turns the marker into a `taskList`.
|
||||
- A document nested deeper than 500 levels is an error result, not a stack overflow, and no input
|
||||
makes a call loop forever.
|
||||
- The emitted formats are semver surface
|
||||
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#the-formats-are-api)).
|
||||
- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides
|
||||
`data-*` attributes. Foreign HTML maps a documented element set, which markdown's raw HTML reads
|
||||
through as well, and a construct outside it is an error; well-formed HTML only — no tag-soup
|
||||
@@ -179,7 +242,8 @@ emit refuses:
|
||||
|
||||
## The package
|
||||
|
||||
ESM only, no runtime dependencies, public npmjs. Built JavaScript with `.d.ts` beside it.
|
||||
Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs under Node,
|
||||
Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
|
||||
Contract: `AGENTS.md` §5–6.
|
||||
Serves Goal 7. ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts`
|
||||
beside it. Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs
|
||||
under Node, Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
|
||||
Contract: [`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#any-es2022-engine), §Any
|
||||
ES2022 engine to §Public on npm.
|
||||
|
||||
+6
-7
@@ -1,10 +1,10 @@
|
||||
# The corpus
|
||||
|
||||
One directory per contract kind, each landing with its milestone:
|
||||
One directory per contract kind:
|
||||
|
||||
- `round-trip/` — `<name>.json` + `<name>.md`: the markdown `adfToMarkdown` must emit for that
|
||||
document, byte for byte, and that `markdownToAdf` must read back to it (AGENTS.md §2). Grouped
|
||||
by what the fixture exercises.
|
||||
document, byte for byte, and that `markdownToAdf` must read back to it (`docs/decisions.md` §The
|
||||
round-trip is the product). Grouped by what the fixture exercises.
|
||||
- `normalization/` — `<name>.md` + `<name>.json`: markdown input, and the document
|
||||
`markdownToAdf` must build from it, which must in turn emit and read back to itself. The
|
||||
markdown is not canonical.
|
||||
@@ -16,11 +16,10 @@ One directory per contract kind, each landing with its milestone:
|
||||
is the suite; `refusals.json` pins each refusing example to its error `code`; `exceptions.json`
|
||||
pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a
|
||||
`reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node
|
||||
model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap a later
|
||||
milestone may close).
|
||||
model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap).
|
||||
|
||||
JSON is editor-normal (AGENTS.md §2), two-space indent, keys sorted. `spec.json` is the vendored,
|
||||
upstream machine-readable suite, byte-exact from
|
||||
JSON is editor-normal (`docs/decisions.md` §Equality is editor-normal), two-space indent, keys
|
||||
sorted. `spec.json` is the vendored, upstream machine-readable suite, byte-exact from
|
||||
[spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John
|
||||
MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not
|
||||
re-serialized by the corpus gate.
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
# Decisions
|
||||
|
||||
## Plain markdown is a flavour of the grammar
|
||||
|
||||
2026-09-27, the maintainer. Goal 2. Valid while the plain flavour's spellings are ones the markdown
|
||||
grammar can read and write.
|
||||
|
||||
The lossy pair is the plain flavour: the markdown grammar's reader and writer with the flavour set,
|
||||
its spellings — alerts, callouts, task markers, `==` — read and written there, so a marker line and
|
||||
a backslash reach them intact; what the flavour cannot spell reduces ADF→ADF ahead of the writer.
|
||||
|
||||
## The round-trip is the product
|
||||
|
||||
2026-08-23, real payloads 2026-09-15, the maintainer. Goal 1. Valid while a consumer saves back
|
||||
through the lossless pair.
|
||||
|
||||
`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. Round-trip equality is a property
|
||||
tested over a checked-in corpus (`corpus/README.md`), not a claim made in prose. Its real payloads
|
||||
are invented content written in Atlassian's editor on the maintainer's test site, so none is
|
||||
sanitized and a mention keeps the test user's real account id.
|
||||
|
||||
## Markdown in is a canonical fixpoint
|
||||
|
||||
2026-08-23, the maintainer. Goals 1 and 4. Valid while markdown input may be written by hand.
|
||||
|
||||
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 some things the flavour has no escape for — a
|
||||
paragraph opening with a code span whose backticks read back as a fence — so a parse succeeding
|
||||
does not imply a spellable document; `corpus/commonmark-spec/exceptions.json` names those.
|
||||
|
||||
## Equality is editor-normal
|
||||
|
||||
2026-08-24, the maintainer. Goal 1. Valid while markdown cannot tell apart the ADF shapes this
|
||||
merges.
|
||||
|
||||
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
|
||||
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content
|
||||
array the absent key — the only domain markdown can restore.
|
||||
`todo.md` item 40 replaces this with deep equality (2026-09-28, the maintainer).
|
||||
|
||||
## Unknown nodes ride the carry
|
||||
|
||||
2026-08-23, extended to misplaced known nodes 2026-08-26, the maintainer. Goal 1. Valid while ADF
|
||||
holds nodes, or node positions, this library does not spell.
|
||||
|
||||
An unknown ADF node is 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 — a `bulletList` holding other than
|
||||
`listItem`, a `codeBlock` other than text — the error result names that instead.
|
||||
|
||||
## Foreign HTML sorts three ways
|
||||
|
||||
2026-08-23, the sort 2026-09-20, the maintainer. Goals 1 and 4. Valid while ADF holds no node
|
||||
for a bare container, a comment or a script. Lands with `todo.md` item 6.
|
||||
|
||||
Every foreign element `htmlToAdf` and `markdownToAdf` read sorts one of three ways, never a silent
|
||||
drop of content:
|
||||
|
||||
- A container around document content that ADF has no node for unwraps to its children, its own
|
||||
attributes dropped: `<div align="center">text</div>` keeps `text`, losing the alignment.
|
||||
- Content ADF cannot hold is an error result naming it. A comment is one: a person wrote those
|
||||
words, and neither of Atlassian's schemas holds them — `annotation`'s `inlineComment` carries an
|
||||
id, `placeholder` is the editor's own hint, `extension` names a vendor app.
|
||||
- What is not document content drops whole: `<script>` and `<style>`, their text with them.
|
||||
|
||||
`<details><summary>Title</summary>…</details>` is an `expand` titled by its summary, a
|
||||
`nestedExpand` inside another; an empty one is refused, since `expand` requires content. A `style`
|
||||
attribute is not read at `0.2.0`: the `textColor` and `backgroundColor` it could reach cost more
|
||||
than they buy.
|
||||
`plainMarkdownToAdf` reads through `markdownToAdf`'s parser, so it takes the same set.
|
||||
|
||||
## Names stay text
|
||||
|
||||
2026-08-23, the maintainer. Goal 7. Valid while resolving a name to an id needs I/O.
|
||||
|
||||
A bare `@name` or `:smile:` in typed text stays a text node. Only directives produce
|
||||
mention/emoji/media nodes; resolving names to ids is the consumer's job.
|
||||
|
||||
## Directives under `!adf:`
|
||||
|
||||
2026-08-23, prefixed `!adf:` 2026-09-16, the maintainer. Goals 4 and 5. Valid while prose does not
|
||||
write `!adf:`.
|
||||
|
||||
Directives are one grammar for everything markdown lacks, namespaced under `!adf:`:
|
||||
`!adf:panel info` … `!adf:/panel` blocks, `!adf:mention[@Mikael]{id=5b10a2}` inline, `\!adf:` the
|
||||
one escape. Not CommonMark's generic-directives proposal: its `:::` claims a form prose writes, and
|
||||
its fence-length discipline ties a container's opener to its own body, where closing from the
|
||||
opener nests by itself and leaf versus container falls out of the node's content model.
|
||||
|
||||
## CommonMark is a subset
|
||||
|
||||
2026-08-23, the maintainer. Goal 4. Valid while prose rarely writes the shapes the carve-outs claim.
|
||||
|
||||
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
|
||||
|
||||
2026-08-23, the maintainer. Goal 5. Valid while a pipe table holds only one header row and inline
|
||||
cells.
|
||||
|
||||
One header row plus plain inline cells → pipe table; anything richer → directive form.
|
||||
|
||||
## Links
|
||||
|
||||
2026-09-13, nesting 2026-09-17, the maintainer. Goals 1 and 5. Valid while CommonMark's link
|
||||
syntax is what readers edit.
|
||||
|
||||
`[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark spells the
|
||||
mark; `!adf:link[text]{attrs}` where it does not — an attribute CommonMark cannot hold, an `href` or
|
||||
`title` no canonical escape spells, a paragraph opening whose CommonMark spelling would read as a
|
||||
link reference definition — and a directive link CommonMark could spell is refused. No link wraps a
|
||||
link — the bracket form goes literal, the directive form refused — which is CommonMark's prose
|
||||
where its reference implementation nests one `<a>` in another.
|
||||
|
||||
## Ids stay site-local
|
||||
|
||||
2026-08-23, the maintainer. Goal 1. Valid while ADF ids are minted per site.
|
||||
|
||||
Identity-bearing nodes carry their ids in attributes; a document is only portable within its site —
|
||||
accepted.
|
||||
|
||||
## Plain task ids come from position
|
||||
|
||||
2026-09-26, spelling 2026-09-29, the maintainer. Goals 6 and 7. Valid while a site rejects a task
|
||||
node with no `localId`.
|
||||
|
||||
`plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId`
|
||||
in the editor's UUID v4 shape, hashed from the whole markdown and the node's order among those it
|
||||
mints, skipping any id the document holds: the same markdown reads to the same ids every run,
|
||||
different markdown to different ids. The same markdown pasted twice into one document repeats its
|
||||
ids: determinism wins over that case. A node the carry restores stays deep-equal (§Unknown nodes ride the carry): its
|
||||
ids are only skipped.
|
||||
|
||||
## A callout title keeps its link targets
|
||||
|
||||
2026-09-29, the maintainer. Goals 5 and 6. Valid while an expand's `title` is a string.
|
||||
|
||||
`plainMarkdownToAdf` writes a link in a folded callout's title as its text and its target in
|
||||
parentheses: `> [!faq]- See [x](http://y)` reads to the title `See x (http://y)`. A link whose text
|
||||
is its target, with or without `mailto:`, keeps its text alone: `<http://y>` titles `http://y`,
|
||||
`<a@b.c>` `a@b.c` — three persona readers agreeing, 2026-09-30.
|
||||
|
||||
## The plain flavour's spellings
|
||||
|
||||
2026-09-14, panels 2026-09-25 and 2026-09-29, the maintainer. Goals 5 and 6. Valid while GitHub's
|
||||
renderer is the one the audience's markdown is read in.
|
||||
|
||||
README §Plain markdown's rows come from a survey of GitHub, GitLab, Gitea, Obsidian, Pandoc,
|
||||
MkDocs, Docusaurus, Typora, Joplin, Logseq, Bear, Notion, Azure DevOps and Discord, GitHub's
|
||||
renderer confirming each shape. Reader panels settled `error` as an error panel, the `==` bounds
|
||||
(3 of 3) and a Han, Hangul, kana, Thai, Lao, Khmer or Myanmar character on either side bounding a
|
||||
delimiter, so `は==日本語==で` (3 of 3), `==한국어==에서만` and `iPhone==専用==` (6 of 7) highlight,
|
||||
the external image's two forms (6 of 7), a rule opening a list item dropping and the omission
|
||||
notes (3 of 3), and a list's numbering overflowing into bullets (3 of 3, 5 of 7).
|
||||
An omission note reads as the converter's, never as the author's.
|
||||
Reading takes other tools' spellings, since it reads their output and writes none of them.
|
||||
Rejected: `~sub~` and `^sup^` (`~2~` is a strike on GitHub, so `subsup` drops), underline and colour
|
||||
spellings, raw HTML (`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family,
|
||||
footnotes, definition lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states
|
||||
past `[x]`/`[ ]`, and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes.
|
||||
|
||||
## The HTML dialect
|
||||
|
||||
2026-08-23, the maintainer. Goals 5 and 7. Valid while HTML output is read by consumers styling it
|
||||
themselves.
|
||||
|
||||
The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*`
|
||||
for what HTML cannot express, text always escaped. No stylesheet ships.
|
||||
|
||||
## No runtime dependencies
|
||||
|
||||
2026-08-23, the maintainer. Goal 7. Valid while ~20 lines of own code, or a vendored table, do each
|
||||
job a dependency would.
|
||||
|
||||
`dependencies` is empty. A runtime dependency enters only through an 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.
|
||||
|
||||
## Standards ship as data
|
||||
|
||||
2026-08-30, the CommonMark suite 2026-09-05 and ADF's schemas 2026-09-13, the maintainer. Goals 1,
|
||||
4 and 7. Valid while each table is fixed data a dependency would only wrap.
|
||||
|
||||
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.
|
||||
The CommonMark spec suite is the same shape of data and ships vendored at `corpus/commonmark-spec/`
|
||||
rather than as the `commonmark-spec` dev dependency — that package is CommonJS-only, and Renovate
|
||||
auto-bumping a spec version would silently point the vendored exception list's example numbers at a
|
||||
renumbered suite. A spec bump is a deliberate re-pin, exceptions re-derived by hand beside it.
|
||||
Atlassian's ADF JSON Schemas ship vendored the same way, at `spec/adf-schema/`, rather than as the
|
||||
`@atlaskit/adf-schema` dev dependency — CommonJS-only, some fifty packages with React among them,
|
||||
and a release most days for Renovate to automerge — re-pinned by hand when a payload or a report
|
||||
shows the need.
|
||||
|
||||
## fast-check
|
||||
|
||||
2026-09-14, the maintainer. Goal 1. Valid while a failing generated document needs shrinking by
|
||||
hand otherwise.
|
||||
|
||||
`fast-check` earns its place as a devDependency shrinking a failing generated document to the
|
||||
nodes that break it.
|
||||
|
||||
## Any ES2022 engine
|
||||
|
||||
2026-09-01, the maintainer. Goal 7. Valid while ES2022 is the floor browsers and servers share.
|
||||
|
||||
The library 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.json` is that gate, typechecking and emitting the shipped files alone, so
|
||||
`node:fs`, `process` and 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 the Unicode property escapes emphasis matching leans on 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, and `engines.node` states the floor the shipped JavaScript needs — `>=18` —
|
||||
never the higher one those repo-only tools want.
|
||||
|
||||
## ESM only
|
||||
|
||||
2026-08-23, the maintainer. Goal 7. Valid while the audience's toolchains all import ES modules.
|
||||
|
||||
No CommonJS build, no dual-package hazard.
|
||||
|
||||
## One built entrypoint
|
||||
|
||||
2026-08-23, the maintainer. Goal 7. Valid while Node refuses to type-strip under `node_modules`.
|
||||
|
||||
Built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint — Node refuses to
|
||||
type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so it cannot serve
|
||||
an npm consumer.
|
||||
|
||||
## Public on npm
|
||||
|
||||
2026-08-23, the name 2026-09-01, the maintainer. Goals 2 and 7. Valid while the package's source
|
||||
stays public beside it.
|
||||
|
||||
Published to public npm as `@larvit/adf-codec`. Public source: the Gitea repo goes public,
|
||||
LICENSE in place, before the first publish. A codec, since it converts both directions, and named
|
||||
for the hub rather than the formats around it.
|
||||
|
||||
## The formats are API
|
||||
|
||||
2026-08-23, strict input 2026-09-01, content models 2026-09-16, the maintainer. Goal 1.
|
||||
Valid while consumers store what the library emits.
|
||||
|
||||
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. A spelled node's content model is part of that contract — leaf or
|
||||
container is the model, not the syntax — so giving a spelled node's model content it had not, or
|
||||
taking it away, is MAJOR whatever ADF's own schema does. Input reads the canonical directive
|
||||
spelling alone — spacing, key order, each value's spelling — since loosening it later is MINOR.
|
||||
|
||||
The error surface is a contract too; `README.md` §The errors states it to the consumer, and the
|
||||
types in `src/result.ts` hold its shape.
|
||||
|
||||
## The code list
|
||||
|
||||
2026-08-25, the maintainer; dated below where a rule came later. Goal 1. Valid while a consumer
|
||||
switches on `code` with no `default`.
|
||||
|
||||
- Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose
|
||||
name reads true of it in both directions; where none does and a plain name exists, a new code —
|
||||
in any 0.x minor, and after 1.0 only in a MAJOR (2026-09-18).
|
||||
- A refusal whose cause is this library's own invariant rather than the input takes the existing
|
||||
code nearest what the consumer sees — a document that does not convert is
|
||||
`unsupported-node-shape` — since a code no input reaches is one no consumer can switch on
|
||||
(2026-09-20).
|
||||
- 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 (`unspellable-link`, 2026-09-13). A cause the carry answers gets
|
||||
no code: a mark no spelling writes rides the carry with its node.
|
||||
|
||||
## Which code a cause takes
|
||||
|
||||
2026-08-28, the maintainer; dated below where a rule came later. Goal 1. Valid while a consumer
|
||||
handles one cause alike whichever node, attribute or direction raised it.
|
||||
|
||||
- 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-character` the text node and the code block 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 `!adf: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. What the grammar itself refuses stays a
|
||||
claim code, key order among it, and a leaf given a body is refused at its opener, as a container
|
||||
missing its closer is (2026-09-16).
|
||||
- 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:carry`, whose carry is the fence — and a well-formed form in the wrong place
|
||||
is `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one
|
||||
type (2026-09-01).
|
||||
- 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 of a shape
|
||||
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 (2026-09-23).
|
||||
- A non-finite number takes two codes: `unsupported-node-shape` parsing, `not-an-adf-document`
|
||||
emitting — no document holds one, so no round-trip crosses them (2026-09-23).
|
||||
|
||||
## `message` and `path`
|
||||
|
||||
2026-09-03, the path 2026-09-23, the maintainer. Goals 1 and 4. Valid while a person fixing the
|
||||
input reads `message`.
|
||||
|
||||
- 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: `\!adf:` for a directive, block line and
|
||||
inline alike, `\|` for every pipe row.
|
||||
- `not-an-adf-document` carries the document's own path throughout: seven of the guard's eight
|
||||
branches read the document's own shape, and threading a path to the eighth — a malformed node
|
||||
anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces. The message
|
||||
names the violation instead.
|
||||
|
||||
## Publish on a version bump
|
||||
|
||||
2026-08-23, converging 2026-09-03, the maintainer. Goal 7. Valid while CI on `main` holds the npm
|
||||
token.
|
||||
|
||||
`package.json` version on `main` is the source of truth. CI on `main`: tests green and the version
|
||||
not yet on npm → publish and tag `vX.Y.Z`. No bump, no deploy. `publish.sh` is that job.
|
||||
|
||||
The publish and the tag each check their own end state — the version on npm, the tag on the
|
||||
remote — so a run that dies between them converges on the next push
|
||||
to `main` rather 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's `dist`: the lockfile is committed, the image is patch-pinned and `tsc` is
|
||||
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.
|
||||
|
||||
## Docs describe the release being built
|
||||
|
||||
2026-09-16, the maintainer. Goal 7. Valid while a bump on `main` publishes.
|
||||
|
||||
Docs on `main` describe the release being built rather than the version npm holds, so they match it
|
||||
the moment the bump publishes; add no interim note marking the gap.
|
||||
|
||||
## No schema validation
|
||||
|
||||
2026-08-23, the mark refusal 2026-08-25, the maintainer. Goal 1. Valid while the site a document
|
||||
is saved to validates it.
|
||||
|
||||
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.
|
||||
|
||||
## The gate runs on Deno and Bun
|
||||
|
||||
2026-09-01, Deno's reason 2026-09-28, the maintainer. Goals 3 and 7. Valid while the library claims
|
||||
any ES2022 engine.
|
||||
|
||||
The gate runs the suite under Deno and Bun as well as Node. Bun runs JavaScriptCore, the one engine
|
||||
of the three that is not V8, where the Unicode property escapes emphasis matching leans on can
|
||||
disagree. Deno shares Node's V8 and stays to prove the library runs there too, catching what the
|
||||
two runtimes leave undocumented. Both refuse a run matching no test, so Node's is the only
|
||||
vacuous-green guard, and `AGENTS.md` §3's `node:` shims rule is the price of proving those engines
|
||||
over the corpus rather than over a smoke import.
|
||||
|
||||
## The gate installs the tarball
|
||||
|
||||
2026-09-03, the maintainer. Goal 7. Valid while consumers install the packed package.
|
||||
|
||||
The gate 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.
|
||||
|
||||
## Firefox reads the build
|
||||
|
||||
2026-09-04, the maintainer. Goal 7. Valid while the library claims a browser and no other leg runs
|
||||
SpiderMonkey.
|
||||
|
||||
A headless Firefox loads `dist/index.js` over HTTP and converts the round-trip, normalization and
|
||||
error fixtures and the real payloads — the `commonmark-spec` sort is the Node suite's to check —
|
||||
which is the browser half of §Any ES2022 engine and the only SpiderMonkey there is — the gate's
|
||||
other engines are two V8s and a JavaScriptCore that is not Safari's. A WebDriver session 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 leg re-checks
|
||||
the conversions and nothing else — each fixture's emitted markdown, its parsed document, its error
|
||||
code — leaving the corpus's pairing, uniqueness, source positions and byte-level equality to the
|
||||
Node suite that owns them. `selenium/standalone-firefox` runs it over the smaller
|
||||
`instrumentisto/geckodriver`: the leg is worth a current SpiderMonkey, and that image fell four
|
||||
Firefox majors behind.
|
||||
|
||||
## The coverage floors
|
||||
|
||||
2026-08-24, the maintainer. Goal 1. Valid while `noUncheckedIndexedAccess` and ADF's optional keys
|
||||
force guards with a half no valid document reaches.
|
||||
|
||||
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 size ratchet
|
||||
|
||||
2026-09-20, the maintainer. KISS, a technical principle. Valid while no measure picks out what
|
||||
readers find hard better than a function's length.
|
||||
|
||||
`.oxlintrc.json`'s single rule, over the files `tsconfig.build.json` builds, is a per-function line
|
||||
ceiling, set at that set's worst and moving only downward. It covers the built files alone, since
|
||||
one ceiling over the tests too would have to be their worst, loosening the guard over the shipped
|
||||
code. It guards against drift and never drives a refactor, so no cyclomatic rule and no second lint
|
||||
rule join it: neither measure picked out what nine readers found hard (the comprehension panel,
|
||||
2026-09-20). `oxlint` measures it since TypeScript 7 is a native compiler publishing no in-process
|
||||
parser, only the `unstable/` AST surface an out-of-process handshake reaches. Three switches guard
|
||||
a silent green: `IIFEs: true`, since oxlint exempts an IIFE otherwise; an explicit `-c`, so a
|
||||
config gone missing fails the leg instead of falling back to oxlint's own defaults; and
|
||||
`--deny-warnings`, since a rule from a category this config never names arrives as a warning it
|
||||
exits 0 on.
|
||||
|
||||
## Properties on a fixed seed
|
||||
|
||||
2026-09-14, the maintainer. Goal 1. Valid while a red gate must reproduce.
|
||||
|
||||
Beside the corpus, properties run over documents generated from the node tables and over generated
|
||||
markdown, on a fixed seed in the gate; a counterexample found becomes a round-trip fixture.
|
||||
|
||||
## The CommonMark suite checks three ways
|
||||
|
||||
2026-08-27, the maintainer. Goals 1 and 3. Valid while the suite's answers are HTML ADF cannot be
|
||||
compared against.
|
||||
|
||||
Each example is a named error or markdown that parses and emits to itself byte for byte; its
|
||||
reference HTML's text, tags stripped and entities decoded, equals the parsed document's; and its
|
||||
elements count the marks and nodes they map to. The fixpoint alone passes a parser returning the
|
||||
empty document, the text alone one dropping every emphasis. An exception is the maintainer's to
|
||||
add, and valid CommonMark parsing to a document `adfToMarkdown` refuses where a spelling could exist
|
||||
is a bug to fix, never an exception.
|
||||
|
||||
## The flavour spec is read as a source
|
||||
|
||||
2026-09-01, the maintainer. Goal 1. Valid while `spec/flavour.md` restates the node tables in
|
||||
prose.
|
||||
|
||||
`spec/flavour.md` is read as a source, so the node tables cannot drift from the prose they copy:
|
||||
its node and mark bullets must equal the tables in `adf/`. It guards the attributes alone: nodes
|
||||
that differ in content model share a bullet, and the argument attribute is spelled outside the
|
||||
bullet's attribute list, so both answer to the round-trip corpus and to nothing else where a node
|
||||
has no fixture.
|
||||
|
||||
## The node tables answer to Atlassian's schema
|
||||
|
||||
2026-09-13, the maintainer. Goal 1. Valid while a site's editor writes what Atlassian's schema
|
||||
holds.
|
||||
|
||||
For every node and mark the tables spell, the attribute names and kinds equal what `full.json` and
|
||||
`stage-0.json` (§Standards ship as data) hold between them. Value sets stay documentation, since
|
||||
any value round-trips. What the schema holds and the tables do not spell is pinned by name — an
|
||||
attribute as a gap, a type as carried — so a re-pin adding either goes red until someone spells it
|
||||
or pins it.
|
||||
|
||||
## Nothing recurses unbounded
|
||||
|
||||
2026-08-25, the directive form's count 2026-09-18, the maintainer. Goal 1. Valid while an engine's
|
||||
stack overflows near 2000 frames.
|
||||
|
||||
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 (`largestNesting`), so a deep document is a `Result`
|
||||
rather than the stack overflow that waits near 2000. An attribute is counted from its value; a
|
||||
spelling that nests it deeper — the block directive's `marks`, the carry — refuses in its own
|
||||
format, as its parser does. A list giving way to the directive form refuses at zero headroom rather
|
||||
than walking again; counting every list twice halved the list limit, counting the directive form
|
||||
once doubled the parser's frames per level.
|
||||
|
||||
## Nothing spreads an unbounded array
|
||||
|
||||
2026-09-18, the maintainer. Goal 1. Valid while engines cap a call's arguments.
|
||||
|
||||
Nothing spreads an unbounded array into a call — a node's siblings, a code block's held lines, a
|
||||
mark run's segments: the argument list caps near 125k and throws a `RangeError` where a `Result` is
|
||||
owed. A walk pushes one at a time. A literal spread (`[...value]`) is not the same thing and is
|
||||
fine.
|
||||
|
||||
## A retry loop checks its own termination
|
||||
|
||||
2026-09-20, the maintainer. Goal 1. Valid while a fallback can fail to spell what it is handed.
|
||||
|
||||
A loop retrying an input until a fallback spells it refuses the pass taking no fallback, so its
|
||||
termination is the loop's own check.
|
||||
|
||||
## Readers scan by index
|
||||
|
||||
2026-08-30, the kept scan 2026-09-18, the maintainer. Goal 8. Valid while the pipeline persona
|
||||
feeds documents nobody typed.
|
||||
|
||||
A reader takes the text and an index — a sticky regex whose `lastIndex` the 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: a megabyte through a quadratic walk is a minute rather
|
||||
than a millisecond. A scan may keep what it read for a later walk of the same text, and the
|
||||
fallback where it kept nothing must be the same reader over the same text at the same index, so the
|
||||
two cannot disagree — which is what makes the kept value a memo rather than a second spelling.
|
||||
|
||||
## The spelling memo
|
||||
|
||||
2026-09-19, the maintainer. Goal 8. Valid while the `commonMarkSpelling` ask spells a node once
|
||||
per level above it otherwise.
|
||||
|
||||
The parse and the plain reduction keep each node's readable spelling in a memo, so the
|
||||
`commonMarkSpelling` ask stops spelling a node once per level above it. `text` and `spelling` carry
|
||||
no depth and `headroom` is affine in it, so a read at or above the depth that filled the entry
|
||||
rebases; a read below re-spells, because a hit skips the depth guards the walk it replaces runs and
|
||||
an ordered list past the marker cap gives way, spending two emitter levels where the parser spent
|
||||
one. Only what succeeded is kept, so no path minted at another position is ever read.
|
||||
|
||||
## Cost fixes are measured, never timed
|
||||
|
||||
2026-09-18, the maintainer and the stability-reviewer. Goal 8. Valid while Goal 8 promises growth
|
||||
rather than a figure.
|
||||
|
||||
A cost fix that changes no behaviour lands on the suite staying green with no fixture output
|
||||
changed, and a before-and-after figure in its PR; the gate times nothing. Measured and kept:
|
||||
`adfDocumentFault`'s shape and depth walks stay two — the parting gives depth its own code — at
|
||||
52 ms for a 9 MB document the emit takes 314 ms over; and `continuesContainer`'s re-scan per item
|
||||
level stays, linear in the lines and bounded in depth by the 500-level guard.
|
||||
|
||||
## Only the hard break holds a raw newline
|
||||
|
||||
2026-08-26, the maintainer. Goal 1. Valid while the whitespace carry finds a line edge by its raw
|
||||
newline.
|
||||
|
||||
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 follows CommonMark's matching
|
||||
|
||||
2026-08-27, the maintainer. Goals 1 and 3. Valid while CommonMark's emphasis rules are the
|
||||
reader's.
|
||||
|
||||
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.
|
||||
|
||||
## Readable spellings take the `try` prefix
|
||||
|
||||
2026-09-21, the maintainer. Goals 1 and 5. Valid while a readable spelling's refusal would cost a
|
||||
document the general form spells.
|
||||
|
||||
A readable spelling tried ahead of a general one takes the `try` prefix 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. A readable spelling
|
||||
that must spell its subtree before it can give way — the list, whose thematic-break first line and
|
||||
blank lines exist only spelled — hands that one walk to the general form instead: giving way after
|
||||
the walk walks again at every level, doubling per level.
|
||||
|
||||
## The attribute vocabulary is ADF's
|
||||
|
||||
2026-08-27, the maintainer. Goal 2. Valid while every format spells the same ADF attributes.
|
||||
|
||||
`adf/` walks the attribute vocabulary 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.
|
||||
|
||||
## The source parts by ADF and format
|
||||
|
||||
2026-08-27, placement 2026-09-18, `adf/`'s bar 2026-09-21, the maintainer. Goal 2. Valid while
|
||||
each format has a reader and a writer through ADF.
|
||||
|
||||
`src/adf/` holds ADF's own knowledge, imports no format, and is where a construct both formats read
|
||||
lives: the question is answered in ADF's vocabulary — a node type, an attribute kind, a content
|
||||
model — and no delimiter, element name or escape reaches it. A helper that cannot answer that way
|
||||
is two constructs, the ADF question there and the spelling in each format, the seam
|
||||
`markAttributes` and `markSpellings` already draw; one that cannot be split is a gap to ask.
|
||||
`markdown/` and `html/` are peers: neither imports the other, and no third directory sits between
|
||||
them. A primitive knowing neither ADF nor a format stays at `src/` root. A construct rises to
|
||||
`adf/` on its second consumer, not in anticipation of one. A directory follows a split
|
||||
`spec/flavour.md` draws, and a placement nothing here settles goes beside its only reader, or in
|
||||
what both read where there are two.
|
||||
|
||||
Each format directory parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest of it
|
||||
holding what both directions read. A construct's reader lives there beside the regex the emitter
|
||||
escapes against, so the two cannot drift; a reader with no emit counterpart goes in `parse/`,
|
||||
unless it is part of a construct that side 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, and that is the only import `parse/` takes from `emit/` —
|
||||
`commonMarkSpelling` and `openingLinkTakesDirective` — 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.
|
||||
@@ -1,11 +1,13 @@
|
||||
import { adfToMarkdown, isAdfDocument, markdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec'
|
||||
import { adfToMarkdown, adfToPlainMarkdown, isAdfDocument, markdownToAdf, plainMarkdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec'
|
||||
|
||||
const document: AdfDocument = { content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
|
||||
|
||||
const emitted: Result<string> = adfToMarkdown(document)
|
||||
const parsed: Result<AdfDocument, ParseError> = markdownToAdf('x\n')
|
||||
const plainEmitted: Result<string> = adfToPlainMarkdown(document)
|
||||
const plainParsed: Result<AdfDocument, ParseError> = plainMarkdownToAdf('x\n')
|
||||
const guarded: boolean = isAdfDocument(document)
|
||||
const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code
|
||||
const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line
|
||||
|
||||
export const surface = { code, guarded, line }
|
||||
export const surface = { code, guarded, line, plainEmitted, plainParsed }
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"size-ratchet": "oxlint --deny-warnings -c .oxlintrc.json src",
|
||||
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-exclude=src/property-harness.ts --test-coverage-branches=98 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
|
||||
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-exclude=src/conformance/property-harness.ts --test-coverage-branches=98 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -30,7 +30,6 @@ fi
|
||||
published=$(leg "ask npmjs for $name@$version ($node_image)" published_version "$name" "$version")
|
||||
tagged=$(leg "ask origin for v$version" git ls-remote --tags origin "v$version")
|
||||
|
||||
# Both steps observe their own end state, so a partial run converges on the next push to main.
|
||||
if [ -z "$published" ]; then
|
||||
: "${NPM_TOKEN:?the publish needs NPM_TOKEN}"
|
||||
leg "install ($node_image)" in_image "$node_image" npm ci
|
||||
|
||||
+55
-54
@@ -1,12 +1,12 @@
|
||||
# The markdown flavour
|
||||
|
||||
The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain
|
||||
CommonMark is a subset apart from raw HTML (below), with three carve-outs: literal text that
|
||||
matches directive syntax below or reads as a pipe table is claimed by the flavour, and a matched
|
||||
`~~` pair spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal) — and one gap: a
|
||||
CommonMark image fits only as its own
|
||||
title-less paragraph — mid-text and titled images are named errors. The emitted form is contract
|
||||
(AGENTS.md §8). Per-node syntaxes build on this grammar in the sections below.
|
||||
CommonMark is a subset apart from raw HTML (below), with three carve-outs: literal text that matches
|
||||
directive syntax below or reads as a pipe table is claimed by the flavour, and a matched `~~` pair
|
||||
spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal) — and one gap: a CommonMark
|
||||
image fits only as its own title-less paragraph — mid-text and titled images are named errors. The
|
||||
emitted form is contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this
|
||||
grammar in the sections below.
|
||||
|
||||
## Canonical form
|
||||
|
||||
@@ -67,22 +67,21 @@ normalizes to it through the round-trip.
|
||||
matching below, which is what lets the emitter decide its own pairings.
|
||||
- Blocks separated by one blank line at document level, inside a blockquote and between CommonMark
|
||||
blocks; inside a directive container a pair holding a directive block takes none. No trailing
|
||||
whitespace outside a code
|
||||
block's
|
||||
content, single trailing newline; a document with no blocks is the empty string.
|
||||
whitespace outside a code block's content, single trailing newline; a document with no blocks is the empty string.
|
||||
|
||||
## Directives
|
||||
|
||||
One grammar for everything CommonMark lacks, namespaced: every directive opens with the literal
|
||||
`!adf:`. A directive name is `[a-z][A-Za-z0-9]*` — the ADF node and mark names the sections below
|
||||
spell as directives. 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 at the opener, whatever follows it — so output an old emitter escaped stays
|
||||
escaped, and erroring input gaining meaning later is MINOR, never a reparse (§8). Each name belongs
|
||||
to one position, and a name the other one spells — a mark or an inline node written as a block
|
||||
directive, a block node written inline — is a different error, naming the spelling it takes. Two
|
||||
reserved names read back to no node: `carry` for the opaque carry, as both directive name and fence
|
||||
info string, and `listBreak` for the leaf that parts two adjacent lists (Canonical form).
|
||||
below parses as a directive regardless of whether the name is known, and an unknown name is an error
|
||||
result naming it at the opener, whatever follows it — so output an old emitter escaped stays
|
||||
escaped, and erroring input gaining meaning later is MINOR, never a reparse (`docs/decisions.md`
|
||||
§The formats are API). Each name belongs to one position, and a name the other one spells — a mark
|
||||
or an inline node written as a block directive, a block node written inline — is a different error,
|
||||
naming the spelling it takes. Two reserved names read back to no node: `carry` for the opaque carry,
|
||||
as both directive name and fence info string, and `listBreak` for the leaf that parts two adjacent
|
||||
lists (Canonical form).
|
||||
|
||||
**Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name`
|
||||
closes a container, and a name picks by what follows it in turn — a space or the line's end a block
|
||||
@@ -123,7 +122,7 @@ Which of the two a node takes is its content model, never the spelling: a model
|
||||
written as an opener–closer pair and one taking none as a leaf, so a leaf given a body and a
|
||||
container missing its closer are each a named error. A node holding no content whose model takes
|
||||
some is an empty pair. A spelled node's content model is contract in consequence — changing one is
|
||||
MAJOR (AGENTS.md §8).
|
||||
MAJOR (`docs/decisions.md` §The formats are API).
|
||||
|
||||
Canonical spacing is the only spacing input reads: one space parts the name, `arg` and `{attrs}`,
|
||||
and one parts each attribute pair, with no padding inside the braces. Trailing whitespace on a
|
||||
@@ -154,15 +153,15 @@ outside code spans and code blocks, `\!adf:` in input yields the literal text.
|
||||
naming no open container or a node other than the innermost open one, a leaf given a body, an
|
||||
`!adf:` completing no directive, 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.
|
||||
fallback — a typo that reparses as prose is the silent loss the round-trip refuses.
|
||||
|
||||
## The opaque carry (AGENTS.md §3)
|
||||
## The opaque carry
|
||||
|
||||
A node no section spells where it stands — an unknown type, or a known one whose spelling belongs
|
||||
to the other position — rides as its raw JSON and restores to a deep-equal node. A carry may hold
|
||||
a node the emitter spells natively: it restores unreinterpreted, and the next emit spells it
|
||||
canonically (AGENTS.md §2). Block and inline positions canonicalize differently, each fitting
|
||||
where it sits:
|
||||
A node no section spells where it stands (`docs/decisions.md` §Unknown nodes ride the carry) — an
|
||||
unknown type, or a known one whose spelling belongs to the other position — rides as its raw JSON
|
||||
and restores to a deep-equal node. A carry may hold a node the emitter spells natively: it restores
|
||||
unreinterpreted, and the next emit spells it canonically (`docs/decisions.md` §The round-trip is the
|
||||
product). Block and inline positions canonicalize differently, each fitting where it sits:
|
||||
|
||||
- **Block position**: a fenced code block with info string `carry`, body = the node's JSON —
|
||||
two-space indent, object keys sorted.
|
||||
@@ -177,25 +176,27 @@ In block-directive position `!adf:carry` is a named error — the carry's block
|
||||
## 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.
|
||||
HTML element mapping (`docs/decisions.md` §Foreign HTML sorts three ways; specified with the HTML
|
||||
dialect, `todo.md` item 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.
|
||||
|
||||
## Block nodes
|
||||
|
||||
The directive name is always the ADF node type. A container's body is the node's `content`; a
|
||||
leaf has none. Every directive parses in any position — `markdownToAdf` builds exactly what is
|
||||
written; validity against ADF's content models stays the author's business (AGENTS.md §14). It
|
||||
parses only in the form the emitter picks, though: a directive spelling a node the emitter would
|
||||
have written as CommonMark is a named error.
|
||||
The directive name is always the ADF node type. A container's body is the node's `content`; a leaf
|
||||
has none. Every directive parses in any position — `markdownToAdf` builds exactly what is written;
|
||||
validity against ADF's content models stays the author's business (`docs/decisions.md` §No schema
|
||||
validation). It parses only in the form the emitter picks, though: a directive spelling a node the
|
||||
emitter would have written as CommonMark is a named error.
|
||||
|
||||
Each section lists attributes as `name (type)`. A parenthesized value set documents what real
|
||||
payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by
|
||||
type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare
|
||||
(`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys
|
||||
sorted), quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty;
|
||||
editor-normal ADF reads an empty attrs object, marks array or content array as the absent key
|
||||
(AGENTS.md §2) — the grammar's empty-`{attrs}` omission already collapses the two spellings.
|
||||
(`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted),
|
||||
quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty; editor-normal
|
||||
ADF reads an empty attrs object, marks array or content array as the absent key (`docs/decisions.md`
|
||||
§Equality is editor-normal) — the grammar's empty-`{attrs}` omission already collapses the two
|
||||
spellings.
|
||||
|
||||
Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a
|
||||
`json` value: `!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`.
|
||||
@@ -300,10 +301,10 @@ other text, or one carrying a title, is a named error: `mediaInline` carries a m
|
||||
### Tables
|
||||
|
||||
One header row plus plain inline cells is a pipe table; anything richer is the directive form
|
||||
(AGENTS.md §4). Precisely: a table emits as a pipe table exactly when the `table`, every row
|
||||
and every cell carry no attrs and no marks, the first row is all `tableHeader` and the rest all
|
||||
`tableCell`, every row has the header's cell count, and every cell holds exactly one attr-less,
|
||||
mark-less paragraph — an empty cell holds one empty paragraph — with no `|` anywhere the
|
||||
(`docs/decisions.md` §Tables). Precisely: a table emits as a pipe table exactly when the `table`,
|
||||
every row and every cell carry no attrs and no marks, the first row is all `tableHeader` and the
|
||||
rest all `tableCell`, every row has the header's cell count, and every cell holds exactly one
|
||||
attr-less, mark-less paragraph — an empty cell holds one empty paragraph — with no `|` anywhere the
|
||||
inline layer spells as syntax: a code span, an autolink, a link destination or title. A `|` there
|
||||
takes the directive form instead. A pipe table parses back to exactly that shape.
|
||||
|
||||
@@ -449,14 +450,14 @@ Shipped !adf:emoji[🎉]{shortName=":tada:"} on !adf:date{timestamp=175608000000
|
||||
```
|
||||
|
||||
**Whitespace CommonMark cannot hold.** A newline inside a text node, and a space or tab where
|
||||
CommonMark strips or refuses one — a block's inline content edges, either side of a line break,
|
||||
an em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled
|
||||
`!adf:text{text="…"}`, the reserved key carrying the node's text, escaped by the attribute grammar
|
||||
and never literal: pipe cells trim and pad. The emitter wraps the whitespace run alone and leaves
|
||||
the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks and no
|
||||
attributes (AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and
|
||||
tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries plainly —
|
||||
is a named error.
|
||||
CommonMark strips or refuses one — a block's inline content edges, either side of a line break, an
|
||||
em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled `!adf:text{text="…"}`,
|
||||
the reserved key carrying the node's text, escaped by the attribute grammar and never literal: pipe
|
||||
cells trim and pad. The emitter wraps the whitespace run alone and leaves the rest plain text;
|
||||
`markdownToAdf` merges adjacent text nodes carrying identical marks and no attributes
|
||||
(`docs/decisions.md` §Equality is editor-normal). Input reads that spelling alone: the value is one
|
||||
run of spaces and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark
|
||||
carries plainly — is a named error.
|
||||
|
||||
```
|
||||
!adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines.
|
||||
@@ -488,11 +489,11 @@ the directive form, open to no literal reading, is a named error.
|
||||
|
||||
A spelling adds its mark to every inline node it wraps, and nesting is the marks array in order,
|
||||
outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the
|
||||
reverse.
|
||||
`adfToMarkdown` nests in the order the array holds rather than sorting it — §2's equality
|
||||
restores the array, not a set — and opens each spelling once over the longest run of adjacent
|
||||
inline nodes carrying an identical mark, attributes included, at that depth. A run breaks at every
|
||||
node the emitter carries, so no emitted carry sits inside a mark spelling.
|
||||
reverse. `adfToMarkdown` nests in the order the array holds rather than sorting it —
|
||||
`docs/decisions.md` §Equality is editor-normal restores the array, not a set — and opens each
|
||||
spelling once over the longest run of adjacent inline nodes carrying an identical mark, attributes
|
||||
included, at that depth. A run breaks at every node the emitter carries, so no emitted carry sits
|
||||
inside a mark spelling.
|
||||
|
||||
An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its
|
||||
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs
|
||||
@@ -502,7 +503,7 @@ close where the run sits (`un**-real**istic`), or one CommonMark's matching pair
|
||||
intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the
|
||||
merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a
|
||||
mark spelling is a named error in input: the carry restores its node exactly, marks included
|
||||
(AGENTS.md §3).
|
||||
(`docs/decisions.md` §Unknown nodes ride the carry).
|
||||
|
||||
```
|
||||
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fc from 'fast-check'
|
||||
import test from 'node:test'
|
||||
|
||||
import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts'
|
||||
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||
import { toEditorNormal } from './adf/editor-normal.ts'
|
||||
|
||||
const gateRuns = 1600
|
||||
|
||||
test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => {
|
||||
fc.assert(
|
||||
fc.property(adfDocument, (document) => {
|
||||
const emitted = adfToMarkdown(document)
|
||||
if (!emitted.ok) return
|
||||
const read = markdownToAdf(emitted.value)
|
||||
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
|
||||
assert.deepEqual(toEditorNormal(read.value), document, `reading ${JSON.stringify(emitted.value)}`)
|
||||
}),
|
||||
propertyRuns(gateRuns),
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AttributeVocabulary } from './attribute-vocabulary.ts'
|
||||
|
||||
export type BlockDirective = {
|
||||
export type BlockNodeModel = {
|
||||
attributes: AttributeVocabulary
|
||||
contentModel: 'block' | 'code' | 'inline' | 'none'
|
||||
}
|
||||
@@ -41,7 +41,7 @@ const mediaAttributes: AttributeVocabulary = {
|
||||
|
||||
const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' }
|
||||
|
||||
export const blockDirectives = {
|
||||
export const blockNodes = {
|
||||
blockTaskItem: { attributes: localIdAttributes, contentModel: 'block' },
|
||||
blockquote: { attributes: localIdAttributes, contentModel: 'block' },
|
||||
bodiedExtension: { attributes: extensionAttributes, contentModel: 'block' },
|
||||
@@ -80,14 +80,14 @@ export const blockDirectives = {
|
||||
tableRow: { attributes: localIdAttributes, contentModel: 'block' },
|
||||
taskItem: { attributes: localIdAttributes, contentModel: 'inline' },
|
||||
taskList: { attributes: localIdAttributes, contentModel: 'block' },
|
||||
} satisfies Readonly<Record<string, BlockDirective>>
|
||||
} satisfies Readonly<Record<string, BlockNodeModel>>
|
||||
|
||||
export type BlockType = keyof typeof blockDirectives
|
||||
export type BlockType = keyof typeof blockNodes
|
||||
|
||||
export function blockDirective(type: string): BlockDirective | undefined {
|
||||
return isBlockType(type) ? blockDirectives[type] : undefined
|
||||
export function blockNodeModel(type: string): BlockNodeModel | undefined {
|
||||
return isBlockType(type) ? blockNodes[type] : undefined
|
||||
}
|
||||
|
||||
function isBlockType(type: string): type is BlockType {
|
||||
return Object.hasOwn(blockDirectives, type)
|
||||
return Object.hasOwn(blockNodes, type)
|
||||
}
|
||||
@@ -61,16 +61,15 @@ test('rejects a node whose shape ProseMirror JSON cannot hold', () => {
|
||||
})
|
||||
|
||||
test('names the attribute nesting past the levels the parser reads one at, and still calls the value a document', () => {
|
||||
const deeper = (key: string, type: string, levels: number = largestNesting): string =>
|
||||
`the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
|
||||
const deeper = (key: string, type: string): string => `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
|
||||
assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted')
|
||||
assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph'))
|
||||
assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth')
|
||||
assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), true)
|
||||
const marked = (levels: number): unknown => ({ content: [{ marks: [{ attrs: { a: nested(levels) }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 })
|
||||
assert.equal(fault(marked(largestNesting - 3)), 'accepted')
|
||||
assert.equal(fault(marked(largestNesting - 2)), deeper('a', 'link', largestNesting - 3))
|
||||
assert.equal(isAdfDocument(marked(largestNesting - 2)), true)
|
||||
assert.equal(fault(marked(largestNesting)), 'accepted')
|
||||
assert.equal(fault(marked(largestNesting + 1)), deeper('a', 'link'))
|
||||
assert.equal(isAdfDocument(marked(largestNesting + 1)), true)
|
||||
})
|
||||
|
||||
test('accepts the JSON values an attribute may hold', () => {
|
||||
|
||||
+5
-8
@@ -23,9 +23,6 @@ export type AdfDocument = {
|
||||
version: number
|
||||
}
|
||||
|
||||
// A block directive spells the whole mark set as one JSON attribute, so a mark's value sits three levels inside it.
|
||||
const markAttributeNesting = largestNesting - 3
|
||||
|
||||
const documentKeys = ['content', 'type', 'version']
|
||||
const markKeys = ['attrs', 'type']
|
||||
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
|
||||
@@ -47,8 +44,8 @@ export function adfDocumentFault(value: unknown): ConvertFault | undefined {
|
||||
return nestingFault(content)
|
||||
}
|
||||
|
||||
export function attributeNestingMessage(key: string, type: string, levels: number = largestNesting): string {
|
||||
return `the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
|
||||
export function attributeNestingMessage(key: string, type: string): string {
|
||||
return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
|
||||
}
|
||||
|
||||
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
|
||||
@@ -116,15 +113,15 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
|
||||
|
||||
function marksFault(marks: readonly AdfMark[]): ConvertFault | undefined {
|
||||
for (const mark of marks) {
|
||||
const fault = attributesFault(nodeAttrs(mark), mark.type, markAttributeNesting)
|
||||
const fault = attributesFault(nodeAttrs(mark), mark.type)
|
||||
if (fault !== undefined) return fault
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function attributesFault(attrs: AdfAttributes, type: string, levels: number = largestNesting): ConvertFault | undefined {
|
||||
function attributesFault(attrs: AdfAttributes, type: string): ConvertFault | undefined {
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) }
|
||||
if (overNested(value)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type) }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ function mergesText(node: AdfNode): boolean {
|
||||
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
|
||||
}
|
||||
|
||||
function sameMarks(previous: AdfNode, node: AdfNode): boolean {
|
||||
export function sameMarks(previous: AdfNode, node: AdfNode): boolean {
|
||||
return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { AttributeVocabulary } from './attribute-vocabulary.ts'
|
||||
|
||||
export type InlineDirective = {
|
||||
export type InlineNodeModel = {
|
||||
attributes: AttributeVocabulary
|
||||
textAttribute?: string
|
||||
}
|
||||
|
||||
export const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
|
||||
export const inlineNodes: Readonly<Record<string, InlineNodeModel>> = {
|
||||
date: { attributes: { localId: 'string', timestamp: 'string' } },
|
||||
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, textAttribute: 'text' },
|
||||
hardBreak: { attributes: { localId: 'string', text: 'string' } },
|
||||
@@ -27,6 +27,6 @@ export const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
|
||||
status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, textAttribute: 'text' },
|
||||
}
|
||||
|
||||
export function inlineDirective(type: string): InlineDirective | undefined {
|
||||
return Object.hasOwn(inlineDirectives, type) ? inlineDirectives[type] : undefined
|
||||
export function inlineNodeModel(type: string): InlineNodeModel | undefined {
|
||||
return Object.hasOwn(inlineNodes, type) ? inlineNodes[type] : undefined
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fc from 'fast-check'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfNode } from '../adf/document.ts'
|
||||
import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts'
|
||||
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { adfToPlainMarkdown, reduceToPlain } from '../markdown/emit/plain-reduction.ts'
|
||||
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
||||
import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
import { toEditorNormal } from '../adf/editor-normal.ts'
|
||||
|
||||
const gateRuns = 1600
|
||||
const renamedPrefix = '!adg:'
|
||||
|
||||
// Renaming the prefix changes what markdown reads only where a directive was read.
|
||||
function readsNoDirective(markdown: string): boolean {
|
||||
const read = markdownToAdf(markdown)
|
||||
const renamed = markdownToAdf(markdown.replaceAll(directivePrefix, renamedPrefix))
|
||||
return read.ok && renamed.ok && JSON.stringify(read.value).replaceAll(directivePrefix, renamedPrefix) === JSON.stringify(renamed.value)
|
||||
}
|
||||
|
||||
// Each block's text, an expand's title and an image's alt and url, in document order: what the plain pair keeps.
|
||||
function shownText(nodes: readonly AdfNode[]): string[] {
|
||||
const shown: string[] = []
|
||||
for (const node of nodes) {
|
||||
const attrs = node.attrs ?? {}
|
||||
if ((node.type === 'expand' || node.type === 'nestedExpand') && typeof attrs['title'] === 'string') shown.push(attrs['title'])
|
||||
if (node.type === 'media') shown.push(`${JSON.stringify(attrs['alt'] ?? '')} ${JSON.stringify(attrs['url'])}`)
|
||||
const content = node.content ?? []
|
||||
if (content.some((child) => child.type === 'text' || child.type === 'hardBreak')) shown.push(content.map((child) => child.text ?? '\n').join(''))
|
||||
else for (const text of shownText(content)) shown.push(text)
|
||||
}
|
||||
return shown.filter((text) => text !== '')
|
||||
}
|
||||
|
||||
test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => {
|
||||
fc.assert(
|
||||
fc.property(adfDocument, (document) => {
|
||||
const emitted = adfToMarkdown(document)
|
||||
if (!emitted.ok) return
|
||||
const read = markdownToAdf(emitted.value)
|
||||
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
|
||||
assert.deepEqual(toEditorNormal(read.value), document, `reading ${JSON.stringify(emitted.value)}`)
|
||||
}),
|
||||
propertyRuns(gateRuns),
|
||||
)
|
||||
})
|
||||
|
||||
test('a generated document writes plain markdown refusing only what the guard refuses, and that markdown reads back to its text and to itself', { timeout: propertyTimeout }, () => {
|
||||
fc.assert(
|
||||
fc.property(adfDocument, (document) => {
|
||||
const written = adfToPlainMarkdown(document)
|
||||
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
|
||||
assert.ok(readsNoDirective(written.value), `a directive in ${JSON.stringify(written.value)}`)
|
||||
const read = plainMarkdownToAdf(written.value)
|
||||
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(written.value)}`)
|
||||
const reduced = reduceToPlain(document)
|
||||
assert.deepEqual(shownText(read.value.content ?? []), reduced.ok ? shownText(reduced.value.content ?? []) : reduced, `reading ${JSON.stringify(written.value)}`)
|
||||
assert.deepEqual(adfToPlainMarkdown(read.value), written, `reading ${JSON.stringify(written.value)}`)
|
||||
}),
|
||||
propertyRuns(gateRuns),
|
||||
)
|
||||
})
|
||||
@@ -5,11 +5,11 @@ import { fileURLToPath } from 'node:url'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts'
|
||||
import { blockArgument } from './markdown/block-directive-arguments.ts'
|
||||
import { blockDirectives } from './adf/block-directives.ts'
|
||||
import { inlineDirectives } from './adf/inline-directives.ts'
|
||||
import { markAttributes } from './adf/mark-attributes.ts'
|
||||
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||
import { blockArgument } from '../markdown/block-directive.ts'
|
||||
import { blockNodes } from '../adf/block-nodes.ts'
|
||||
import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||
|
||||
type Held = Map<string, Set<AttributeKind>>
|
||||
type Properties = Map<string, SchemaObject[]>
|
||||
@@ -21,7 +21,7 @@ const definitionReference = '#/definitions/'
|
||||
const gaps: string[] = []
|
||||
const grammarOwn = ['doc', 'text']
|
||||
const readKeywords = ['$ref', 'additionalProperties', 'allOf', 'anyOf', 'enum', 'items', 'maxItems', 'maximum', 'minItems', 'minLength', 'minimum', 'pattern', 'properties', 'required', 'type']
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'spec', 'adf-schema')
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'spec', 'adf-schema')
|
||||
const schemaFiles = ['full.json', 'stage-0.json']
|
||||
|
||||
test('the ADF JSON Schemas are @atlaskit/adf-schema 57.4.9, vendored byte-exact', () => {
|
||||
@@ -78,8 +78,8 @@ test("the ADF JSON Schemas hold no type the tables leave unspelled, the pinned c
|
||||
|
||||
function spelled(): Spelled[] {
|
||||
return [
|
||||
...Object.entries(blockDirectives).map(([type, entry]) => spelledType(type, entry.attributes, blockArgument(type))),
|
||||
...Object.entries(inlineDirectives).map(([type, entry]) => spelledType(type, entry.attributes)),
|
||||
...Object.entries(blockNodes).map(([type, model]) => spelledType(type, model.attributes, blockArgument(type))),
|
||||
...Object.entries(inlineNodes).map(([type, model]) => spelledType(type, model.attributes)),
|
||||
...Object.entries(markAttributes).map(([type, attributes]) => spelledType(type, attributes)),
|
||||
]
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import { fileURLToPath } from 'node:url'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfDocument, AdfNode } from './adf/document.ts'
|
||||
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||
import type { AdfDocument, AdfNode } from '../adf/document.ts'
|
||||
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus', 'commonmark-spec')
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus', 'commonmark-spec')
|
||||
const checks = ['count', 'fixpoint', 'text'] as const
|
||||
|
||||
type Check = (typeof checks)[number]
|
||||
@@ -91,7 +91,7 @@ test('the refusal list is unique per example and names real examples', () => {
|
||||
for (const example of exampleToRefusal.keys()) assert.ok(spec.some((entry) => entry.example === example), `refusal ${example} names no example in the suite`)
|
||||
})
|
||||
|
||||
// A mark is counted once per text node it touches (AGENTS.md §14).
|
||||
// A mark is counted once per text node it touches (docs/decisions.md §No schema validation).
|
||||
const countKeys = ['a', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'li', 'ol', 'pre', 'strong', 'ul']
|
||||
const nodeElement: Record<string, string> = {
|
||||
blockquote: 'blockquote',
|
||||
@@ -4,14 +4,14 @@ import { fileURLToPath } from 'node:url'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||
import { isAdfDocument } from './adf/document.ts'
|
||||
import { isJsonValue } from './json-value.ts'
|
||||
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||
import { toEditorNormal } from './adf/editor-normal.ts'
|
||||
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { isAdfDocument } from '../adf/document.ts'
|
||||
import { isJsonValue } from '../json-value.ts'
|
||||
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
import { toEditorNormal } from '../adf/editor-normal.ts'
|
||||
|
||||
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus')
|
||||
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus')
|
||||
const errorsRoot = join(corpusRoot, 'errors')
|
||||
const normalizationRoot = join(corpusRoot, 'normalization')
|
||||
const realPayloadsRoot = join(corpusRoot, 'real-payloads')
|
||||
@@ -4,15 +4,15 @@ import { fileURLToPath } from 'node:url'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts'
|
||||
import { blockDirectives } from './adf/block-directives.ts'
|
||||
import { inlineDirectives } from './adf/inline-directives.ts'
|
||||
import { markAttributes } from './adf/mark-attributes.ts'
|
||||
import { textDirectiveName } from './markdown/text-directive.ts'
|
||||
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||
import { blockNodes } from '../adf/block-nodes.ts'
|
||||
import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||
import { textDirectiveName } from '../markdown/text-directive.ts'
|
||||
|
||||
type Declared = { attributes: AttributeVocabulary }
|
||||
|
||||
const specPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'spec', 'flavour.md')
|
||||
const specPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'spec', 'flavour.md')
|
||||
const introducer = 'Attributes: '
|
||||
const codeFence = /^`{3,}/
|
||||
const directiveName = /`([a-z][A-Za-z0-9]*)`/g
|
||||
@@ -90,16 +90,16 @@ function vocabularies(table: Readonly<Record<string, Declared>>): Record<string,
|
||||
}
|
||||
|
||||
test('the block node table holds the attributes spec/flavour.md gives each node', () => {
|
||||
assert.deepEqual(declarations('Block nodes'), vocabularies(blockDirectives))
|
||||
assert.deepEqual(declarations('Block nodes'), vocabularies(blockNodes))
|
||||
})
|
||||
|
||||
test('the inline node table holds the attributes spec/flavour.md gives each node', () => {
|
||||
assert.deepEqual(declarations('Inline nodes'), vocabularies(inlineDirectives))
|
||||
assert.deepEqual(declarations('Inline nodes'), vocabularies(inlineNodes))
|
||||
})
|
||||
|
||||
// A name in two tables would make the position a directive is read in ambiguous.
|
||||
test('no name is spelled in more than one position', () => {
|
||||
const names = [...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes), textDirectiveName]
|
||||
const names = [...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes), textDirectiveName]
|
||||
assert.equal(new Set(names).size, names.length)
|
||||
})
|
||||
|
||||
@@ -2,16 +2,16 @@ import assert from 'node:assert/strict'
|
||||
import fc from 'fast-check'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfDocument } from './adf/document.ts'
|
||||
import type { AdfDocument } from '../adf/document.ts'
|
||||
import type { Arbitrary, DepthIdentifier } from 'fast-check'
|
||||
import type { AttributeVocabulary } from './adf/attribute-vocabulary.ts'
|
||||
import type { JsonValue } from './json-value.ts'
|
||||
import type { Result } from './result.ts'
|
||||
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import type { Result } from '../result.ts'
|
||||
import { adfDocument, attributes, jsonKey, jsonValue, markdownPieces, propertyRuns, propertyTimeout, textOf } from './property-harness.ts'
|
||||
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||
import { blockArgument } from './markdown/block-directive-arguments.ts'
|
||||
import { blockDirectives } from './adf/block-directives.ts'
|
||||
import { carryName } from './markdown/opaque-carry.ts'
|
||||
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||
import { blockArgument, listBreakName, marksAttribute } from '../markdown/block-directive.ts'
|
||||
import { blockNodes } from '../adf/block-nodes.ts'
|
||||
import { carryName } from '../markdown/opaque-carry.ts'
|
||||
import {
|
||||
directivePrefix,
|
||||
spellAttributes,
|
||||
@@ -22,19 +22,17 @@ import {
|
||||
spellJsonAttribute,
|
||||
spellStringAttribute,
|
||||
spellVocabulary,
|
||||
} from './markdown/directive-syntax.ts'
|
||||
import { fencedCodeBlock } from './markdown/commonmark/backtick-runs.ts'
|
||||
import { inlineDirectives } from './adf/inline-directives.ts'
|
||||
import { listBreakName } from './markdown/list-break.ts'
|
||||
import { markAttributes } from './adf/mark-attributes.ts'
|
||||
import { markSpelling } from './markdown/mark-spellings.ts'
|
||||
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||
import { marksAttribute } from './markdown/block-directive-marks.ts'
|
||||
import { nodeContent, nodeMarks } from './adf/document.ts'
|
||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||
import { textDirectiveName } from './markdown/text-directive.ts'
|
||||
import { toEditorNormal } from './adf/editor-normal.ts'
|
||||
import { vocabularyPairs } from './adf/attribute-vocabulary.ts'
|
||||
} from '../markdown/directive-syntax.ts'
|
||||
import { fencedCodeBlock } from '../markdown/commonmark/backtick-runs.ts'
|
||||
import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||
import { markSpelling } from '../markdown/mark-spellings.ts'
|
||||
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||
import { nodeContent, nodeMarks } from '../adf/document.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
import { textDirectiveName } from '../markdown/text-directive.ts'
|
||||
import { toEditorNormal } from '../adf/editor-normal.ts'
|
||||
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
|
||||
|
||||
type Choice = { arbitrary: Arbitrary<string>; hostile?: true; weight: number }
|
||||
|
||||
@@ -50,11 +48,11 @@ const fixpointFloor = 600
|
||||
const gateRuns = 1000
|
||||
const markdownMarkTypes = new Set(Object.keys(markAttributes).filter((type) => markSpelling(type)?.kind !== 'directive'))
|
||||
|
||||
const vocabularies = [...Object.values(blockDirectives).map((directive) => directive.attributes), ...Object.values(inlineDirectives).map((directive) => directive.attributes), ...Object.values(markAttributes)]
|
||||
const vocabularies = [...Object.values(blockNodes).map((model) => model.attributes), ...Object.values(inlineNodes).map((model) => model.attributes), ...Object.values(markAttributes)]
|
||||
const attributeKeys = [
|
||||
...new Set([...vocabularies.flatMap((vocabulary) => Object.keys(vocabulary)), ...Object.keys(blockDirectives).flatMap((type) => blockArgument(type) ?? []), marksAttribute, 'json', textDirectiveName]),
|
||||
...new Set([...vocabularies.flatMap((vocabulary) => Object.keys(vocabulary)), ...Object.keys(blockNodes).flatMap((type) => blockArgument(type) ?? []), marksAttribute, 'json', textDirectiveName]),
|
||||
]
|
||||
const directiveNames = [...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes), carryName, listBreakName, textDirectiveName]
|
||||
const directiveNames = [...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes), carryName, listBreakName, textDirectiveName]
|
||||
|
||||
// Hostile generation reaches refusals; clean generation holds none a single piece would trip, so a whole document reaches the emitter.
|
||||
function choose(hostile: boolean, choices: readonly Choice[], depth?: { depthIdentifier: DepthIdentifier; maxDepth: number }): Arbitrary<string> {
|
||||
@@ -233,9 +231,9 @@ function inlineMarkdown(hostile: boolean): InlineMarkdown {
|
||||
},
|
||||
{
|
||||
arbitrary: fc.oneof(
|
||||
...Object.entries(inlineDirectives).map(([name, directive]) =>
|
||||
...Object.entries(inlineNodes).map(([name, model]) =>
|
||||
fc
|
||||
.tuple(directive.textAttribute === undefined ? fc.constant(null) : fc.option(hostile ? word : prose), tableAttributes(directive.attributes, directive.textAttribute))
|
||||
.tuple(model.textAttribute === undefined ? fc.constant(null) : fc.option(hostile ? word : prose), tableAttributes(model.attributes, model.textAttribute))
|
||||
.map(([slot, attrs]) => (slot === null ? spellInlineLeafDirective(name, attrs) : `${spellInlineDirectiveOpener(name)}${slot}]${attrs}`)),
|
||||
),
|
||||
...Object.entries(markAttributes)
|
||||
@@ -328,15 +326,15 @@ function blockMarkdown(hostile: boolean, { inlines, oneLine }: InlineMarkdown, {
|
||||
const blockDepth = fc.createDepthIdentifier()
|
||||
const { blocks } = fc.letrec<{ block: string; blocks: string }>((tie) => {
|
||||
const bodyByModel = { block: fc.oneof(tie('blocks'), fc.constant('')), code: fencedCode, inline: fc.oneof(oneLine, fc.constant('')) }
|
||||
const tableDirectives = Object.entries(blockDirectives).map(([name, directive]) => {
|
||||
const tableDirectives = Object.entries(blockNodes).map(([name, model]) => {
|
||||
const argument =
|
||||
blockArgument(name) === undefined
|
||||
? fc.constant(undefined)
|
||||
: fc.oneof({ arbitrary: fc.constantFrom('DONE', 'TODO', 'custom', 'info', 'warning'), weight: 3 }, { arbitrary: bareToken, weight: 1 })
|
||||
const attrs = hostile ? fc.oneof({ arbitrary: tableAttributes(directive.attributes), weight: 4 }, { arbitrary: hostileAttributes, weight: 1 }) : tableAttributes(directive.attributes)
|
||||
if (directive.contentModel === 'none') return fc.tuple(argument, attrs).map(([held, spelled]) => spellDirectiveOpener(name, held, spelled))
|
||||
const attrs = hostile ? fc.oneof({ arbitrary: tableAttributes(model.attributes), weight: 4 }, { arbitrary: hostileAttributes, weight: 1 }) : tableAttributes(model.attributes)
|
||||
if (model.contentModel === 'none') return fc.tuple(argument, attrs).map(([held, spelled]) => spellDirectiveOpener(name, held, spelled))
|
||||
return fc
|
||||
.tuple(argument, attrs, bodyByModel[directive.contentModel], hostile ? closerDrift : fc.constant(null))
|
||||
.tuple(argument, attrs, bodyByModel[model.contentModel], hostile ? closerDrift : fc.constant(null))
|
||||
.map(([held, spelled, body, closer]) => container(spellDirectiveOpener(name, held, spelled), body, closer ?? spellDirectiveCloser(name)))
|
||||
})
|
||||
return {
|
||||
@@ -2,16 +2,16 @@ import assert from 'node:assert/strict'
|
||||
import { env } from 'node:process'
|
||||
import fc from 'fast-check'
|
||||
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../adf/document.ts'
|
||||
import type { Arbitrary } from 'fast-check'
|
||||
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts'
|
||||
import type { JsonValue } from './json-value.ts'
|
||||
import { blockArgument } from './markdown/block-directive-arguments.ts'
|
||||
import { blockDirectives } from './adf/block-directives.ts'
|
||||
import { directivePrefix } from './markdown/directive-syntax.ts'
|
||||
import { inlineDirectives } from './adf/inline-directives.ts'
|
||||
import { markAttributes } from './adf/mark-attributes.ts'
|
||||
import { toEditorNormal } from './adf/editor-normal.ts'
|
||||
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { blockArgument } from '../markdown/block-directive.ts'
|
||||
import { blockNodes } from '../adf/block-nodes.ts'
|
||||
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
||||
import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||
import { toEditorNormal } from '../adf/editor-normal.ts'
|
||||
|
||||
type Positions = { block: AdfNode; inline: AdfNode }
|
||||
|
||||
@@ -23,9 +23,9 @@ export const propertyTimeout = 600000
|
||||
const depthIdentifier = fc.createDepthIdentifier()
|
||||
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
|
||||
const flatCommonMarkShapeWeight = 4
|
||||
export const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
|
||||
export const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉日ー한𠀀', '==', '[!NOTE]', '[x]', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
|
||||
const nestingCommonMarkShapeWeight = 21
|
||||
const spelledTypes = new Set(['text', ...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes)])
|
||||
const spelledTypes = new Set(['text', ...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes)])
|
||||
|
||||
export function textOf(minLength: number): Arbitrary<string> {
|
||||
return fc.oneof(
|
||||
@@ -80,6 +80,7 @@ function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }):
|
||||
|
||||
const mark: Arbitrary<AdfMark> = fc.oneof(
|
||||
{ arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 },
|
||||
{ arbitrary: attributes({ color: 'string' }).map((attrs) => ({ attrs, type: 'backgroundColor' })), weight: 2 },
|
||||
{ arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 },
|
||||
)
|
||||
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
|
||||
@@ -94,8 +95,8 @@ const autolinkTextNode = fc
|
||||
.record({ href: fc.tuple(fc.constantFrom('ab:', 'http://'), textOf(0)).map(([scheme, rest]) => `${scheme}${rest}`), marks })
|
||||
.map(({ href, marks: held }): AdfNode => ({ marks: [...held.filter((outer) => outer.type !== 'link'), { attrs: { href }, type: 'link' }], text: href, type: 'text' }))
|
||||
|
||||
const inlineNodes = Object.entries(inlineDirectives).map(([type, directive]) =>
|
||||
fc.record({ attrs: attributes(directive.attributes), marks }).map((held): AdfNode => ({ ...held, type })),
|
||||
const inlineArbitraries = Object.entries(inlineNodes).map(([type, model]) =>
|
||||
fc.record({ attrs: attributes(model.attributes), marks }).map((held): AdfNode => ({ ...held, type })),
|
||||
)
|
||||
|
||||
function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): { arbitrary: Arbitrary<AdfNode>; weight: number }[] {
|
||||
@@ -112,17 +113,17 @@ const positions = fc.letrec<Positions>((tie) => {
|
||||
none: fc.constant<AdfNode[]>([]),
|
||||
}
|
||||
const blockMarks = fc.oneof({ arbitrary: fc.constant<AdfMark[]>([]), weight: 4 }, { arbitrary: marks, weight: 1 })
|
||||
const blockNodes = Object.entries(blockDirectives).map(([type, directive]) => {
|
||||
const blockArbitraries = Object.entries(blockNodes).map(([type, model]) => {
|
||||
const argument = blockArgument(type)
|
||||
const vocabulary: AttributeVocabulary = argument === undefined ? directive.attributes : { ...directive.attributes, [argument]: 'string' }
|
||||
const node = fc.record({ attrs: attributes(vocabulary), content: contentByModel[directive.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type }))
|
||||
return { leaf: directive.contentModel === 'code' || directive.contentModel === 'none', node }
|
||||
const vocabulary: AttributeVocabulary = argument === undefined ? model.attributes : { ...model.attributes, [argument]: 'string' }
|
||||
const node = fc.record({ attrs: attributes(vocabulary), content: contentByModel[model.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type }))
|
||||
return { leaf: model.contentModel === 'code' || model.contentModel === 'none', node }
|
||||
})
|
||||
const unknownNode = fc
|
||||
.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType })
|
||||
.map((held): AdfNode => held)
|
||||
const leafBlocks = blockNodes.filter((entry) => entry.leaf).map((entry) => entry.node)
|
||||
const containerBlocks = blockNodes.filter((entry) => !entry.leaf).map((entry) => entry.node)
|
||||
const leafBlocks = blockArbitraries.filter((entry) => entry.leaf).map((entry) => entry.node)
|
||||
const containerBlocks = blockArbitraries.filter((entry) => !entry.leaf).map((entry) => entry.node)
|
||||
const misplacedWeight = 7
|
||||
const paragraph = fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: fc.array(backtickRunNode, { maxLength: 4, minLength: 2 }), weight: 1 }).map((content): AdfNode => ({ content, type: 'paragraph' }))
|
||||
const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type }))
|
||||
@@ -135,8 +136,12 @@ const positions = fc.letrec<Positions>((tie) => {
|
||||
paragraph,
|
||||
fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable),
|
||||
]
|
||||
const task = (type: string, content: Arbitrary<AdfNode[]>) =>
|
||||
fc.record({ content, state: fc.constantFrom('DONE', 'TODO') }).map(({ content: held, state }): AdfNode => ({ attrs: { state }, content: held, type }))
|
||||
const taskItem = fc.oneof({ arbitrary: task('taskItem', inlineContent), weight: 3 }, { arbitrary: task('blockTaskItem', blockContent), weight: 1 })
|
||||
const nestingCommonMarkShapes = [
|
||||
blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })),
|
||||
fc.array(fc.oneof({ arbitrary: taskItem, weight: 3 }, { arbitrary: tie('block'), weight: 1 }), { depthIdentifier, maxLength: 3, minLength: 1 }).map((content): AdfNode => ({ content, type: 'taskList' })),
|
||||
listItems.map((content): AdfNode => ({ content, type: 'bulletList' })),
|
||||
fc
|
||||
.record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) })
|
||||
@@ -148,7 +153,7 @@ const positions = fc.letrec<Positions>((tie) => {
|
||||
{ depthIdentifier, depthSize: 'small', maxDepth: 4 },
|
||||
{ arbitrary: fc.oneof(...flatBlocks), weight: flatBlocks.reduce((sum, entry) => sum + entry.weight, 0) },
|
||||
{ arbitrary: fc.oneof(...containerBlocks), weight: containerBlocks.length * 2 },
|
||||
{ arbitrary: fc.oneof(textNode, ...inlineNodes, unknownNode), weight: misplacedWeight },
|
||||
{ arbitrary: fc.oneof(textNode, ...inlineArbitraries, unknownNode), weight: misplacedWeight },
|
||||
{ arbitrary: fc.oneof(...nestingCommonMarkShapes), weight: nestingCommonMarkShapes.length * nestingCommonMarkShapeWeight },
|
||||
),
|
||||
inline: fc.oneof(
|
||||
@@ -156,8 +161,8 @@ const positions = fc.letrec<Positions>((tie) => {
|
||||
{ arbitrary: textNode, weight: 12 },
|
||||
{ arbitrary: autolinkTextNode, weight: 2 },
|
||||
{ arbitrary: backtickRunNode, weight: 3 },
|
||||
{ arbitrary: fc.oneof(...inlineNodes), weight: 7 },
|
||||
{ arbitrary: fc.oneof(...blockNodes.map((entry) => entry.node), unknownNode), weight: 2 },
|
||||
{ arbitrary: fc.oneof(...inlineArbitraries), weight: 7 },
|
||||
{ arbitrary: fc.oneof(...blockArbitraries.map((entry) => entry.node), unknownNode), weight: 2 },
|
||||
),
|
||||
}
|
||||
})
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
// Export only the conversions, their types, isAdfDocument and what a guarantee or a persona needs.
|
||||
export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
|
||||
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
|
||||
export type { JsonValue } from './json-value.ts'
|
||||
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||
export { adfToPlainMarkdown } from './markdown/emit/plain-reduction.ts'
|
||||
export { isAdfDocument } from './adf/document.ts'
|
||||
export { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||
export { markdownToAdf, plainMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { BlockType } from '../adf/block-directives.ts'
|
||||
|
||||
const argumentByType = new Map(
|
||||
Object.entries({
|
||||
blockTaskItem: 'state',
|
||||
panel: 'panelType',
|
||||
taskItem: 'state',
|
||||
} satisfies Partial<Record<BlockType, string>>),
|
||||
)
|
||||
|
||||
export function blockArgument(type: string): string | undefined {
|
||||
return argumentByType.get(type)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { blockDirective } from '../adf/block-directives.ts'
|
||||
import { listBreakName } from './list-break.ts'
|
||||
|
||||
export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined {
|
||||
if (name === listBreakName) return 'leaf'
|
||||
const directive = blockDirective(name)
|
||||
if (directive === undefined) return undefined
|
||||
return directive.contentModel === 'none' ? 'leaf' : 'container'
|
||||
}
|
||||
@@ -1,10 +1,36 @@
|
||||
import type { AdfMark } from '../adf/document.ts'
|
||||
import type { BlockType } from '../adf/block-nodes.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { blockNodeModel } from '../adf/block-nodes.ts'
|
||||
import { isAdfMark, nodeAttrs } from '../adf/document.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
import { spellDirectiveOpener } from './directive-syntax.ts'
|
||||
|
||||
const argumentByType = new Map(
|
||||
Object.entries({
|
||||
blockTaskItem: 'state',
|
||||
panel: 'panelType',
|
||||
taskItem: 'state',
|
||||
} satisfies Partial<Record<BlockType, string>>),
|
||||
)
|
||||
|
||||
export const listBreakName = 'listBreak'
|
||||
|
||||
export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
|
||||
|
||||
export const marksAttribute = 'marks'
|
||||
|
||||
export function blockArgument(type: string): string | undefined {
|
||||
return argumentByType.get(type)
|
||||
}
|
||||
|
||||
export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined {
|
||||
if (name === listBreakName) return 'leaf'
|
||||
const model = blockNodeModel(name)
|
||||
if (model === undefined) return undefined
|
||||
return model.contentModel === 'none' ? 'leaf' : 'container'
|
||||
}
|
||||
|
||||
export function markValues(marks: readonly AdfMark[]): JsonValue {
|
||||
return marks.map((mark) => {
|
||||
const attrs = nodeAttrs(mark)
|
||||
@@ -27,6 +27,7 @@ export function isWordCharacter(character: string): boolean {
|
||||
return character !== '' && !isWhitespace(character) && !isPunctuation(character)
|
||||
}
|
||||
|
||||
// Transcribes CommonMark's reference process_emphasis line for line; the closer walk and opener search stay whole, since named steps drift from it.
|
||||
export function matchEmphasis<Run extends DelimiterRun>(runs: readonly Run[]): EmphasisPairing<Run>[] {
|
||||
const pairings: EmphasisPairing<Run>[] = []
|
||||
const bottoms = new Map<string, Candidate<Run> | undefined>()
|
||||
|
||||
@@ -353,8 +353,8 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
|
||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth')
|
||||
let attrs: AdfMark['attrs'] = { depth: 'x' }
|
||||
for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs }
|
||||
const deeper = (key: string, type: string, levels: number = largestNesting): string =>
|
||||
`unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
|
||||
const deeper = (key: string, type: string): string =>
|
||||
`unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
|
||||
const nested = (levels: number): JsonValue => {
|
||||
let value: JsonValue = 1
|
||||
for (let level = 0; level < levels; level += 1) value = [value]
|
||||
@@ -375,11 +375,18 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
|
||||
assert.deepEqual(toEditorNormal(read.value), document(node))
|
||||
}
|
||||
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em', largestNesting - 3))
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em'))
|
||||
assert.equal(
|
||||
markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { deep: nested(largestNesting) }, type: 'em' }], text: 'x', type: 'text' })))),
|
||||
`unsupported-nesting-depth: a carried node's JSON nests deeper than the ${largestNesting} levels its position leaves`,
|
||||
)
|
||||
assert.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper('data', 'inlineCard'))
|
||||
assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), [])
|
||||
roundTrips(paragraph(card(largestNesting)))
|
||||
assert.equal(markdown(adfToMarkdown(document(marked(largestNesting - 2)))), deeper('deep', 'em', largestNesting - 3))
|
||||
assert.equal(markdown(adfToMarkdown(document(marked(largestNesting - 2)))), deeper('marks', 'panel'))
|
||||
assert.deepEqual(path(adfToMarkdown(document(marked(largestNesting - 2)))), ['content', 0])
|
||||
const markedCode: AdfNode = { content: [{ text: 'x', type: 'text' }], marks: [{ attrs: { deep: nested(largestNesting - 2) }, type: 'em' }], type: 'codeBlock' }
|
||||
assert.equal(markdown(adfToMarkdown(document(markedCode))), deeper('marks', 'codeBlock'))
|
||||
roundTrips(marked(largestNesting - 3))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { BlockDirective } from '../../adf/block-directives.ts'
|
||||
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import type { Flavour } from '../plain-conventions.ts'
|
||||
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { blockDirective, blockDirectives } from '../../adf/block-directives.ts'
|
||||
import { blockDirectiveForm } from '../block-directive-forms.ts'
|
||||
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain-conventions.ts'
|
||||
import { blockDirectiveForm, listBreakSpelling } from '../block-directive.ts'
|
||||
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
|
||||
import { carriedBlock } from '../opaque-carry.ts'
|
||||
import { emitInlineLine } from './inline-line.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
@@ -10,7 +12,6 @@ import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
|
||||
import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { listBreakSpelling } from '../list-break.ts'
|
||||
import { spellBlockDirectiveOpener } from './block-directive-spelling.ts'
|
||||
import { spellDirectiveCloser, spellDirectiveOpener } from '../directive-syntax.ts'
|
||||
import { tryImage } from './image.ts'
|
||||
@@ -20,32 +21,38 @@ type BlockContainer = 'directive' | 'document' | 'list-item'
|
||||
type BlockSpelling = 'commonmark' | 'directive' | 'list'
|
||||
type EmittedBlock = { headroom: number; spelling: BlockSpelling; text: string }
|
||||
type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
|
||||
type PlacedBlock = EmittedBlock & { node: AdfNode }
|
||||
type PlacedBlock = Omit<EmittedBlock, 'headroom'> & { node: AdfNode }
|
||||
// Keyed by reference: only a caller building one object per position (the parse, the plain reduction) passes one; a consumer's document may share a node.
|
||||
export type SpellingMemo = Map<AdfNode, KeptSpelling>
|
||||
type Walk = { blocks: readonly PlacedBlock[]; headroom: number }
|
||||
type WalkedItem = { node: AdfNode; walk: Walk }
|
||||
export type Writing = { flavour: Flavour; memo: SpellingMemo | undefined }
|
||||
|
||||
const largestListMarker = 999999999
|
||||
// Bare because emitList admits no item carrying attributes, marks or text.
|
||||
export const largestListMarker = 999999999
|
||||
// Bare because tryList admits no item carrying attributes, marks or text.
|
||||
const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
|
||||
|
||||
export function adfToMarkdown(document: AdfDocument): Result<string> {
|
||||
return writeMarkdown(document, 'lossless')
|
||||
}
|
||||
|
||||
export function writeMarkdown(document: AdfDocument, flavour: Flavour): Result<string> {
|
||||
const fault = adfDocumentFault(document)
|
||||
if (fault !== undefined) return faulted(fault, [])
|
||||
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
||||
const walk = walkBlocks(nodeContent(document), [], 0, undefined)
|
||||
const walk = walkBlocks(nodeContent(document), [], 0, { flavour, memo: undefined })
|
||||
if (!walk.ok) return walk
|
||||
const text = joinBlocks(walk.value.blocks, 'document')
|
||||
return success(text === '' ? '' : `${text}\n`)
|
||||
}
|
||||
|
||||
// headroom: the least slack any depth guard below the walk has.
|
||||
function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<Walk> {
|
||||
function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, writing: Writing): Result<Walk> {
|
||||
let headroom = largestNesting - depth
|
||||
if (headroom < 0) return tooDeep(path)
|
||||
const blocks: PlacedBlock[] = []
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
const block = emitBlock(node, [...path, 'content', index], depth, memo)
|
||||
const block = emitBlock(node, [...path, 'content', index], depth, writing)
|
||||
if (!block.ok) return block
|
||||
headroom = Math.min(headroom, block.value.headroom)
|
||||
blocks.push({ ...block.value, node })
|
||||
@@ -81,51 +88,120 @@ function separationBetween(previous: PlacedBlock, next: PlacedBlock, container:
|
||||
|
||||
function interruptsParagraph(node: AdfNode): boolean {
|
||||
const items = nodeContent(node)
|
||||
const empty = items[0] === undefined || nodeContent(items[0]).length === 0
|
||||
const empty = node.type !== 'taskList' && (items[0] === undefined || nodeContent(items[0]).length === 0)
|
||||
if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty)
|
||||
return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty)
|
||||
}
|
||||
|
||||
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> {
|
||||
const directive = blockDirective(node.type)
|
||||
if (directive === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||
const readable = readableBlock(node, path, depth, memo)
|
||||
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
|
||||
const model = blockNodeModel(node.type)
|
||||
if (model === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||
const readable = readableBlock(node, path, depth, writing)
|
||||
if (readable !== undefined) return readable
|
||||
return emitDirectiveBlock(node, directive, path, depth, () => walkBlocks(nodeContent(node), path, depth + 1, memo))
|
||||
return emitDirectiveBlock(node, model, path, depth, () => walkBlocks(nodeContent(node), path, depth + 1, writing))
|
||||
}
|
||||
|
||||
export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result<null> | undefined {
|
||||
const readable = readableBlock(node, path, depth, memo)
|
||||
export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<null> | undefined {
|
||||
const readable = readableBlock(node, path, depth, writing)
|
||||
if (readable === undefined) return undefined
|
||||
if (!readable.ok) return readable
|
||||
return readable.value.spelling === 'directive' ? undefined : success(null)
|
||||
}
|
||||
|
||||
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined {
|
||||
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||
const { memo } = writing
|
||||
const kept = memo?.get(node)
|
||||
if (kept !== undefined) {
|
||||
if (kept.block === undefined) return undefined
|
||||
// A read below the fill would skip the depth guards the walk it replaces runs (AGENTS.md §11).
|
||||
// A read below the fill would skip the depth guards the walk it replaces runs (docs/decisions.md §The spelling memo).
|
||||
if (depth <= kept.depth) return success({ ...kept.block, headroom: kept.block.headroom + kept.depth - depth })
|
||||
}
|
||||
const spelled = spellReadableBlock(node, path, depth, memo)
|
||||
const spelled = spellReadableBlock(node, path, depth, writing)
|
||||
if (spelled === undefined) memo?.set(node, { block: undefined, depth })
|
||||
else if (spelled.ok) memo?.set(node, { block: spelled.value, depth })
|
||||
return spelled
|
||||
}
|
||||
|
||||
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined {
|
||||
if (node.type === 'blockquote') return emitBlockquote(node, path, depth, memo)
|
||||
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth, memo)
|
||||
if (node.type === 'codeBlock') return emitCodeBlock(node, path)
|
||||
if (node.type === 'heading') return emitHeading(node, path)
|
||||
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||
const plain = writing.flavour === 'plain' ? spellPlainBlock(node, path, depth, writing) : undefined
|
||||
if (plain !== undefined) return plain
|
||||
if (node.type === 'blockquote') return tryBlockquote(node, path, depth, writing)
|
||||
if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, writing)
|
||||
if (node.type === 'codeBlock') return tryCodeBlock(node, path)
|
||||
if (node.type === 'heading') return tryHeading(node, path, writing.flavour)
|
||||
if (node.type === 'mediaSingle') return readableText(tryImage(node, path))
|
||||
if (node.type === 'paragraph') return emitParagraph(node, path)
|
||||
if (node.type === 'rule') return emitRule(node)
|
||||
if (node.type === 'table') return readableText(tryPipeTable(node, path))
|
||||
if (node.type === 'paragraph') return tryParagraph(node, path, writing.flavour)
|
||||
if (node.type === 'rule') return readableText(tryRule(node))
|
||||
if (node.type === 'table') return readableText(tryPipeTable(node, path, writing.flavour))
|
||||
return undefined
|
||||
}
|
||||
|
||||
// The plain flavour's nodes, in the shapes the plain reduction leaves them.
|
||||
function spellPlainBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||
if (node.type === 'panel') return quotedUnder(alertMarker(nodeAttrs(node)['panelType']), node, path, depth, writing)
|
||||
if (node.type === 'taskList') return tryTaskList(node, path, depth, writing)
|
||||
if (node.type !== 'expand' && node.type !== 'nestedExpand') return undefined
|
||||
const title = nodeAttrs(node)['title']
|
||||
if (typeof title !== 'string') return quotedUnder(foldedAlertMarker, node, path, depth, writing)
|
||||
// The reader takes a title as lossless inline text.
|
||||
const line = emitInlineLine([{ text: title, type: 'text' }], 'paragraph', path, 'lossless')
|
||||
return line.ok ? quotedUnder(`${foldedAlertMarker} ${line.value}`, node, path, depth, writing) : line
|
||||
}
|
||||
|
||||
function quotedUnder(head: string, node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
|
||||
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
|
||||
if (!inner.ok) return inner
|
||||
const body = joinBlocks(inner.value.blocks, 'document')
|
||||
return success(commonMarkText(quoted(body === '' ? head : `${head}\n\n${body}`), inner.value.headroom))
|
||||
}
|
||||
|
||||
function quoted(text: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => (line === '' ? '>' : `> ${line}`))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
|
||||
const items: PlacedBlock[][] = []
|
||||
let headroom = largestNesting - depth - 1
|
||||
// A child other than a task nests in the task before it.
|
||||
for (const [index, child] of nodeContent(node).entries()) {
|
||||
const task = child.type === 'taskItem' || child.type === 'blockTaskItem'
|
||||
const walk = task ? taskBlocks(child, [...path, 'content', index], depth + 1, writing) : placedBlock(child, [...path, 'content', index], depth + 1, writing)
|
||||
if (!walk.ok) return walk
|
||||
headroom = Math.min(headroom, walk.value.headroom)
|
||||
const previous = items.at(-1)
|
||||
if (task || previous === undefined) items.push([...walk.value.blocks])
|
||||
else for (const block of walk.value.blocks) previous.push(block)
|
||||
}
|
||||
const lines = items.map((blocks) => tryListItemLines(joinBlocks(blocks, 'list-item'), '- '))
|
||||
// The plain reduction leaves no task a list item cannot hold: a directive here would break the flavour.
|
||||
return lines.includes(undefined) ? failure('unsupported-node-shape', 'a task holds blocks no list item spells', path) : success({ headroom, spelling: 'list', text: lines.join('\n') })
|
||||
}
|
||||
|
||||
function placedBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<Walk> {
|
||||
const block = emitBlock(node, path, depth, writing)
|
||||
return block.ok ? success({ blocks: [{ ...block.value, node }], headroom: block.value.headroom }) : block
|
||||
}
|
||||
|
||||
// The marker leads the first paragraph, or stands as one where the blocks open with another.
|
||||
function taskBlocks(task: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<Walk> {
|
||||
const marker = taskMarker(nodeAttrs(task)['state'])
|
||||
const markerBlock: PlacedBlock = { node: { type: 'paragraph' }, spelling: 'commonmark', text: marker }
|
||||
if (task.type === 'taskItem') {
|
||||
const content = nodeContent(task)
|
||||
const line = content.length === 0 ? success('') : emitInlineLine(content, 'paragraph', path, writing.flavour)
|
||||
if (!line.ok) return line
|
||||
return success({ blocks: [{ ...markerBlock, text: line.value === '' ? marker : `${marker} ${line.value}` }], headroom: Number.POSITIVE_INFINITY })
|
||||
}
|
||||
const walk = walkBlocks(nodeContent(task), path, depth, writing)
|
||||
if (!walk.ok) return walk
|
||||
const [first, ...rest] = walk.value.blocks
|
||||
const blocks = first?.node.type === 'paragraph' ? [{ ...first, text: `${marker} ${first.text}` }, ...rest] : [markerBlock, ...walk.value.blocks]
|
||||
return success({ blocks, headroom: walk.value.headroom })
|
||||
}
|
||||
|
||||
function readableText(text: string | undefined): Result<EmittedBlock> | undefined {
|
||||
return text === undefined ? undefined : success(commonMarkText(text))
|
||||
}
|
||||
@@ -143,19 +219,20 @@ function directivePair(node: AdfNode, opener: string, body: string, headroom: nu
|
||||
return { headroom, spelling: 'directive', text: `${opener}\n${body === '' ? '' : `${body}\n`}${spellDirectiveCloser(node.type)}` }
|
||||
}
|
||||
|
||||
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number, walkBody: () => Result<Walk>): Result<EmittedBlock> {
|
||||
function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number, walkBody: () => Result<Walk>): Result<EmittedBlock> {
|
||||
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
|
||||
if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
|
||||
if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth)
|
||||
const opener = spellBlockDirectiveOpener(node, directive)
|
||||
if (model.contentModel === 'code') return emitCodeDirective(node, model, path, depth)
|
||||
const opener = spellBlockDirectiveOpener(node, model, path)
|
||||
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||
return emitDirectiveBody(node, directive, opener, path, walkBody)
|
||||
if (!opener.ok) return opener
|
||||
return emitDirectiveBody(node, model, opener.value, path, walkBody)
|
||||
}
|
||||
|
||||
function emitDirectiveBody(node: AdfNode, directive: BlockDirective, opener: string, path: ConvertErrorPath, walkBody: () => Result<Walk>): Result<EmittedBlock> {
|
||||
function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string, path: ConvertErrorPath, walkBody: () => Result<Walk>): Result<EmittedBlock> {
|
||||
if (blockDirectiveForm(node.type) === 'leaf') return success({ headroom: Number.POSITIVE_INFINITY, spelling: 'directive', text: opener })
|
||||
if (directive.contentModel === 'inline') {
|
||||
const line = emitInlineLine(nodeContent(node), 'paragraph', path)
|
||||
if (model.contentModel === 'inline') {
|
||||
const line = emitInlineLine(nodeContent(node), 'paragraph', path, 'lossless')
|
||||
if (!line.ok) return line
|
||||
return success(directivePair(node, opener, line.value))
|
||||
}
|
||||
@@ -164,18 +241,16 @@ function emitDirectiveBody(node: AdfNode, directive: BlockDirective, opener: str
|
||||
return success(directivePair(node, opener, joinBlocks(walk.value.blocks, 'directive'), walk.value.headroom))
|
||||
}
|
||||
|
||||
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined {
|
||||
function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||
if (!carriesOnly(node, [])) return undefined
|
||||
const inner = walkBlocks(nodeContent(node), path, depth + 1, memo)
|
||||
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
|
||||
if (!inner.ok) return inner
|
||||
const text = joinBlocks(inner.value.blocks, 'document')
|
||||
.split('\n')
|
||||
.map((line) => (line === '' ? '>' : `> ${line}`))
|
||||
.join('\n')
|
||||
return success(commonMarkText(text, inner.value.headroom))
|
||||
const alert = writing.flavour === 'plain' && leadingMarker(text, readAlertMarker) !== undefined
|
||||
return success(commonMarkText(quoted(alert ? `\\${text}` : text), inner.value.headroom))
|
||||
}
|
||||
|
||||
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||
function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||
if (!carriesOnly(node, ['language'])) return undefined
|
||||
const slot = languageSlot(nodeAttrs(node)['language'])
|
||||
if (slot.kind === 'attribute') return undefined
|
||||
@@ -184,13 +259,14 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
|
||||
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
|
||||
}
|
||||
|
||||
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||
function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||
const slot = languageSlot(nodeAttrs(node)['language'])
|
||||
const opener = spellBlockDirectiveOpener(node, directive, slot.kind === 'attribute' ? [] : ['language'])
|
||||
const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
|
||||
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||
if (!opener.ok) return opener
|
||||
const text = codeBlockText(node, path)
|
||||
if (!text.ok) return text
|
||||
return success(directivePair(node, opener, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
|
||||
return success(directivePair(node, opener.value, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
|
||||
}
|
||||
|
||||
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
@@ -214,19 +290,19 @@ function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
return success(text)
|
||||
}
|
||||
|
||||
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||
function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
|
||||
if (!carriesOnly(node, ['level'])) return undefined
|
||||
const level = nodeAttrs(node)['level']
|
||||
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
|
||||
const hashes = '#'.repeat(level)
|
||||
const content = nodeContent(node)
|
||||
if (content.length === 0) return success(commonMarkText(hashes))
|
||||
const line = emitInlineLine(content, 'heading', path)
|
||||
const line = emitInlineLine(content, 'heading', path, flavour)
|
||||
if (!line.ok) return line
|
||||
return success(commonMarkText(`${hashes} ${line.value}`))
|
||||
}
|
||||
|
||||
function emitList(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined {
|
||||
function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||
const ordered = node.type === 'orderedList'
|
||||
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
|
||||
const items = nodeContent(node)
|
||||
@@ -236,26 +312,29 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number, memo: Sp
|
||||
const walked: WalkedItem[] = []
|
||||
let headroom = Number.POSITIVE_INFINITY
|
||||
for (const [offset, item] of items.entries()) {
|
||||
const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1, memo)
|
||||
const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1, writing)
|
||||
if (!walk.ok) return walk
|
||||
headroom = Math.min(headroom, walk.value.headroom)
|
||||
walked.push({ node: item, walk: walk.value })
|
||||
}
|
||||
const lines: string[] = []
|
||||
for (const [offset, item] of walked.entries()) {
|
||||
const line = listItemLines(item.walk.blocks, ordered ? `${start + offset}. ` : '- ')
|
||||
const inner = joinBlocks(item.walk.blocks, 'list-item')
|
||||
// GitHub reads a task marker opening any item's first paragraph as a checkbox, whatever its siblings hold.
|
||||
const escaped = writing.flavour === 'plain' && leadingMarker(inner, readTaskMarker) !== undefined ? `\\${inner}` : inner
|
||||
const line = tryListItemLines(escaped, ordered ? `${start + offset}. ` : '- ')
|
||||
if (line === undefined) {
|
||||
// The directive form spends a level the walk did not count.
|
||||
if (headroom < 1) return tooDeep(path)
|
||||
return emitDirectiveBlock(node, ordered ? blockDirectives.orderedList : blockDirectives.bulletList, path, depth, () => success({ blocks: directiveItems(walked), headroom: headroom - 1 }))
|
||||
return emitDirectiveBlock(node, ordered ? blockNodes.orderedList : blockNodes.bulletList, path, depth, () => success({ blocks: directiveItems(walked), headroom: headroom - 1 }))
|
||||
}
|
||||
lines.push(line)
|
||||
}
|
||||
return success({ headroom, spelling: 'list', text: lines.join('\n') })
|
||||
}
|
||||
|
||||
// The directive form sinks each item's blocks a level below where the walk read them.
|
||||
function directiveItems(items: readonly WalkedItem[]): PlacedBlock[] {
|
||||
return items.map((item) => ({ ...directivePair(item.node, listItemOpener, joinBlocks(item.walk.blocks, 'directive'), item.walk.headroom - 1), node: item.node }))
|
||||
return items.map((item) => ({ ...directivePair(item.node, listItemOpener, joinBlocks(item.walk.blocks, 'directive')), node: item.node }))
|
||||
}
|
||||
|
||||
function listStart(node: AdfNode, items: number): number | undefined {
|
||||
@@ -265,8 +344,7 @@ function listStart(node: AdfNode, items: number): number | undefined {
|
||||
return start + items - 1 > largestListMarker ? undefined : start
|
||||
}
|
||||
|
||||
function listItemLines(blocks: readonly PlacedBlock[], marker: string): string | undefined {
|
||||
const inner = joinBlocks(blocks, 'list-item')
|
||||
function tryListItemLines(inner: string, marker: string): string | undefined {
|
||||
if (inner === '') return marker.trimEnd()
|
||||
const body = inner.split('\n')
|
||||
if (body.some((line) => line !== '' && isBlankLine(line))) return undefined
|
||||
@@ -276,15 +354,15 @@ function listItemLines(blocks: readonly PlacedBlock[], marker: string): string |
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||
function tryParagraph(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
|
||||
const content = nodeContent(node)
|
||||
if (content.length === 0 || !carriesOnly(node, [])) return undefined
|
||||
const line = emitInlineLine(content, 'paragraph', path)
|
||||
const line = emitInlineLine(content, 'paragraph', path, flavour)
|
||||
if (!line.ok) return line
|
||||
return success(commonMarkText(line.value))
|
||||
}
|
||||
|
||||
function emitRule(node: AdfNode): Result<EmittedBlock> | undefined {
|
||||
function tryRule(node: AdfNode): string | undefined {
|
||||
if (!carriesOnly(node, []) || nodeContent(node).length > 0) return undefined
|
||||
return success(commonMarkText('---'))
|
||||
return '---'
|
||||
}
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { BlockDirective } from '../../adf/block-directives.ts'
|
||||
import { blockArgument } from '../block-directive-arguments.ts'
|
||||
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { attributeNestingMessage, nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||
import { blockArgument, markValues, marksAttribute } from '../block-directive.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
|
||||
import { markValues, marksAttribute } from '../block-directive-marks.ts'
|
||||
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||
import { overNested } from '../../json-value.ts'
|
||||
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
||||
|
||||
export function spellBlockDirectiveOpener(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined {
|
||||
export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, spelledByBody: readonly string[] = []): Result<string> | undefined {
|
||||
const argumentAttribute = blockArgument(node.type)
|
||||
const slot = bareArgument(node, argumentAttribute)
|
||||
if (slot === undefined) return undefined
|
||||
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
|
||||
const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, spelled)
|
||||
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, spelled)
|
||||
if (pairs === undefined) return undefined
|
||||
const spelledPairs = spellVocabulary(pairs)
|
||||
const marks = nodeMarks(node)
|
||||
if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))])
|
||||
return spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs))
|
||||
if (marks.length > 0) {
|
||||
const values = markValues(marks)
|
||||
if (overNested(values)) return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, node.type), path)
|
||||
spelledPairs.push([marksAttribute, spellJsonAttribute(values)])
|
||||
}
|
||||
return success(spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs)))
|
||||
}
|
||||
|
||||
// `undefined` where the argument slot holds a value no bare token spells.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { InlineDirective } from '../../adf/inline-directives.ts'
|
||||
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { nodeAttrs } from '../../adf/document.ts'
|
||||
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
|
||||
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
||||
|
||||
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined {
|
||||
const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, directive.textAttribute === undefined ? [] : [directive.textAttribute])
|
||||
export function spellInlineNodeAttributes(node: AdfNode, model: InlineNodeModel): string | undefined {
|
||||
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, model.textAttribute === undefined ? [] : [model.textAttribute])
|
||||
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { InlineDirective } from '../../adf/inline-directives.ts'
|
||||
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts'
|
||||
import type { Flavour } from '../plain-conventions.ts'
|
||||
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts'
|
||||
import { carriedInline } from '../opaque-carry.ts'
|
||||
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
||||
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { inlineDirective } from '../../adf/inline-directives.ts'
|
||||
import { highlightDelimiter } from '../plain-conventions.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
@@ -23,6 +26,7 @@ type InlineContext = {
|
||||
atBlockEnd: boolean
|
||||
bracketed: boolean
|
||||
carried: ReadonlySet<number>
|
||||
flavour: Flavour
|
||||
openingLinkAsDirective: boolean
|
||||
path: ConvertErrorPath
|
||||
spansLines: boolean
|
||||
@@ -32,22 +36,32 @@ type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[
|
||||
|
||||
type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } | { fallback?: undefined; line: string }
|
||||
|
||||
type LineFallbacks = { carried: Set<number>; openingLinkAsDirective: boolean }
|
||||
type LineFallbacks = { carried: Set<number>; flavour: Flavour; openingLinkAsDirective: boolean }
|
||||
|
||||
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
|
||||
const emitted = emitLine(nodes, container, path)
|
||||
export type PlainLineFallback = { kind: 'claimed-line'; line: number; text: string } | { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
|
||||
|
||||
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<string> {
|
||||
const emitted = emitLine(nodes, container, path, flavour)
|
||||
if (!emitted.ok) return emitted
|
||||
return success(emitted.value.line)
|
||||
}
|
||||
|
||||
export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<boolean> {
|
||||
const emitted = emitLine(nodes, 'paragraph', path)
|
||||
const emitted = emitLine(nodes, 'paragraph', path, 'lossless')
|
||||
if (!emitted.ok) return emitted
|
||||
return success(emitted.value.openingLinkAsDirective)
|
||||
}
|
||||
|
||||
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
|
||||
const emitted = emitLine(nodes, 'table-cell', path)
|
||||
export function plainLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<PlainLineFallback | undefined> {
|
||||
const emission = lineSegments(nodes, container, path, { carried: new Set(), flavour: 'plain', openingLinkAsDirective: false })
|
||||
if (!emission.ok) return emission
|
||||
if (emission.value.carry !== undefined) return failure('unsupported-node-shape', 'an inline node on a plain line has no spelling but the carry', path)
|
||||
const verdict = lineVerdict(emission.value.segments, container, 'plain')
|
||||
return success(verdict.kind === 'line' ? undefined : verdict)
|
||||
}
|
||||
|
||||
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, flavour: Flavour): string | undefined {
|
||||
const emitted = emitLine(nodes, 'table-cell', path, flavour)
|
||||
if (!emitted.ok) return undefined
|
||||
if (emitted.value.segments.some((segment) => isSyntax(segment.escaping) && segment.text.includes('|'))) return undefined
|
||||
return emitted.value.line
|
||||
@@ -58,12 +72,13 @@ export function tryImageLine(alt: string | undefined, href: string, path: Conver
|
||||
const destination = spellDestination(href)
|
||||
if (destination === undefined) return undefined
|
||||
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
|
||||
const attempt = attemptLine([syntax('`)], 'paragraph', path)
|
||||
const attempt = attemptLine([syntax('`)], 'paragraph', path, 'lossless')
|
||||
return attempt.ok ? attempt.value.line : undefined
|
||||
}
|
||||
|
||||
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<EmittedLine> {
|
||||
const fallbacks: LineFallbacks = { carried: new Set(), openingLinkAsDirective: false }
|
||||
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<EmittedLine> {
|
||||
const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false }
|
||||
// Terminates because takeFallback refuses a pass that took no new fallback.
|
||||
for (;;) {
|
||||
const emission = lineSegments(nodes, container, path, fallbacks)
|
||||
if (!emission.ok) return emission
|
||||
@@ -72,7 +87,7 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con
|
||||
if (!taken.ok) return taken
|
||||
continue
|
||||
}
|
||||
const attempt = attemptLine(emission.value.segments, container, path)
|
||||
const attempt = attemptLine(emission.value.segments, container, path, flavour)
|
||||
if (!attempt.ok) return attempt
|
||||
if (attempt.value.line !== undefined) {
|
||||
return success({ line: attempt.value.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
|
||||
@@ -102,16 +117,23 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
|
||||
return success({ segments: carryStrippedWhitespace(emission.value.segments) })
|
||||
}
|
||||
|
||||
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> {
|
||||
const assembled = assembleInlineLine(segments, container)
|
||||
if (assembled.openingLinkAsDirective) return success({ fallback: 'opening-link' })
|
||||
if (assembled.unspellableRun !== undefined) return success({ fallback: assembled.unspellableRun })
|
||||
for (const [index, single] of assembled.line.split('\n').entries()) {
|
||||
if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) {
|
||||
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`, path)
|
||||
}
|
||||
}
|
||||
return success({ line: assembled.line })
|
||||
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<LineAttempt> {
|
||||
const verdict = lineVerdict(segments, container, flavour)
|
||||
if (verdict.kind === 'opening-link') return success({ fallback: 'opening-link' })
|
||||
if (verdict.kind === 'unspellable-run') return success({ fallback: verdict.runs[0] })
|
||||
if (verdict.kind === 'claimed-line') return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(verdict.text)}`, path)
|
||||
return success({ line: verdict.text })
|
||||
}
|
||||
|
||||
// The fallbacks in the order a line takes them, or the line where it takes none.
|
||||
function lineVerdict(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): PlainLineFallback | { kind: 'line'; text: string } {
|
||||
const assembled = assembleInlineLine(segments, container, flavour)
|
||||
if (assembled.openingLinkAsDirective) return { kind: 'opening-link' }
|
||||
const [run, ...others] = assembled.unspellableRuns
|
||||
if (run !== undefined) return { kind: 'unspellable-run', runs: [run, ...others] }
|
||||
const lines = assembled.line.split('\n')
|
||||
const claimed = container === 'paragraph' ? lines.findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later')) : -1
|
||||
return claimed === -1 ? { kind: 'line', text: assembled.line } : { kind: 'claimed-line', line: claimed, text: lines[claimed] ?? '' }
|
||||
}
|
||||
|
||||
// spec/flavour.md, Inline nodes.
|
||||
@@ -196,7 +218,7 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath {
|
||||
|
||||
function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
|
||||
if (carried.has(index)) return true
|
||||
return node.type !== 'text' && inlineDirective(node.type) === undefined
|
||||
return node.type !== 'text' && inlineNodeModel(node.type) === undefined
|
||||
}
|
||||
|
||||
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> {
|
||||
@@ -208,27 +230,27 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
|
||||
}
|
||||
const types = nodeMarks(node).map((mark) => mark.type)
|
||||
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
|
||||
const directive = inlineDirective(node.type)
|
||||
if (directive === undefined) return emitText(node, context, index, path)
|
||||
if (node.type === 'hardBreak') return emitHardBreak(node, directive, context, index, path)
|
||||
return emitInlineDirective(node, directive, index, path)
|
||||
const model = inlineNodeModel(node.type)
|
||||
if (model === undefined) return emitText(node, context, index, path)
|
||||
if (node.type === 'hardBreak') return emitHardBreak(node, model, context, index, path)
|
||||
return emitInlineDirective(node, model, index, path)
|
||||
}
|
||||
|
||||
function emitHardBreak(node: AdfNode, directive: InlineDirective, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
|
||||
function emitHardBreak(node: AdfNode, model: InlineNodeModel, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
|
||||
const empty = refuseContentAndText(node, path)
|
||||
if (!empty.ok) return empty
|
||||
const attributes = spellInlineNodeAttributes(node, directive)
|
||||
const attributes = spellInlineNodeAttributes(node, model)
|
||||
if (attributes === undefined) return success({ carry: { first: index, last: index } })
|
||||
if (attributes === '' && context.spansLines && !context.atBlockEnd) return success({ segments: [syntax('\\\n')] })
|
||||
return success({ segments: [syntax(spellInlineLeafDirective('hardBreak', attributes))] })
|
||||
}
|
||||
|
||||
function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: number, path: ConvertErrorPath): Result<Emission> {
|
||||
function emitInlineDirective(node: AdfNode, model: InlineNodeModel, index: number, path: ConvertErrorPath): Result<Emission> {
|
||||
const empty = refuseContentAndText(node, path)
|
||||
if (!empty.ok) return empty
|
||||
const attributes = spellInlineNodeAttributes(node, directive)
|
||||
const attributes = spellInlineNodeAttributes(node, model)
|
||||
if (attributes === undefined) return success({ carry: { first: index, last: index } })
|
||||
const slot = directive.textAttribute === undefined ? undefined : nodeAttrs(node)[directive.textAttribute]
|
||||
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
|
||||
if (slot === undefined) return success({ segments: [syntax(spellInlineLeafDirective(node.type, attributes))] })
|
||||
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
|
||||
const spans = slotLineEndingFault(node.type, slot)
|
||||
@@ -252,13 +274,14 @@ function emitText(node: AdfNode, context: InlineContext, index: number, path: Co
|
||||
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
|
||||
const path = nodePath(context, index)
|
||||
const range: NodeRange = { first: index, last: index + nodes.length - 1 }
|
||||
if (mark.type === 'backgroundColor' && context.flavour === 'plain') return emitHighlight(nodes, depth, range, context)
|
||||
const spelling = markSpelling(mark.type)
|
||||
if (spelling === undefined) return success({ carry: range })
|
||||
const attributes = spellMarkAttributes(mark, spelling.attributes)
|
||||
if (attributes === undefined) return success({ carry: range })
|
||||
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
|
||||
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
|
||||
const link = spelling.kind === 'link' ? emitLink(nodes, mark, depth, range, context) : undefined
|
||||
const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined
|
||||
if (link !== undefined) return link
|
||||
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
|
||||
if (!inner.ok) return inner
|
||||
@@ -273,13 +296,22 @@ function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number
|
||||
const carried = carryStrippedWhitespace(inner.value.segments)
|
||||
return success({
|
||||
segments: [
|
||||
{ emphasis: 'open', escaping: 'none', nodes: range, text: spelling },
|
||||
{ emphasis: 'open', escaping: 'none', nodes: { ...range, depth }, text: spelling },
|
||||
...carried,
|
||||
{ emphasis: 'close', escaping: 'none', nodes: range, text: spelling },
|
||||
{ emphasis: 'close', escaping: 'none', nodes: { ...range, depth }, text: spelling },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRange, context: InlineContext): Result<Emission> {
|
||||
const inner = emitRun(nodes, depth + 1, range.first, context)
|
||||
if (!inner.ok || inner.value.carry !== undefined) return inner
|
||||
const run = { ...range, depth }
|
||||
return success({
|
||||
segments: [{ escaping: 'none', highlight: 'open', nodes: run, text: highlightDelimiter }, ...inner.value.segments, { escaping: 'none', highlight: 'close', nodes: run, text: highlightDelimiter }],
|
||||
})
|
||||
}
|
||||
|
||||
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
|
||||
let text = ''
|
||||
for (const node of nodes) {
|
||||
@@ -300,8 +332,7 @@ function needsPadding(text: string): boolean {
|
||||
return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text)
|
||||
}
|
||||
|
||||
// `undefined` where the link takes the directive form the caller spells.
|
||||
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result<Emission> | undefined {
|
||||
function tryLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result<Emission> | undefined {
|
||||
const href = linkHref(nodeAttrs(mark))
|
||||
if (href === undefined) return success({ carry: range })
|
||||
const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import type { Flavour } from '../plain-conventions.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark/grammar.ts'
|
||||
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||
import { claimsDirectivePrefix } from '../directive-syntax.ts'
|
||||
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
|
||||
import { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts'
|
||||
import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
|
||||
import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
|
||||
import { readEntityReference } from '../commonmark/entity-references.ts'
|
||||
|
||||
export type EmphasisRole = 'close' | 'open'
|
||||
export type DelimiterRole = 'close' | 'open'
|
||||
|
||||
export type InlineEscaping = 'backslash' | 'bracketed' | 'bracketed-link-target' | 'none'
|
||||
|
||||
export type NodeRange = { first: number; last: number }
|
||||
|
||||
export type InlineSegment =
|
||||
| { emphasis: EmphasisRole; escaping: 'none'; nodes: NodeRange; text: string }
|
||||
| { emphasis?: undefined; escaping: 'none'; nodes: NodeRange; text: string }
|
||||
| { emphasis?: undefined; escaping: InlineEscaping; nodes?: undefined; text: string }
|
||||
export type MarkRun = NodeRange & { depth: number }
|
||||
|
||||
export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRun: NodeRange | undefined }
|
||||
export type InlineSegment =
|
||||
| { emphasis: DelimiterRole; escaping: 'none'; highlight?: undefined; nodes: MarkRun; text: string }
|
||||
| { emphasis?: undefined; escaping: 'none'; highlight: DelimiterRole; nodes: MarkRun; text: string }
|
||||
| { emphasis?: undefined; escaping: 'none'; highlight?: undefined; nodes: NodeRange; text: string }
|
||||
| { emphasis?: undefined; escaping: InlineEscaping; highlight?: undefined; nodes?: undefined; text: string }
|
||||
|
||||
export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRuns: MarkRun[] }
|
||||
|
||||
type ScanLine = { position: LinePosition; start: number; text: string }
|
||||
|
||||
export type LineContainer = 'heading' | 'paragraph' | 'table-cell'
|
||||
|
||||
type EmittedDelimiter = { closes: boolean; offset: number; pair: number; width: number }
|
||||
|
||||
type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; delimiters: EmittedDelimiter[]; length: number; start: number }
|
||||
@@ -31,8 +35,8 @@ const delimiters = ['*', '_', '`', '~']
|
||||
|
||||
const followsLinkText = /[([]/
|
||||
|
||||
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
|
||||
return escape(resolveEmphasis(segments), container)
|
||||
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): AssembledLine {
|
||||
return escape(resolveEmphasis(segments), container, flavour === 'plain')
|
||||
}
|
||||
|
||||
function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
|
||||
@@ -64,11 +68,11 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
|
||||
return resolved
|
||||
}
|
||||
|
||||
function escape(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
|
||||
function escape(segments: readonly InlineSegment[], container: LineContainer, highlights: boolean): AssembledLine {
|
||||
const scan = segments.map((segment) => segment.text).join('')
|
||||
const escapings: InlineEscaping[] = []
|
||||
for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) escapings.push(segment.escaping)
|
||||
const escaped = escapedIndexes(scan, escapings, container)
|
||||
const escaped = escapeClosedRuns(scan, escapings, escapeClaims(scan, escapings, container, highlights))
|
||||
const placements: number[] = []
|
||||
let output = ''
|
||||
for (let index = 0; index < scan.length; index += 1) {
|
||||
@@ -77,37 +81,48 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
|
||||
output += scan.charAt(index)
|
||||
}
|
||||
if (container === 'paragraph' && opensLinkDefinition(output)) {
|
||||
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRun: undefined }
|
||||
return { line: `\\${output}`, unspellableRun: unspellableRun(segments, output, placements) }
|
||||
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRuns: [] }
|
||||
return { line: `\\${output}`, unspellableRuns: unspellableRuns(segments, output, placements) }
|
||||
}
|
||||
return { line: output, unspellableRun: unspellableRun(segments, output, placements) }
|
||||
return { line: output, unspellableRuns: unspellableRuns(segments, output, placements) }
|
||||
}
|
||||
|
||||
function escapedIndexes(scan: string, escapings: readonly InlineEscaping[], container: LineContainer): Set<number> {
|
||||
function escapeClaims(scan: string, escapings: readonly InlineEscaping[], container: LineContainer, highlights: boolean): ReadonlySet<number> {
|
||||
const escaped = new Set<number>()
|
||||
const linkClose = lastLinkClose(scan, escapings)
|
||||
let line = scanLine(scan, 0)
|
||||
let afterEscape = false
|
||||
// Whether the `=` before opens a `==` the reader takes whole, so this one starts nothing.
|
||||
let pairsEquals = false
|
||||
for (let index = 0; index < scan.length; index += 1) {
|
||||
if (index > line.start + line.text.length) line = scanLine(scan, line.start + line.text.length + 1)
|
||||
const escaping = escapings[index]
|
||||
const escapable = escaping === 'backslash' || escaping === 'bracketed'
|
||||
if (
|
||||
const opensEquals: boolean = highlights && !pairsEquals && scan.startsWith(highlightDelimiter, index)
|
||||
const claimed: boolean =
|
||||
(escapable &&
|
||||
(claimsLineStart(line, index, container) ||
|
||||
((opensEquals && claimsHighlight(scan, index)) ||
|
||||
claimsLineStart(line, index, container) ||
|
||||
mergesWithSyntax(scan, escapings, index) ||
|
||||
opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))) ||
|
||||
opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, afterEscape))) ||
|
||||
(escaping === 'bracketed-link-target' &&
|
||||
((scan.charAt(index) === '`' && opensCodeSpan(scan, index, escaped)) || claimsDirectivePrefix(scan, index)))
|
||||
) {
|
||||
escaped.add(index)
|
||||
((scan.charAt(index) === '`' && opensCodeSpan(scan, index, afterEscape)) || claimsDirectivePrefix(scan, index)))
|
||||
if (claimed) escaped.add(index)
|
||||
afterEscape = claimed
|
||||
pairsEquals = opensEquals && !claimed
|
||||
}
|
||||
}
|
||||
escapeClosedRuns(scan, escapings, escaped)
|
||||
return escaped
|
||||
}
|
||||
|
||||
// Like an emphasis run, a `==` in text escapes where the reader can open or close with it.
|
||||
function claimsHighlight(scan: string, index: number): boolean {
|
||||
const flanking = highlightFlanking(scan, index)
|
||||
return flanking.opens || flanking.closes
|
||||
}
|
||||
|
||||
// CommonMark reads no escape inside a code span, so a backtick string an escape forms or splits off still closes one an earlier bare run opens.
|
||||
function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], escaped: Set<number>): void {
|
||||
function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], claimed: ReadonlySet<number>): ReadonlySet<number> {
|
||||
const escaped = new Set(claimed)
|
||||
const formed = new Set<number>()
|
||||
let end = scan.length - 1
|
||||
while (end >= 0) {
|
||||
@@ -119,23 +134,41 @@ function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], es
|
||||
while (scan.charAt(start - 1) === '`') start -= 1
|
||||
let segmentEnd = end
|
||||
for (let index = end; index > start; index -= 1) {
|
||||
if (!escaped.has(index)) continue
|
||||
if (!claimed.has(index)) continue
|
||||
formed.add(segmentEnd - index + 1)
|
||||
segmentEnd = index - 1
|
||||
}
|
||||
if (segmentEnd !== end || escaped.has(start)) formed.add(segmentEnd - start + 1)
|
||||
if (segmentEnd !== end || claimed.has(start)) formed.add(segmentEnd - start + 1)
|
||||
else if (escapings[start] !== 'none' && formed.has(end - start + 1)) {
|
||||
for (let index = start; index <= end; index += 1) escaped.add(index)
|
||||
formed.add(1)
|
||||
}
|
||||
end = start - 1
|
||||
}
|
||||
return escaped
|
||||
}
|
||||
|
||||
function unspellableRun(segments: readonly InlineSegment[], output: string, placements: readonly number[]): NodeRange | undefined {
|
||||
// One emphasis run, the innermost, or every highlight run the line cannot spell.
|
||||
function unspellableRuns(segments: readonly InlineSegment[], output: string, placements: readonly number[]): MarkRun[] {
|
||||
const { nodes, runs } = emittedRuns(segments, placements, output)
|
||||
const pair = misflanked(runs) ?? unpaired(runs)
|
||||
return pair === undefined ? undefined : nodes[pair]
|
||||
const run = pair === undefined ? undefined : nodes[pair]
|
||||
return run === undefined ? unreadHighlights(segments, output, placements) : [run]
|
||||
}
|
||||
|
||||
// No `==` in text can open or close, and highlights never nest, so a pair reads back where each delimiter flanks.
|
||||
function unreadHighlights(segments: readonly InlineSegment[], output: string, placements: readonly number[]): MarkRun[] {
|
||||
const unread: MarkRun[] = []
|
||||
let cursor = 0
|
||||
for (const segment of segments) {
|
||||
const start = placements[cursor] ?? 0
|
||||
cursor += segment.text.length
|
||||
if (segment.highlight === undefined) continue
|
||||
const flanking = highlightFlanking(output, start)
|
||||
const flanks = segment.highlight === 'open' ? flanking.opens : flanking.closes
|
||||
if (!flanks && unread.at(-1) !== segment.nodes) unread.push(segment.nodes)
|
||||
}
|
||||
return unread
|
||||
}
|
||||
|
||||
function misflanked(runs: readonly EmittedRun[]): number | undefined {
|
||||
@@ -168,9 +201,9 @@ function delimiterAt(run: EmittedRun, closes: boolean, offset: number, width: nu
|
||||
return run.delimiters.find((delimiter) => delimiter.closes === closes && delimiter.offset === offset && delimiter.width === width)
|
||||
}
|
||||
|
||||
function emittedRuns(segments: readonly InlineSegment[], placements: readonly number[], output: string): { nodes: NodeRange[]; runs: EmittedRun[] } {
|
||||
function emittedRuns(segments: readonly InlineSegment[], placements: readonly number[], output: string): { nodes: MarkRun[]; runs: EmittedRun[] } {
|
||||
const runs: EmittedRun[] = []
|
||||
const nodes: NodeRange[] = []
|
||||
const nodes: MarkRun[] = []
|
||||
const open: number[] = []
|
||||
let cursor = 0
|
||||
for (const segment of segments) {
|
||||
@@ -232,10 +265,10 @@ function opensConstruct(
|
||||
index: number,
|
||||
inBrackets: boolean,
|
||||
container: LineContainer,
|
||||
escaped: ReadonlySet<number>,
|
||||
afterEscape: boolean,
|
||||
): boolean {
|
||||
if (container === 'heading' && closesHeading(scan, index)) return true
|
||||
return claimsCharacter(scan, linkClose, index, inBrackets, container, escaped)
|
||||
return claimsCharacter(scan, linkClose, index, inBrackets, container, afterEscape)
|
||||
}
|
||||
|
||||
// A hard break is the one spelling that puts a delimiter row under a row of its own, so only a later line claims.
|
||||
@@ -261,7 +294,7 @@ function claimsCharacter(
|
||||
index: number,
|
||||
inBrackets: boolean,
|
||||
container: LineContainer,
|
||||
escaped: ReadonlySet<number>,
|
||||
afterEscape: boolean,
|
||||
): boolean {
|
||||
const character = scan.charAt(index)
|
||||
if (inBrackets && (character === '[' || character === ']')) return true
|
||||
@@ -271,8 +304,8 @@ function claimsCharacter(
|
||||
if (character === '<') return opensBracketedAutolink(scan, index) || opensEmailAutolink(scan, index) || inlineHtmlConstruct(scan, index) !== undefined
|
||||
if (character === '!') return claimsDirectivePrefix(scan, index)
|
||||
if (character === '[') return index < linkClose
|
||||
if (character === '`') return opensCodeSpan(scan, index, escaped)
|
||||
if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, escaped)
|
||||
if (character === '`') return opensCodeSpan(scan, index, afterEscape)
|
||||
if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, afterEscape)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -285,16 +318,16 @@ function lastLinkClose(scan: string, escapings: readonly (InlineEscaping | undef
|
||||
return -1
|
||||
}
|
||||
|
||||
function opensCodeSpan(scan: string, index: number, escaped: ReadonlySet<number>): boolean {
|
||||
function opensCodeSpan(scan: string, index: number, afterEscape: boolean): boolean {
|
||||
// A run escapes whole: a rest left bare would be a raw run of another length for a closer.
|
||||
if (scan.charAt(index - 1) === '`' && escaped.has(index - 1)) return true
|
||||
if (!startsRun(scan, index, escaped)) return false
|
||||
if (afterEscape && scan.charAt(index - 1) === '`') return true
|
||||
if (!startsRun(scan, index, afterEscape)) return false
|
||||
const opener = backtickRun(scan, index)
|
||||
return closingBacktickRun(scan, index + opener, opener) !== undefined
|
||||
}
|
||||
|
||||
function claimsEmphasis(scan: string, index: number, escaped: ReadonlySet<number>): boolean {
|
||||
if (!startsRun(scan, index, escaped)) return false
|
||||
function claimsEmphasis(scan: string, index: number, afterEscape: boolean): boolean {
|
||||
if (!startsRun(scan, index, afterEscape)) return false
|
||||
const character = scan.charAt(index)
|
||||
const length = runLength(scan, index)
|
||||
if (character === '~' && length !== 2) return false
|
||||
@@ -302,8 +335,8 @@ function claimsEmphasis(scan: string, index: number, escaped: ReadonlySet<number
|
||||
return flags.canClose || flags.canOpen
|
||||
}
|
||||
|
||||
function startsRun(scan: string, index: number, escaped: ReadonlySet<number>): boolean {
|
||||
if (index === 0 || escaped.has(index - 1)) return true
|
||||
function startsRun(scan: string, index: number, afterEscape: boolean): boolean {
|
||||
if (index === 0 || afterEscape) return true
|
||||
return scan.charAt(index - 1) !== scan.charAt(index)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { ConvertErrorPath } from '../../result.ts'
|
||||
import type { Flavour } from '../plain-conventions.ts'
|
||||
import { carriesOnly, nodeContent } from '../../adf/document.ts'
|
||||
import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
|
||||
import { tryPipeCell } from './inline-line.ts'
|
||||
|
||||
export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | undefined {
|
||||
export function tryPipeTable(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): string | undefined {
|
||||
const rows = pipeRows(node)
|
||||
if (rows === undefined) return undefined
|
||||
const lines: string[] = []
|
||||
@@ -12,7 +13,7 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
|
||||
const cells: string[] = []
|
||||
for (const [cellIndex, paragraph] of row.entries()) {
|
||||
const content = nodeContent(paragraph)
|
||||
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
|
||||
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], flavour)
|
||||
if (line === undefined) return undefined
|
||||
cells.push(line)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import type { MarkRun } from './line-escaping.ts'
|
||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts'
|
||||
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { plainLineFallback, type PlainLineFallback } from './inline-line.ts'
|
||||
import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
|
||||
|
||||
const highlight = 'backgroundColor'
|
||||
const highlightMark: AdfMark = { type: highlight }
|
||||
const edgeStrippingMarks: readonly string[] = [highlight, 'em', 'strike', 'strong']
|
||||
const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link']
|
||||
|
||||
export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||
const leaves = inlineLeaves(nodes, container, path, depth)
|
||||
if (!leaves.ok) return leaves
|
||||
return spellableLine(trimmedEdges(highlighted(trimmedEdges(leaves.value))), container, path)
|
||||
}
|
||||
|
||||
export function isBlockNodeType(type: string): boolean {
|
||||
return blockNodeModel(type) !== undefined || type === 'blockCard' || type === 'embedCard'
|
||||
}
|
||||
|
||||
export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
|
||||
const leaves: AdfNode[] = []
|
||||
let joinsNext = false
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
const held = nodeLeaves(node, container, [...path, 'content', index], depth)
|
||||
if (!held.ok) return held
|
||||
if (held.value.length === 0) continue
|
||||
const block = isBlockNodeType(node.type) && node.type !== 'media'
|
||||
if (leaves.length > 0 && (joinsNext || block)) leaves.push(textLeaf(' ', []))
|
||||
joinsNext = block
|
||||
for (const leaf of held.value) leaves.push(leaf)
|
||||
}
|
||||
return success(leaves)
|
||||
}
|
||||
|
||||
function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||
const marks = nodeMarks(node)
|
||||
const attrs = nodeAttrs(node)
|
||||
if (node.type === 'text') return success(textLeaves(node.text, marks, container))
|
||||
if (node.type === 'hardBreak') return success([lineBreak(container)])
|
||||
if (node.type === 'date') return success(textLeaves(isoDate(attrs['timestamp']), marks, container))
|
||||
if (node.type === 'emoji') return success(textLeaves(nonEmpty(attrs['text']) ?? attrs['shortName'], marks, container))
|
||||
if (node.type === 'placeholder') return success([])
|
||||
if (node.type === 'mention') return success(textLeaves(nonEmpty(attrs['text']) ?? idMention(attrs['id']), marks, container))
|
||||
if (node.type === 'status') return success(textLeaves(attrs['text'], marks, container))
|
||||
if (['extension', 'inlineExtension'].includes(node.type)) return success(textLeaves(nonEmpty(attrs['text']), marks, container, noteName(attrs['extensionKey']) ?? 'extension'))
|
||||
if (node.type === 'syncBlock') return success(noteLeaves('synced block'))
|
||||
if (['media', 'mediaInline'].includes(node.type)) return success(mediaLeaves(attrs, marks, container))
|
||||
if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(attrs, marks, container))
|
||||
const own = textLeaves(node.text ?? (['expand', 'nestedExpand'].includes(node.type) ? attrs['title'] : undefined), marks, container)
|
||||
const held = inlineLeaves(nodeContent(node), container, path, depth + 1)
|
||||
if (!held.ok) return held
|
||||
return success(own.length > 0 && held.value.length > 0 ? [...own, textLeaf(' ', []), ...held.value] : [...own, ...held.value])
|
||||
}
|
||||
|
||||
function cardLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||
const data = attrs['data']
|
||||
const held = typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {}
|
||||
const url = nonEmpty(attrs['url'])
|
||||
const heldUrl = nonEmpty(held['url'])
|
||||
const name = nonEmpty(held['name'])
|
||||
const href = url ?? heldUrl
|
||||
if (href === undefined) return textLeaves(name, marks, container, 'link card')
|
||||
return linkedLeaves(url ?? name ?? href, href, marks, container)
|
||||
}
|
||||
|
||||
function linkedLeaves(text: string, href: string, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||
return textLeaves(text, [...marks.filter((mark) => mark.type !== 'link'), { attrs: { href }, type: 'link' }], container)
|
||||
}
|
||||
|
||||
function idMention(id: unknown): string | undefined {
|
||||
return typeof id === 'string' && id !== '' ? `@${id}` : undefined
|
||||
}
|
||||
|
||||
// An image standing inline is a link to it: CommonMark's inline image reads back as no node.
|
||||
function mediaLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||
const alt = nonEmpty(attrs['alt'])
|
||||
const url = nonEmpty(attrs['url'])
|
||||
if (attrs['type'] !== 'external' || url === undefined) return textLeaves(alt, marks, container, 'image')
|
||||
return linkedLeaves(alt ?? url, url, marks, container)
|
||||
}
|
||||
|
||||
function noteLeaves(name: string): AdfNode[] {
|
||||
return [textLeaf(`(${name} not included)`, [{ type: 'em' }])]
|
||||
}
|
||||
|
||||
function noteName(value: unknown): string | undefined {
|
||||
return nonEmpty(value) === undefined ? undefined : oneLine(String(value)).trim()
|
||||
}
|
||||
|
||||
export function oneLine(text: string): string {
|
||||
return text.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ')
|
||||
}
|
||||
|
||||
function nonEmpty(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && oneLine(value).trim() !== '' ? value : undefined
|
||||
}
|
||||
|
||||
function isoDate(timestamp: unknown): string | undefined {
|
||||
const milliseconds = typeof timestamp === 'string' && /^-?\d+$/.test(timestamp) ? Number(timestamp) : Number.NaN
|
||||
const date = new Date(milliseconds)
|
||||
if (Number.isNaN(date.getTime())) return undefined
|
||||
const iso = date.toISOString()
|
||||
return iso.slice(0, iso.indexOf('T'))
|
||||
}
|
||||
|
||||
function lineBreak(container: LineContainer): AdfNode {
|
||||
return container === 'paragraph' ? { type: 'hardBreak' } : textLeaf(' ', [])
|
||||
}
|
||||
|
||||
function textLeaves(value: unknown, marks: readonly AdfMark[], container: LineContainer, note?: string): AdfNode[] {
|
||||
if (typeof value !== 'string') return note === undefined ? [] : noteLeaves(note)
|
||||
const text = value.replace(/[\r\u0000]/g, '')
|
||||
const kept = plainMarks(marks, container, text)
|
||||
const leaves: AdfNode[] = []
|
||||
for (const [index, line] of text.split('\n').entries()) {
|
||||
if (index > 0) leaves.push(lineBreak(container))
|
||||
if (line !== '') leaves.push(textLeaf(line, kept))
|
||||
}
|
||||
return leaves
|
||||
}
|
||||
|
||||
function textLeaf(text: string, marks: readonly AdfMark[]): AdfNode {
|
||||
return marks.length === 0 ? { text, type: 'text' } : { marks: [...marks], text, type: 'text' }
|
||||
}
|
||||
|
||||
// A highlight goes first, where `highlighted` looks for it, and code last, the only place its spelling holds.
|
||||
function plainMarks(marks: readonly AdfMark[], container: LineContainer, text: string): AdfMark[] {
|
||||
const kept: AdfMark[] = []
|
||||
for (const mark of marks) {
|
||||
if (!keptMarks.includes(mark.type) || kept.some((held) => held.type === mark.type)) continue
|
||||
if (mark.type === 'code' && container === 'table-cell' && text.includes('|')) continue
|
||||
const plain = mark.type === 'link' ? plainLink(mark, container) : { type: mark.type }
|
||||
if (plain !== undefined) kept.push(plain)
|
||||
}
|
||||
const rank = (mark: AdfMark): number => (mark.type === highlight ? 0 : mark.type === 'code' ? 2 : 1)
|
||||
// The reader highlights no code, as Atlassian's schema allows none.
|
||||
const code = kept.some((mark) => mark.type === 'code')
|
||||
return kept.filter((mark) => !code || mark.type !== highlight).sort((first, second) => rank(first) - rank(second))
|
||||
}
|
||||
|
||||
function plainLink(mark: AdfMark, container: LineContainer): AdfMark | undefined {
|
||||
const attrs = nodeAttrs(mark)
|
||||
const held = attrs['href']
|
||||
const title = typeof attrs['title'] === 'string' ? attrs['title'].replace(/\r/g, '').replace(/\n/g, ' ') : undefined
|
||||
if (typeof held !== 'string') return undefined
|
||||
const href = writableHref(container === 'table-cell' ? held.replaceAll('|', '%7C') : held)
|
||||
if (title === undefined || spellLinkTarget(href, title) === undefined || (container === 'table-cell' && title.includes('|'))) return { attrs: { href }, type: 'link' }
|
||||
return { attrs: { href, title }, type: 'link' }
|
||||
}
|
||||
|
||||
// spec/flavour.md, Links: the characters no destination spelling holds, then an ampersand an entity reference would read.
|
||||
export function writableHref(href: string): string {
|
||||
let written = href
|
||||
for (const unwritable of [/[\u0000-\u001f\u007f\\<>]/g, /&/g]) {
|
||||
if (spellDestination(written) !== undefined) return written
|
||||
written = written.replace(unwritable, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
}
|
||||
return written
|
||||
}
|
||||
|
||||
// The marks a whole highlight run shares go outside the highlight, so its delimiters open and close inside them.
|
||||
function highlighted(leaves: readonly AdfNode[]): AdfNode[] {
|
||||
const spelled: AdfNode[] = []
|
||||
let run: AdfNode[] = []
|
||||
let shared: AdfMark[] = []
|
||||
for (const leaf of [...leaves, { type: 'hardBreak' }]) {
|
||||
const marks = nodeMarks(leaf)
|
||||
if (marks[0]?.type === highlight) {
|
||||
const held = marks.slice(1)
|
||||
shared = run.length === 0 ? held : shared.filter((mark) => held.some((other) => sameMark(other, mark)))
|
||||
run.push(leaf)
|
||||
continue
|
||||
}
|
||||
for (const held of run) spelled.push(withMarks(held, [...shared, highlightMark, ...nodeMarks(held).slice(1).filter((mark) => !shared.some((other) => sameMark(other, mark)))]))
|
||||
run = []
|
||||
spelled.push(leaf)
|
||||
}
|
||||
return spelled.slice(0, -1)
|
||||
}
|
||||
|
||||
function withMarks(leaf: AdfNode, marks: readonly AdfMark[]): AdfNode {
|
||||
const { marks: _, ...unmarked } = leaf
|
||||
return marks.length === 0 ? unmarked : { ...unmarked, marks: [...marks] }
|
||||
}
|
||||
|
||||
function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] {
|
||||
for (let current = leaves; ; ) {
|
||||
const merged = withoutEdgeBreaks(mergeAdjacentText(current))
|
||||
let changed = false
|
||||
const trimmed: AdfNode[] = []
|
||||
for (const [index, leaf] of merged.entries()) {
|
||||
const edges = leafEdges(leaf, merged[index - 1], merged[index + 1])
|
||||
if (edges === undefined) {
|
||||
trimmed.push(leaf)
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
for (const edge of edges) trimmed.push(edge)
|
||||
}
|
||||
if (!changed) return merged
|
||||
current = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
function withoutEdgeBreaks(leaves: readonly AdfNode[]): AdfNode[] {
|
||||
let first = 0
|
||||
let last = leaves.length - 1
|
||||
while (leaves[first]?.type === 'hardBreak') first += 1
|
||||
while (last >= first && leaves[last]?.type === 'hardBreak') last -= 1
|
||||
return leaves.slice(first, last + 1)
|
||||
}
|
||||
|
||||
// spec/flavour.md, Inline nodes: edge whitespace leaves every stripping mark opening or closing beside it, and goes at a line edge.
|
||||
function leafEdges(leaf: AdfNode, previous: AdfNode | undefined, next: AdfNode | undefined): AdfNode[] | undefined {
|
||||
const marks = nodeMarks(leaf)
|
||||
const text = leaf.text
|
||||
if (text === undefined || marks.some((mark) => mark.type === 'code')) return undefined
|
||||
const lead = text.slice(0, text.search(/[^ \t]|$/))
|
||||
const trail = text.slice(text.search(/[ \t]*$/))
|
||||
const leadDepth = edgeDepth(marks, previous, lead)
|
||||
const trailDepth = edgeDepth(marks, next, trail)
|
||||
if (leadDepth === marks.length && trailDepth === marks.length) return undefined
|
||||
if (lead === text) return leadDepth === undefined || trailDepth === undefined ? [] : [textLeaf(text, marks.slice(0, Math.min(leadDepth, trailDepth)))]
|
||||
const edges: AdfNode[] = []
|
||||
if (lead !== '' && leadDepth !== undefined) edges.push(textLeaf(lead, marks.slice(0, leadDepth)))
|
||||
const core = text.slice(lead.length, text.length - trail.length)
|
||||
if (core !== '') edges.push(textLeaf(core, marks))
|
||||
if (trail !== '' && trailDepth !== undefined) edges.push(textLeaf(trail, marks.slice(0, trailDepth)))
|
||||
return edges
|
||||
}
|
||||
|
||||
function edgeDepth(marks: readonly AdfMark[], neighbour: AdfNode | undefined, whitespace: string): number | undefined {
|
||||
if (whitespace === '') return marks.length
|
||||
const lineEdge = neighbour === undefined || neighbour.type === 'hardBreak'
|
||||
const neighbourMarks = lineEdge ? [] : nodeMarks(neighbour)
|
||||
let shared = 0
|
||||
while (shared < marks.length && sameMarkAt(marks, neighbourMarks, shared)) shared += 1
|
||||
const stripping = marks.findIndex((mark, index) => index >= shared && edgeStrippingMarks.includes(mark.type))
|
||||
const kept = stripping === -1 ? marks.length : stripping
|
||||
return kept === 0 && lineEdge ? undefined : kept
|
||||
}
|
||||
|
||||
function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index: number): boolean {
|
||||
const mark = marks[index]
|
||||
const other = others[index]
|
||||
return mark !== undefined && other !== undefined && sameMark(mark, other)
|
||||
}
|
||||
|
||||
function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<AdfNode[]> {
|
||||
for (let current = leaves; ; ) {
|
||||
const fallback = plainLineFallback(current, container, path)
|
||||
if (!fallback.ok) return fallback
|
||||
if (fallback.value === undefined) return success(current)
|
||||
const fixed = withoutFallback(current, fallback.value)
|
||||
if (fixed === undefined) return failure('unsupported-node-shape', 'a plain line keeps a spelling that dropping a mark does not change', path)
|
||||
current = trimmedEdges(fixed)
|
||||
}
|
||||
}
|
||||
|
||||
function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback): AdfNode[] | undefined {
|
||||
if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, fallback.runs)
|
||||
const first = fallback.kind === 'opening-link' ? 0 : lineStart(leaves, fallback.line)
|
||||
const mark = nodeMarks(leaves[first] ?? {})[0]
|
||||
if (mark === undefined || mark.type !== (fallback.kind === 'opening-link' ? 'link' : 'code')) return undefined
|
||||
let last = first
|
||||
while (sameMarkAt(nodeMarks(leaves[last + 1] ?? {}), [mark], 0)) last += 1
|
||||
// A code span is what binds the `]` a link definition reads, and dropping it keeps the link target.
|
||||
const spans = leaves.slice(first, last + 1).some((leaf) => nodeMarks(leaf).length > 1 && nodeMarks(leaf).at(-1)?.type === 'code')
|
||||
if (mark.type === 'link' && spans) return leaves.map((leaf, index) => (index < first || index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code'))))
|
||||
return withoutMarks(leaves, [{ depth: 0, first, last }])
|
||||
}
|
||||
|
||||
function lineStart(leaves: readonly AdfNode[], line: number): number {
|
||||
let index = 0
|
||||
for (let breaks = 0; breaks < line && index < leaves.length; index += 1) if (leaves[index]?.type === 'hardBreak') breaks += 1
|
||||
return index
|
||||
}
|
||||
|
||||
// The runs cover disjoint leaves, so one pass drops them all.
|
||||
function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): AdfNode[] {
|
||||
const depths = new Map<number, number>()
|
||||
for (const run of runs) for (let index = run.first; index <= run.last; index += 1) depths.set(index, run.depth)
|
||||
return leaves.map((leaf, index) => {
|
||||
const depth = depths.get(index)
|
||||
return depth === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => held !== depth))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import { adfToPlainMarkdown, reduceToPlain } from './plain-reduction.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
|
||||
const code: AdfMark = { type: 'code' }
|
||||
const em: AdfMark = { type: 'em' }
|
||||
const strong: AdfMark = { type: 'strong' }
|
||||
|
||||
function document(...content: AdfNode[]): AdfDocument {
|
||||
return { content, type: 'doc', version: 1 }
|
||||
}
|
||||
|
||||
function plain(...content: AdfNode[]): string {
|
||||
return plainDocument(document(...content))
|
||||
}
|
||||
|
||||
function plainDocument(input: AdfDocument): string {
|
||||
const markdown = adfToPlainMarkdown(input)
|
||||
return markdown.ok ? markdown.value : `${markdown.error.code} at /${markdown.error.path.join('/')}`
|
||||
}
|
||||
|
||||
function text(value: string, ...marks: AdfMark[]): AdfNode {
|
||||
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
|
||||
}
|
||||
|
||||
function node(type: string, attrs: AdfAttributes, ...content: AdfNode[]): AdfNode {
|
||||
return { attrs, content, type }
|
||||
}
|
||||
|
||||
function paragraph(...content: AdfNode[]): AdfNode {
|
||||
return { content, type: 'paragraph' }
|
||||
}
|
||||
|
||||
function said(value: string): AdfNode {
|
||||
return paragraph(text(value))
|
||||
}
|
||||
|
||||
function item(...content: AdfNode[]): AdfNode {
|
||||
return { content, type: 'listItem' }
|
||||
}
|
||||
|
||||
function bulletList(...content: AdfNode[]): AdfNode {
|
||||
return { content, type: 'bulletList' }
|
||||
}
|
||||
|
||||
function cell(type: string, ...content: AdfNode[]): AdfNode {
|
||||
return { content, type }
|
||||
}
|
||||
|
||||
function row(...content: AdfNode[]): AdfNode {
|
||||
return { content, type: 'tableRow' }
|
||||
}
|
||||
|
||||
function link(href: string, title?: string): AdfMark {
|
||||
return { attrs: title === undefined ? { href } : { href, title }, type: 'link' }
|
||||
}
|
||||
|
||||
test('refuses what the document guard refuses, and nothing else', () => {
|
||||
assert.equal(plainDocument({ type: 'doc', version: Number.NaN }), 'not-an-adf-document at /')
|
||||
assert.equal(plainDocument({ type: 'doc', version: 2 }), 'unsupported-document-version at /')
|
||||
let deep: AdfNode = said('x')
|
||||
for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' }
|
||||
assert.match(plainDocument(document(deep)), /^unsupported-nesting-depth at \/content\/0(\/content\/0)+$/)
|
||||
let deepInline: AdfNode = text('x')
|
||||
for (let level = 0; level <= largestNesting; level += 1) deepInline = { content: [deepInline], type: 'unknownInline' }
|
||||
assert.match(plainDocument(document(paragraph(deepInline))), /^unsupported-nesting-depth at /)
|
||||
})
|
||||
|
||||
test('refuses nesting past 500 levels wherever the reduction walks', () => {
|
||||
const lowest = (bottom: AdfNode): string => {
|
||||
let deep = bottom
|
||||
for (let level = 0; level < largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' }
|
||||
return plainDocument(document(deep)).split(' ')[0] ?? ''
|
||||
}
|
||||
const wrapped: AdfNode = { content: [text('x')], type: 'unknownInline' }
|
||||
const bottoms: AdfNode[] = [
|
||||
bulletList(item(said('x'))),
|
||||
node('taskList', {}, node('taskList', {}, node('taskItem', {}, text('x')))),
|
||||
node('taskList', {}, node('taskItem', {}, wrapped)),
|
||||
node('taskList', {}, node('blockTaskItem', {}, said('x'))),
|
||||
node('panel', {}, said('x')),
|
||||
node('expand', {}, said('x')),
|
||||
node('decisionList', {}, node('decisionItem', {}, text('x'))),
|
||||
node('table', {}, row(cell('tableCell', said('x')))),
|
||||
node('mediaSingle', {}, node('caption', {}, text('x'))),
|
||||
node('heading', { level: 1 }, wrapped),
|
||||
node('codeBlock', {}, wrapped),
|
||||
paragraph(wrapped),
|
||||
]
|
||||
for (const bottom of bottoms) assert.equal(lowest(bottom), 'unsupported-nesting-depth', bottom.type)
|
||||
})
|
||||
|
||||
test('spells a panel as an alert in the GitHub word for its colour', () => {
|
||||
const panel = (panelType: string | undefined): string =>
|
||||
plain(node('panel', panelType === undefined ? {} : { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType }, said('Check it.')))
|
||||
assert.equal(panel('info'), '> [!NOTE]\n>\n> Check it.\n')
|
||||
assert.equal(panel('note'), '> [!IMPORTANT]\n>\n> Check it.\n')
|
||||
assert.equal(panel('tip'), '> [!TIP]\n>\n> Check it.\n')
|
||||
assert.equal(panel('success'), '> [!TIP]\n>\n> Check it.\n')
|
||||
assert.equal(panel('warning'), '> [!WARNING]\n>\n> Check it.\n')
|
||||
assert.equal(panel('error'), '> [!CAUTION]\n>\n> Check it.\n')
|
||||
assert.equal(panel('custom'), '> [!NOTE]\n>\n> Check it.\n')
|
||||
assert.equal(panel(undefined), '> [!NOTE]\n>\n> Check it.\n')
|
||||
assert.equal(plain(node('panel', { panelType: 'warning' })), '> [!WARNING]\n')
|
||||
})
|
||||
|
||||
test('spells an expand and a nested expand as a folded callout titled by the marker line', () => {
|
||||
const nested = node('nestedExpand', { title: 'Inner' }, said('Deep.'))
|
||||
assert.equal(
|
||||
plain(node('expand', { localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de', title: 'Build log' }, said('Line.'), nested)),
|
||||
'> [!NOTE]- Build log\n>\n> Line.\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n',
|
||||
)
|
||||
assert.equal(plain(node('expand', {}, said('Line.'))), '> [!NOTE]-\n>\n> Line.\n')
|
||||
assert.equal(plain(node('expand', { title: ' *Two*\nlines ' })), '> [!NOTE]- \\*Two\\* lines\n')
|
||||
assert.equal(plain(node('expand', { title: '\ta \t b\t ' })), '> [!NOTE]- a \t b\n')
|
||||
assert.equal(plain(node('expand', { title: '**x** [y](z) ==w==' }, said('b'))), '> [!NOTE]- \\*\\*x\\*\\* \\[y](z) ==w==\n>\n> b\n')
|
||||
})
|
||||
|
||||
test('spells a task list as a bullet list whose items lead with their state', () => {
|
||||
const task = (state: string, value: string): AdfNode => node('taskItem', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', state }, text(value))
|
||||
const nested = node('taskList', {}, task('TODO', 'Review'))
|
||||
assert.equal(plain(node('taskList', {}, task('DONE', 'Write the spec'), nested, task('TODO', 'Ship it'))), '- [x] Write the spec\n - [ ] Review\n- [ ] Ship it\n')
|
||||
assert.equal(plain(node('taskList', {}, nested, task('DONE', ''), said('Stray'))), '- - [ ] Review\n- \\[x]\n- Stray\n')
|
||||
assert.equal(plain(node('taskList', {}, node('taskList', {}), task('DONE', 'a'), said('b'), node('taskList', {}, task('TODO', 'c')))), '- \\[x] a\n- b\n - [ ] c\n')
|
||||
assert.equal(plain(node('taskList', {}, paragraph(), task('DONE', 'a'))), '- [x] a\n')
|
||||
assert.equal(plain(bulletList(item(said('x'))), node('taskList', {}, task('DONE', 'a'), node('taskList', {}, task('TODO', 'c')))), '- x\n- \\[x] a\n - [ ] c\n')
|
||||
assert.equal(plain(node('taskList', {}), task('TODO', 'Loose')), 'Loose\n')
|
||||
const blockTask = node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.'))
|
||||
const codeTask = node('blockTaskItem', { state: 'TODO' }, { content: [text('x')], type: 'codeBlock' })
|
||||
assert.equal(plain(node('taskList', {}, blockTask, codeTask)), '- [x] First.\n\n Second.\n- [ ]\n\n ```\n x\n ```\n')
|
||||
const listTask = node('blockTaskItem', { state: 'DONE' }, bulletList(item(said('a'))))
|
||||
assert.equal(plain(node('taskList', {}, task('TODO', ''), listTask, node('taskList', {}, task('TODO', 'b')))), '- [ ]\n- [x]\n - a\n - \\[ ] b\n')
|
||||
assert.equal(plain(node('taskList', {}, task('DONE', 'a'), node('taskList', {}, task('TODO', '')))), '- [x] a\n - [ ]\n')
|
||||
})
|
||||
|
||||
test('spells a decision list as a plain bullet list', () => {
|
||||
assert.equal(plain(node('decisionList', {}, node('decisionItem', { state: 'DECIDED' }, text('Ship')), said('Stray'))), '- Ship\n- Stray\n')
|
||||
})
|
||||
|
||||
test('spells a highlight as a == pair around the run, whatever its colour', () => {
|
||||
const highlight = (color: string): AdfMark => ({ attrs: { color }, type: 'backgroundColor' })
|
||||
assert.equal(plain(paragraph(text('a '), text('hi', highlight('#fff')), text(' there', highlight('#000')), text(' b'))), 'a ==hi there== b\n')
|
||||
assert.equal(plain(paragraph(text('hi ', strong, highlight('#fff')), text('b'))), '**==hi==** b\n')
|
||||
assert.equal(plain(paragraph(text('a', strong, highlight('#fff')), text('b', highlight('#fff'), em))), '==**a**_b_==\n')
|
||||
assert.equal(plain(paragraph(text('a', highlight('#fff'), code), text('b', highlight('#fff')))), '`a`==b==\n')
|
||||
assert.equal(plain(paragraph(text('=', highlight('#fff')), text(' '), text('a==b', highlight('#fff')))), '==\\=== ==a==b==\n')
|
||||
assert.equal(plain(paragraph(text('x'), text('y', highlight('#fff')), text(' z'))), 'xy z\n')
|
||||
assert.equal(plain(paragraph(text('この機能は'), text('日本語', highlight('#fff')), text('でのみ'))), 'この機能は==日本語==でのみ\n')
|
||||
assert.equal(plain(paragraph(text('サーバー'), text('停止', highlight('#fff')), text('中 iPhone'), text('専用', highlight('#fff')), text('アプリ 기능은 '), text('한국어', highlight('#fff')), text('에서만'))), 'サーバー==停止==中 iPhone==専用==アプリ 기능은 ==한국어==에서만\n')
|
||||
assert.equal(plain(paragraph(text('日==本==語'))), '日\\==本\\==語\n')
|
||||
})
|
||||
|
||||
test('escapes text a renderer would take as a flavour marker, and only there', () => {
|
||||
assert.equal(plain(said('==x== a == b a==b ===')), '\\==x\\== a == b a==b \\=\\==\n')
|
||||
assert.equal(plain({ content: [said('[!NOTE] x'), said('[!TIP]')], type: 'blockquote' }), '> \\[!NOTE] x\n>\n> [!TIP]\n')
|
||||
assert.equal(plain({ content: [said('[!NOTE]x')], type: 'blockquote' }, said('[!NOTE]')), '> [!NOTE]x\n\n[!NOTE]\n')
|
||||
assert.equal(plain(bulletList(item(said('[x] a')), item(said('[ ]')))), '- \\[x] a\n- \\[ ]\n')
|
||||
assert.equal(plain(bulletList(item(said('[x] a')), item(said('b'))), node('orderedList', { order: 1 }, item(said('[x] c')))), '- \\[x] a\n- b\n\n1. \\[x] c\n')
|
||||
const task = (state: string, value: string): AdfNode => node('taskItem', { state }, text(value))
|
||||
assert.equal(plain(node('taskList', {}, task('DONE', '[x] a'), task('TODO', '==b=='))), '- [x] [x] a\n- [ ] \\==b\\==\n')
|
||||
})
|
||||
|
||||
test('unwraps the containers plain markdown has no spelling for to their body blocks in order', () => {
|
||||
const column = (value: string): AdfNode => node('layoutColumn', { width: 50 }, said(value))
|
||||
assert.equal(plain(node('layoutSection', {}, column('Left.'), column('Right.'))), 'Left.\n\nRight.\n')
|
||||
assert.equal(plain(node('bodiedExtension', { extensionKey: 'k' }, said('Body.'))), 'Body.\n')
|
||||
assert.equal(plain(node('bodiedSyncBlock', { resourceId: 'r' }, said('Synced.'))), 'Synced.\n')
|
||||
const frame = (value: string): AdfNode => node('extensionFrame', {}, said(value))
|
||||
assert.equal(plain(node('multiBodiedExtension', { extensionKey: 'k' }, frame('One.'), frame('Two.'))), 'One.\n\nTwo.\n')
|
||||
})
|
||||
|
||||
test('keeps the CommonMark blocks in their spelling and drops their attributes and marks', () => {
|
||||
const localId = { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05' }
|
||||
assert.equal(plain(node('paragraph', localId, text('x')), node('heading', { level: 2, localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de' }, text('h'))), 'x\n\n## h\n')
|
||||
assert.equal(plain({ attrs: localId, content: [said('q')], marks: [{ type: 'breakout' }], type: 'blockquote' }), '> q\n')
|
||||
assert.equal(plain(node('codeBlock', { language: 'ts', wrap: true }, text('a\r\nb\u0000'))), '```ts\na\nb\n```\n')
|
||||
assert.equal(plain(node('codeBlock', { language: 'carry' }, text('a'), { type: 'hardBreak' }, text('b', strong))), '```\na\nb\n```\n')
|
||||
assert.equal(plain(node('codeBlock', {})), '```\n```\n')
|
||||
assert.equal(plain(node('rule', { color: '#000' })), '---\n')
|
||||
assert.equal(plain(node('orderedList', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', order: 3 }, item(said('c')))), '3. c\n')
|
||||
assert.equal(plain(node('orderedList', {}, item(said('a')))), '1. a\n')
|
||||
assert.equal(plain(node('orderedList', { order: -1 }, item(said('a')))), '1. a\n')
|
||||
const code: AdfNode = { content: [text('x')], type: 'codeBlock' }
|
||||
assert.equal(plain(node('orderedList', { order: 1e10 }, item(said('Alpha')), item(code), item())), '- 10000000000. Alpha\n- 10000000001.\n\n ```\n x\n ```\n- 10000000002.\n')
|
||||
const long = node('orderedList', { order: 1e10 }, item(said('y')))
|
||||
assert.equal(plain(bulletList(item(said('x'))), long, bulletList(item(said('z')))), '- x\n- 10000000000. y\n- z\n')
|
||||
assert.equal(plain(node('taskList', {}, node('taskItem', { state: 'DONE' }, text('t'))), long), '- \\[x] t\n- 10000000000. y\n')
|
||||
assert.equal(plain(bulletList(item(said('a'), bulletList(item(said('x'))), long))), '- a\n - x\n - 10000000000. y\n')
|
||||
const givesWay = node('orderedList', { order: 1e10 }, item(node('rule', {})), item(bulletList(item(bulletList(item())))))
|
||||
assert.equal(plain(givesWay, bulletList(item(said('z')))), '- 10000000000.\n- 10000000001.\n - -\n- z\n')
|
||||
assert.equal(plain(node('orderedList', { order: 999999999 }, item(said('a'))), node('orderedList', { order: 5 }, item(said('b')))), '- 999999999\\. a\n- 5\\. b\n')
|
||||
assert.equal(plain(node('heading', { level: 7 }, text('h'))), 'h\n')
|
||||
})
|
||||
|
||||
test('spells an inline node as its text', () => {
|
||||
assert.equal(plain(paragraph(node('mention', { id: 'a', text: '@Mikael' }), text(' and '), node('status', { color: 'red', text: 'Blocked' }))), '@Mikael and Blocked\n')
|
||||
assert.equal(plain(paragraph(node('emoji', { shortName: ':tada:', text: '🎉' }), node('emoji', { shortName: ':smile:' }))), '🎉:smile:\n')
|
||||
assert.equal(plain(paragraph(node('date', { timestamp: '1757721600000' }), text(' '), node('date', { timestamp: 'soon' }))), '2025-09-13\n')
|
||||
assert.equal(plain(paragraph({ marks: [strong], ...node('mention', { text: '@Mikael' }) })), '**@Mikael**\n')
|
||||
assert.equal(plain(paragraph(text('by '), node('mention', { id: '5b10a2' }), node('mention', {}))), 'by @5b10a2\n')
|
||||
})
|
||||
|
||||
test('spells a card as a link to its url, or to its data url named by its data name, else a note', () => {
|
||||
assert.equal(plain(paragraph(node('inlineCard', { url: 'https://example.com' }))), '<https://example.com>\n')
|
||||
assert.equal(plain(paragraph({ ...node('inlineCard', { url: 'https://example.com' }), marks: [strong, link('https://other.com')] })), '**<https://example.com>**\n')
|
||||
assert.equal(plain(paragraph(text('see '), node('inlineCard', { data: {} }))), 'see _(link card not included)_\n')
|
||||
assert.equal(plain(paragraph(node('inlineCard', { data: { name: 'Spec', url: 'https://e.com/s' } }), text(' '), node('inlineCard', { data: { url: 'https://e.com/u' } }))), '[Spec](https://e.com/s) <https://e.com/u>\n')
|
||||
assert.equal(plain(paragraph(node('inlineCard', { data: { name: 'Spec' } }), text(' '), node('inlineCard', { data: ['x'] }))), 'Spec _(link card not included)_\n')
|
||||
assert.equal(plain(node('blockCard', { url: 'https://example.com/a b' })), '[https://example.com/a b](<https://example.com/a b>)\n')
|
||||
assert.equal(plain(node('embedCard', { layout: 'center', url: 'https://example.com' })), '<https://example.com>\n')
|
||||
assert.equal(plain(node('blockCard', { data: {} })), '_(link card not included)_\n')
|
||||
})
|
||||
|
||||
test('keeps an external image wherever it stands and spells a stored file as its alt text, else a note', () => {
|
||||
const media = (attrs: AdfAttributes): AdfNode => ({ attrs, type: 'media' })
|
||||
const caption: AdfNode = node('caption', {}, text('The moon.'))
|
||||
const external = media({ alt: 'Moon', height: 10, type: 'external', url: 'https://example.com/moon.png' })
|
||||
assert.equal(plain(node('mediaSingle', { layout: 'wide', width: 50 }, external, caption)), '\n\nThe moon.\n')
|
||||
assert.equal(plain(node('mediaSingle', {}, media({ alt: '', type: 'external', url: 'https://example.com/a.png' }))), '\n')
|
||||
assert.equal(plain(node('mediaSingle', {}, media({ alt: ' Two\nlines ', type: 'external', url: 'u' }), media({ type: 'external', url: 'v' }))), '\n\n\n')
|
||||
assert.equal(plain(node('mediaSingle', {}, media({ alt: 'Bad', type: 'external', url: 'a\\b <&>' }))), '\n')
|
||||
assert.equal(plain(node('mediaSingle', {}, media({ alt: 'Photo', collection: 'c', id: 'i', type: 'file' }))), 'Photo\n')
|
||||
assert.equal(plain(node('mediaGroup', {}, media({ alt: 'One', type: 'file' }), media({ type: 'file' }), external)), 'One\n\n_(image not included)_\n\n\n')
|
||||
assert.equal(plain(external), '\n')
|
||||
assert.equal(plain(paragraph(text('a '), node('mediaInline', { alt: 'clip', type: 'file' }), text(' '), node('mediaInline', { type: 'file' }))), 'a clip _(image not included)_\n')
|
||||
assert.equal(plain(paragraph(text('See '), external, text(' for '), node('mediaInline', { type: 'external', url: 'https://e.com/i.png' }))), 'See [Moon](https://example.com/moon.png) for <https://e.com/i.png>\n')
|
||||
assert.equal(plain(caption), 'The moon.\n')
|
||||
})
|
||||
|
||||
test('spells an extension as its text attribute, else a note naming it, and a placeholder as nothing', () => {
|
||||
assert.equal(plain(node('extension', { extensionKey: 'toc', text: 'Contents' }), node('extension', { extensionKey: 'jira-issues-table' })), 'Contents\n\n_(jira-issues-table not included)_\n')
|
||||
assert.equal(plain(node('syncBlock', { resourceId: 'r' })), '_(synced block not included)_\n')
|
||||
assert.equal(plain(node('extension', { extensionKey: 'jira\r\nissues\u0000' })), '_(jira issues not included)_\n')
|
||||
assert.equal(plain(node('extension', { extensionKey: '\r\u0000' }), node('extension', { extensionKey: '\n' })), '_(extension not included)_\n\n_(extension not included)_\n')
|
||||
const blank = paragraph(node('inlineExtension', { extensionKey: 'k', text: '\r' }), text(' '), node('mediaInline', { alt: '\u0000', type: 'file' }), text(' '), node('mention', { id: '5b10a2', text: '\r' }))
|
||||
assert.equal(plain(blank), '_(k not included)_ _(image not included)_ @5b10a2\n')
|
||||
assert.equal(plain(paragraph(text('a '), node('inlineExtension', { text: 'macro' }), text(' '), node('inlineExtension', {}), node('placeholder', { text: 'Type here' }))), 'a macro _(extension not included)_\n')
|
||||
})
|
||||
|
||||
test('spells a node no row names, or one standing where no spelling holds it, as its blocks or its text', () => {
|
||||
assert.equal(plain(node('futureBlock', {}, said('Inside.'))), 'Inside.\n')
|
||||
assert.equal(plain(paragraph(text('a '), { content: [text('b')], text: 'c', type: 'futureInline' })), 'a c b\n')
|
||||
assert.equal(plain(text('loose'), node('mention', { text: '@x' }), node('listItem', {}, said('item'))), 'loose@x\n\nitem\n')
|
||||
assert.equal(plain(paragraph(text('a '), node('bulletList', {}, item(said('b')), item(said('c'))))), 'a b c\n')
|
||||
assert.equal(plain(bulletList(said('stray'), item(said('b')), text('loose'))), '- stray\n- b\n- loose\n')
|
||||
assert.equal(plain(bulletList()), '')
|
||||
assert.equal(plain(bulletList(item({ content: [text('a\n \nb')], type: 'codeBlock' }))), '- ```\n a\n\n b\n ```\n')
|
||||
assert.equal(plain(bulletList(item({ content: [text(' ')], type: 'codeBlock' }))), '- ```\n ```\n')
|
||||
assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {}), said('Install')), item(said('Configure')))), '- Install\n- Configure\n')
|
||||
assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {})), item(said('Configure')))), '-\n- Configure\n')
|
||||
assert.equal(plain(bulletList(item(bulletList(item(bulletList(item())))))), '- -\n')
|
||||
assert.equal(plain(node('nestedExpand', {}, node('tableCell', {}, said('c')))), '> [!NOTE]-\n>\n> c\n')
|
||||
})
|
||||
|
||||
test('keeps a table as a pipe table headed by its first row, one line per cell', () => {
|
||||
const table = node(
|
||||
'table',
|
||||
{ layout: 'wide' },
|
||||
row(cell('tableCell', said('Part')), cell('tableCell', said('Qty'))),
|
||||
row(cell('tableHeader', said('Bolt'), bulletList(item(said('M8')))), { attrs: { background: '#fff' }, content: [paragraph(text('4'), { type: 'hardBreak' }, text('0'))], type: 'tableCell' }),
|
||||
)
|
||||
assert.equal(plain(table), '| Part | Qty |\n| --- | --- |\n| Bolt M8 | 4 0 |\n')
|
||||
const spanned = node(
|
||||
'table',
|
||||
{},
|
||||
row(cell('tableHeader', said('A')), cell('tableHeader', said('B')), cell('tableHeader', said('C'))),
|
||||
row(node('tableCell', { colspan: 2, rowspan: 2 }, said('wide')), cell('tableCell', said('c'))),
|
||||
row(cell('tableCell', said('d'))),
|
||||
row(cell('tableCell')),
|
||||
)
|
||||
assert.equal(plain(spanned), '| A | B | C |\n| --- | --- | --- |\n| wide | | c |\n| | | d |\n| | | |\n')
|
||||
const huge = node('table', {}, row(node('tableHeader', { colspan: 1e9, rowspan: 1e9 }, said('A')), cell('tableHeader', said('B'))), row(cell('tableCell', said('c'))))
|
||||
assert.equal(plain(huge), '| A | | | | B |\n| --- | --- | --- | --- | --- |\n| c | | | | |\n')
|
||||
assert.equal(plain(node('table', {}, row(cell('tableHeader', paragraph(text('a|b', code), text(' '), text('x', link('https://e.com/|'))))))), '| a\\|b [x](https://e.com/%7C) |\n| --- |\n')
|
||||
assert.equal(plain(node('table', {}, said('stray'))), '| stray |\n| --- |\n')
|
||||
const titled = paragraph(text('t', link('https://e.com', 'a|b')))
|
||||
const folded = [node('expand', { title: 'Log' }, said('x')), node('nestedExpand', {}, said('y'))]
|
||||
assert.equal(plain(node('table', {}, row(cell('tableHeader', titled), cell('tableHeader', ...folded)))), '| [t](https://e.com) | Log x y |\n| --- | --- |\n')
|
||||
assert.equal(plain(node('table', {}, row())), '')
|
||||
})
|
||||
|
||||
test('keeps code, em, link, strike and strong and drops every other mark, keeping its text', () => {
|
||||
const marks: AdfMark[] = [{ type: 'strike' }, { attrs: { type: 'sub' }, type: 'subsup' }, { type: 'underline' }, { attrs: { color: '#f00' }, type: 'textColor' }]
|
||||
assert.equal(plain(paragraph(text('H'), text('2', ...marks), text('O', em, strong), text('!', { attrs: { size: 1 }, type: 'border' }))), 'H~~2~~_**O**_!\n')
|
||||
assert.equal(plain(paragraph(text('x', code, strong), text(' '), text('y', { attrs: { x: 1 }, type: 'strong' }), text('z', em, em))), '**`x`** **y**_z_\n')
|
||||
assert.equal(plain(paragraph(text('site', { attrs: { collection: 'c', href: 'https://e.com', id: 'i' }, type: 'link' }))), '[site](https://e.com)\n')
|
||||
})
|
||||
|
||||
test('percent-encodes a link href no CommonMark escape writes until one does', () => {
|
||||
assert.equal(plain(paragraph(text('a', link('a\\b')), text(' '), text('b', { attrs: { id: 'i' }, type: 'link' }), text(' '), text('c', link('/&')))), '[a](a%5Cb) b [c](/%26amp;)\n')
|
||||
assert.equal(plain(paragraph(text('t', link('https://e.com', 'two\nlines')), text(' '), text('u', link('https://e.com', 'a\\b')))), '[t](https://e.com "two lines") [u](https://e.com)\n')
|
||||
assert.equal(plain(paragraph(text(']: a', link('/u'), code))), '[\\]: a](/u)\n')
|
||||
})
|
||||
|
||||
test('drops the mark of a run CommonMark flanking or matching cannot spell', () => {
|
||||
assert.equal(plain(paragraph(text('un'), text('-real', strong), text('istic'))), 'un-realistic\n')
|
||||
assert.equal(plain(paragraph(text('a', em), text('b', strong), text('c', em))), '_a_**b**_c_\n')
|
||||
assert.equal(plain(paragraph(text('x'), text('*', em), text('y'))), 'x\\*y\n')
|
||||
const highlight: AdfMark = { type: 'backgroundColor' }
|
||||
assert.equal(plain(paragraph(text('a'), text('b', highlight), text(' c'), text('d', highlight), text(' '), text('e', highlight))), 'ab cd ==e==\n')
|
||||
})
|
||||
|
||||
test('breaks a line at a newline and trims whitespace at every edge CommonMark strips', () => {
|
||||
assert.equal(plain(paragraph(text(' \n a \n b\n'))), 'a\\\nb\n')
|
||||
assert.equal(plain(paragraph({ type: 'hardBreak' }, text('a'), { attrs: { text: '\n' }, type: 'hardBreak' }, text('b'), { type: 'hardBreak' })), 'a\\\nb\n')
|
||||
assert.equal(plain(paragraph(text('a'), text(' b ', strong), text('c'))), 'a **b** c\n')
|
||||
assert.equal(plain(paragraph(text(' '), text('b', strong), text(' \n'), text('c', strong), text(' '))), '**b**\\\n**c**\n')
|
||||
assert.equal(plain(paragraph(text('a'), text(' b ', em, strong), text(' ', em), text('c', em))), 'a _**b** c_\n')
|
||||
assert.equal(plain(paragraph(text(' x ', code))), '` x `\n')
|
||||
assert.equal(plain(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n')
|
||||
assert.equal(plain(paragraph(text('a\r\u0000b'))), 'ab\n')
|
||||
})
|
||||
|
||||
test('drops the code mark of a span opening a line with backticks that read as a fence', () => {
|
||||
assert.equal(plain(paragraph(text('``` x', code))), '\\`\\`\\` x\n')
|
||||
assert.equal(plain(paragraph(text('a\n'), text('``` x', code))), 'a\\\n\\`\\`\\` x\n')
|
||||
assert.equal(plain(paragraph(text('a '), text('``` x', code))), 'a ```` ``` x ````\n')
|
||||
})
|
||||
|
||||
test('drops an empty paragraph and merges adjacent lists of one type', () => {
|
||||
const ordered = (order: number, value: string): AdfNode => node('orderedList', { order }, item(said(value)))
|
||||
assert.equal(plain(said('a'), paragraph(), paragraph(text(' ')), said('b')), 'a\n\nb\n')
|
||||
assert.equal(plain(bulletList(item(said('a'))), paragraph(), node('decisionList', {}, node('decisionItem', {}, text('b')))), '- a\n- b\n')
|
||||
assert.equal(plain(ordered(2, 'a'), ordered(3, 'b'), bulletList(item(said('c')))), '2. a\n3. b\n\n- c\n')
|
||||
assert.equal(plain(bulletList(item(said('x'))), ordered(1, 'a'), ordered(5, 'b'), ordered(6, 'c')), '- x\n- 1\\. a\n- 5\\. b\n\n6. c\n')
|
||||
assert.equal(plain(ordered(1, 'a'), ordered(1e10, 'y')), '- 1\\. a\n- 10000000000. y\n')
|
||||
assert.equal(plain(node('taskList', {}, ordered(1e10, 'y')), bulletList(ordered(1e10, 'z'))), '- - 10000000000. y\n- - 10000000000. z\n')
|
||||
const column = (list: AdfNode): AdfNode => node('layoutColumn', {}, list)
|
||||
assert.equal(plain(node('layoutSection', {}, column(bulletList(item(said('a')))), column(bulletList(item(said('b')))))), '- a\n- b\n')
|
||||
})
|
||||
|
||||
test('keeps the nodes the plain flavour spells and degrades only what it cannot', () => {
|
||||
const tasks = node('taskList', {}, node('taskItem', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', state: 'DONE' }, text('t')))
|
||||
const reduced = reduceToPlain(document(node('panel', { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType: 'info' }, said('x')), tasks))
|
||||
assert.deepEqual(reduced.ok ? reduced.value : undefined, document(node('panel', { panelType: 'info' }, said('x')), { content: [node('taskItem', { state: 'DONE' }, text('t'))], type: 'taskList' }))
|
||||
})
|
||||
@@ -0,0 +1,402 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from './adf-to-markdown.ts'
|
||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './plain-inline.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { taskMarker } from '../plain-conventions.ts'
|
||||
|
||||
// depth: the level the node reduced stands at, counted as the emitter counts it.
|
||||
type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
|
||||
|
||||
type BlockReducer = (node: AdfNode, reduction: Reduction) => Result<AdfNode[]>
|
||||
|
||||
type PlacedCell = { colspan: number; paragraph: AdfNode; rowspan: number }
|
||||
|
||||
type Placed = { index: number; loose: AdfNode[] } | { index: number; loose?: undefined; node: AdfNode }
|
||||
|
||||
const blockReducers: Readonly<Record<string, BlockReducer>> = {
|
||||
blockCard: paragraphOfNode,
|
||||
blockquote: (node, reduction) => contained({ type: 'blockquote' }, node, reduction),
|
||||
bulletList: reduceList,
|
||||
caption: (node, reduction) => paragraphOf(nodeContent(node), reduction),
|
||||
codeBlock: reduceCodeBlock,
|
||||
decisionList: (node, reduction) => reduceItems(node, reduction, (child, at) => (child.type === 'decisionItem' ? paragraphOf(nodeContent(child), at) : reduceStanding(child, at))),
|
||||
embedCard: paragraphOfNode,
|
||||
expand: reduceExpand,
|
||||
extension: paragraphOfNode,
|
||||
heading: reduceHeading,
|
||||
media: reduceMedia,
|
||||
mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index) => reduceStanding(child, childReduction(reduction, index)))),
|
||||
nestedExpand: reduceExpand,
|
||||
orderedList: reduceList,
|
||||
panel: reducePanel,
|
||||
paragraph: (node, reduction) => paragraphOf(nodeContent(node), reduction),
|
||||
rule: () => success([{ type: 'rule' }]),
|
||||
syncBlock: paragraphOfNode,
|
||||
table: reduceTable,
|
||||
taskList: reduceTaskList,
|
||||
}
|
||||
|
||||
export function adfToPlainMarkdown(document: AdfDocument): Result<string> {
|
||||
const reduced = reduceToPlain(document)
|
||||
return reduced.ok ? writeMarkdown(reduced.value, 'plain') : reduced
|
||||
}
|
||||
|
||||
export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
|
||||
const fault = adfDocumentFault(document)
|
||||
if (fault !== undefined) return faulted(fault, [])
|
||||
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
||||
const blocks = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
|
||||
return blocks.ok ? success({ content: blocks.value, type: 'doc', version: 1 }) : blocks
|
||||
}
|
||||
|
||||
function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
|
||||
const placed: Placed[] = []
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
const previous = placed[placed.length - 1]
|
||||
if (!standsInline(node)) placed.push({ index, node })
|
||||
else if (previous?.loose !== undefined) previous.loose.push(node)
|
||||
else placed.push({ index, loose: [node] })
|
||||
}
|
||||
const blocks = concatenated(
|
||||
placed.map((entry) => {
|
||||
const at = { ...reduction, path: [...reduction.path, 'content', entry.index] }
|
||||
return entry.loose === undefined ? reduceNode(entry.node, at) : paragraphOf(entry.loose, reduction)
|
||||
}),
|
||||
)
|
||||
return blocks.ok ? plainSequence(blocks.value, reduction) : blocks
|
||||
}
|
||||
|
||||
function reduceNode(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
if (reduction.depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, reduction.path)
|
||||
const reducer = Object.hasOwn(blockReducers, node.type) ? blockReducers[node.type] : undefined
|
||||
return (reducer ?? reduceBody)(node, reduction)
|
||||
}
|
||||
|
||||
function reduceStanding(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const blocks = standsInline(node) ? paragraphOf([node], reduction) : reduceNode(node, reduction)
|
||||
return blocks.ok ? plainSequence(blocks.value, reduction) : blocks
|
||||
}
|
||||
|
||||
function standsInline(node: AdfNode): boolean {
|
||||
if (node.type === 'text' || inlineNodeModel(node.type) !== undefined) return true
|
||||
return !isBlockNodeType(node.type) && node.content === undefined
|
||||
}
|
||||
|
||||
function reduceBody(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
if (blockNodeModel(node.type)?.contentModel === 'inline') return paragraphOf(nodeContent(node), reduction)
|
||||
return reduceBlocks(nodeContent(node), { ...reduction, depth: reduction.depth + 1 })
|
||||
}
|
||||
|
||||
function childReduction(reduction: Reduction, index: number): Reduction {
|
||||
return { ...reduction, depth: reduction.depth + 1, path: [...reduction.path, 'content', index] }
|
||||
}
|
||||
|
||||
function concatenated(results: readonly Result<AdfNode[]>[]): Result<AdfNode[]> {
|
||||
const blocks: AdfNode[] = []
|
||||
for (const result of results) {
|
||||
if (!result.ok) return result
|
||||
for (const block of result.value) blocks.push(block)
|
||||
}
|
||||
return success(blocks)
|
||||
}
|
||||
|
||||
// A list still taking the directive form gives way to its items' blocks.
|
||||
function plainSequence(blocks: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
|
||||
let sequence = mergedLists(blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0))
|
||||
for (let index = 0; index < sequence.length; index += 1) {
|
||||
const listed = sequence[index]
|
||||
if (listed === undefined || (listed.type !== 'bulletList' && listed.type !== 'orderedList')) continue
|
||||
const block = numberedPastMarkers(listed)
|
||||
const spelled = block === listed && commonMarkSpelling(block, reduction.path, reduction.depth, { flavour: 'plain', memo: reduction.memo })?.ok === true
|
||||
if (spelled) continue
|
||||
sequence = spliced(sequence, index, block === listed ? nodeContent(block).flatMap(nodeContent) : [block])
|
||||
index = Math.max(0, index - 1) - 1
|
||||
}
|
||||
return success(sequence)
|
||||
}
|
||||
|
||||
// The replacement merges with the lists beside it, so no two lists of one type stand adjacent.
|
||||
function spliced(sequence: readonly AdfNode[], index: number, replacement: readonly AdfNode[]): AdfNode[] {
|
||||
const from = Math.max(0, index - 1)
|
||||
return [...sequence.slice(0, from), ...mergedLists([...sequence.slice(from, index), ...replacement, ...sequence.slice(index + 1, index + 2)]), ...sequence.slice(index + 2)]
|
||||
}
|
||||
|
||||
// A numbered list whose markers run past CommonMark's keeps its numbers as text in a bullet list.
|
||||
function numberedPastMarkers(list: AdfNode): AdfNode {
|
||||
const order = nodeAttrs(list)['order']
|
||||
if (list.type !== 'orderedList' || typeof order !== 'number' || order + nodeContent(list).length - 1 <= largestListMarker) return list
|
||||
return numberedAsText(list)
|
||||
}
|
||||
|
||||
function numberedAsText(list: AdfNode): AdfNode {
|
||||
const order = Number(nodeAttrs(list)['order'])
|
||||
return { content: nodeContent(list).map((item, offset) => itemOf(marked(nodeContent(item), `${order + offset}.`))), type: 'bulletList' }
|
||||
}
|
||||
|
||||
// Adjacent lists of one marker read back as one list.
|
||||
function mergedLists(blocks: readonly AdfNode[]): AdfNode[] {
|
||||
const merged: AdfNode[] = []
|
||||
for (const block of blocks) {
|
||||
let next = block
|
||||
for (let previous = merged.at(-1); previous !== undefined && listMarker(next) !== undefined && listMarker(previous) === listMarker(next); previous = merged.at(-1)) {
|
||||
merged.pop()
|
||||
next = joinedLists(previous, next)
|
||||
}
|
||||
merged.push(next)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function listMarker(block: AdfNode): string | undefined {
|
||||
if (block.type === 'orderedList') return '.'
|
||||
return block.type === 'bulletList' || block.type === 'taskList' ? '-' : undefined
|
||||
}
|
||||
|
||||
// Two numbered lists whose numbering breaks between them keep their numbers as text in one bullet list, and a task list joining a bullet list its markers.
|
||||
function joinedLists(first: AdfNode, second: AdfNode): AdfNode {
|
||||
const breaks = first.type === 'orderedList' && nodeAttrs(second)['order'] !== Number(nodeAttrs(first)['order']) + nodeContent(first).length
|
||||
const [head, tail] = breaks ? [numberedAsText(first), numberedAsText(second)] : first.type === second.type ? [first, second] : [tasksAsText(first), tasksAsText(second)]
|
||||
return { ...head, content: [...nodeContent(head), ...nodeContent(tail)] }
|
||||
}
|
||||
|
||||
// A task keeps its marker as text; a list item stands as one, and anything else nests in the item before it.
|
||||
function tasksAsText(list: AdfNode): AdfNode {
|
||||
if (list.type !== 'taskList') return list
|
||||
const items: AdfNode[] = []
|
||||
for (const child of nodeContent(list)) {
|
||||
const previous = isTask(child) || child.type === 'listItem' ? undefined : items.pop()
|
||||
items.push(itemOf(previous === undefined ? taskAsText(child) : mergedLists([...nodeContent(previous), child])))
|
||||
}
|
||||
return { content: items, type: 'bulletList' }
|
||||
}
|
||||
|
||||
function taskAsText(child: AdfNode): readonly AdfNode[] {
|
||||
const marker = taskMarker(nodeAttrs(child)['state'])
|
||||
if (child.type === 'taskItem') return [paragraph(nodeContent(child).length === 0 ? [text(marker)] : [text(`${marker} `), ...nodeContent(child)])]
|
||||
if (child.type === 'blockTaskItem') return marked(nodeContent(child), marker)
|
||||
return child.type === 'listItem' ? nodeContent(child) : [child]
|
||||
}
|
||||
|
||||
function paragraph(content: readonly AdfNode[]): AdfNode {
|
||||
return { content: [...content], type: 'paragraph' }
|
||||
}
|
||||
|
||||
function text(value: string): AdfNode {
|
||||
return { text: value, type: 'text' }
|
||||
}
|
||||
|
||||
function listOf(items: readonly AdfNode[], type: string): AdfNode[] {
|
||||
return items.length === 0 ? [] : [{ content: [...items], type }]
|
||||
}
|
||||
|
||||
function paragraphOf(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
|
||||
const content = reduceInline(nodes, 'paragraph', reduction.path, reduction.depth)
|
||||
return content.ok ? success(content.value.length === 0 ? [] : [paragraph(content.value)]) : content
|
||||
}
|
||||
|
||||
function paragraphOfNode(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
return paragraphOf([node], reduction)
|
||||
}
|
||||
|
||||
function contained(shell: AdfNode, node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const content = reduceBlocks(nodeContent(node), { ...reduction, depth: reduction.depth + 1 })
|
||||
return content.ok ? success([{ ...shell, content: content.value }]) : content
|
||||
}
|
||||
|
||||
function reducePanel(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const panelType = nodeAttrs(node)['panelType']
|
||||
return contained(typeof panelType === 'string' ? { attrs: { panelType }, type: 'panel' } : { type: 'panel' }, node, reduction)
|
||||
}
|
||||
|
||||
function reduceExpand(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const held = nodeAttrs(node)['title']
|
||||
const title = typeof held === 'string' ? withoutTrailingBlanks(oneLine(held).replace(/^[ \t]+/, '')) : ''
|
||||
return contained(title === '' ? { type: node.type } : { attrs: { title }, type: node.type }, node, reduction)
|
||||
}
|
||||
|
||||
// A backward scan: an unanchored-end regex retries from every blank in a long run.
|
||||
function withoutTrailingBlanks(text: string): string {
|
||||
let end = text.length
|
||||
while (end > 0 && (text.charAt(end - 1) === ' ' || text.charAt(end - 1) === '\t')) end -= 1
|
||||
return text.slice(0, end)
|
||||
}
|
||||
|
||||
function reduceHeading(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const level = nodeAttrs(node)['level']
|
||||
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return paragraphOf(nodeContent(node), reduction)
|
||||
const content = reduceInline(nodeContent(node), 'heading', reduction.path, reduction.depth)
|
||||
return content.ok ? success([{ attrs: { level }, content: content.value, type: 'heading' }]) : content
|
||||
}
|
||||
|
||||
function reduceCodeBlock(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const leaves = inlineLeaves(nodeContent(node), 'paragraph', reduction.path, reduction.depth)
|
||||
if (!leaves.ok) return leaves
|
||||
const code = leaves.value.map((leaf) => leaf.text ?? '\n').join('')
|
||||
const slot = languageSlot(nodeAttrs(node)['language'])
|
||||
const block: AdfNode = { content: code === '' ? [] : [text(code)], type: 'codeBlock' }
|
||||
return success([slot.kind === 'fence' ? { ...block, attrs: { language: slot.info } } : block])
|
||||
}
|
||||
|
||||
function reduceList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const listed = reduceItems(node, reduction, (child, at) => (child.type === 'listItem' ? reduceBlocks(nodeContent(child), at) : reduceStanding(child, at)))
|
||||
if (!listed.ok || node.type !== 'orderedList') return listed
|
||||
const order = nodeAttrs(node)['order']
|
||||
const start = typeof order === 'number' && Number.isInteger(order) && order >= 0 ? order : 1
|
||||
return success(listed.value.map((list) => ({ ...list, attrs: { order: start } })))
|
||||
}
|
||||
|
||||
function reduceItems(node: AdfNode, reduction: Reduction, itemBlocks: (child: AdfNode, at: Reduction) => Result<AdfNode[]>): Result<AdfNode[]> {
|
||||
const items = concatenated(nodeContent(node).map((child, index) => listItem(itemBlocks(child, childReduction(reduction, index)))))
|
||||
return items.ok ? success(listOf(items.value, node.type === 'orderedList' ? 'orderedList' : 'bulletList')) : items
|
||||
}
|
||||
|
||||
function listItem(blocks: Result<AdfNode[]>): Result<AdfNode[]> {
|
||||
return blocks.ok ? success([itemOf(blocks.value)]) : blocks
|
||||
}
|
||||
|
||||
// A list item's first line reads as no rule and holds no line of spaces alone: the rule and the spaces give way.
|
||||
function itemOf(blocks: readonly AdfNode[]): AdfNode {
|
||||
const rules = blocks.findIndex((block) => block.type !== 'rule')
|
||||
return { content: blankedCode(blocks.slice(rules === -1 ? blocks.length : rules)), type: 'listItem' }
|
||||
}
|
||||
|
||||
function blankedCode(blocks: readonly AdfNode[]): AdfNode[] {
|
||||
return blocks.map((block) => (block.type === 'codeBlock' ? { ...block, content: blankedLines(nodeContent(block)) } : block))
|
||||
}
|
||||
|
||||
function blankedLines(code: readonly AdfNode[]): AdfNode[] {
|
||||
const blanked = code.map((leaf) => leaf.text ?? '').join('').replace(/^[ \t]+$/gm, '')
|
||||
return blanked === '' ? [] : [text(blanked)]
|
||||
}
|
||||
|
||||
// A task list opening with a task and holding tasks and task lists alone keeps its spelling, a list nesting in the task before it; any other keeps its markers as text. A child reducing to nothing counts for neither.
|
||||
function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const kept: { blocks: AdfNode[]; child: AdfNode }[] = []
|
||||
for (const [index, child] of nodeContent(node).entries()) {
|
||||
const at = childReduction(reduction, index)
|
||||
const reduced = isTask(child) ? reduceTask(child, at) : reduceStanding(child, at)
|
||||
if (!reduced.ok) return reduced
|
||||
if (reduced.value.length > 0) kept.push({ blocks: reduced.value, child })
|
||||
}
|
||||
const regular = isTask(kept[0]?.child) && kept.every(({ child }) => isTask(child) || child.type === 'taskList')
|
||||
const tasks: AdfNode[] = []
|
||||
let nested: AdfNode[] = []
|
||||
for (const { blocks, child } of kept) {
|
||||
if (!regular) {
|
||||
const standsAlone = !isTask(child) && child.type !== 'taskList'
|
||||
for (const block of standsAlone ? [{ content: blocks, type: 'listItem' }] : blocks) tasks.push(block)
|
||||
continue
|
||||
}
|
||||
if (isTask(child)) {
|
||||
nestIn(tasks, nested)
|
||||
nested = []
|
||||
}
|
||||
for (const block of blocks) (isTask(child) ? tasks : nested).push(block)
|
||||
}
|
||||
nestIn(tasks, nested)
|
||||
if (regular) return success([{ content: tasks, type: 'taskList' }])
|
||||
return success(listOf(nodeContent(tasksAsText({ content: tasks, type: 'taskList' })), 'bulletList'))
|
||||
}
|
||||
|
||||
// The writer nests a list in the task before it, so one closing a block task item's blocks merges with it.
|
||||
function nestIn(tasks: AdfNode[], nested: readonly AdfNode[]): void {
|
||||
const previous = tasks.at(-1)
|
||||
if (previous?.type === 'blockTaskItem') tasks[tasks.length - 1] = { ...previous, content: mergedLists([...nodeContent(previous), ...nested]) }
|
||||
else for (const block of mergedLists(nested)) tasks.push(block)
|
||||
}
|
||||
|
||||
function isTask(node: AdfNode | undefined): boolean {
|
||||
return node?.type === 'taskItem' || node?.type === 'blockTaskItem'
|
||||
}
|
||||
|
||||
function reduceTask(task: AdfNode, at: Reduction): Result<AdfNode[]> {
|
||||
const attrs = { state: nodeAttrs(task)['state'] === 'DONE' ? 'DONE' : 'TODO' }
|
||||
if (task.type === 'taskItem') {
|
||||
const content = reduceInline(nodeContent(task), 'paragraph', at.path, at.depth)
|
||||
return content.ok ? success([{ attrs, content: content.value, type: 'taskItem' }]) : content
|
||||
}
|
||||
const blocks = reduceBlocks(nodeContent(task), at)
|
||||
return blocks.ok ? success([{ attrs, content: blankedCode(blocks.value), type: 'blockTaskItem' }]) : blocks
|
||||
}
|
||||
|
||||
// The marker leads the first paragraph, or stands as one where the blocks open with another.
|
||||
function marked(blocks: readonly AdfNode[], marker: string): AdfNode[] {
|
||||
const [first, ...rest] = blocks
|
||||
if (first?.type === 'paragraph') return [paragraph([text(`${marker} `), ...nodeContent(first)]), ...rest]
|
||||
return [paragraph([text(marker)]), ...blocks]
|
||||
}
|
||||
|
||||
function reduceTable(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const rows: PlacedCell[][] = []
|
||||
for (const [rowIndex, row] of nodeContent(node).entries()) {
|
||||
const rowReduction = childReduction(reduction, rowIndex)
|
||||
const cells: PlacedCell[] = []
|
||||
for (const [cellIndex, cell] of (row.type === 'tableRow' ? nodeContent(row) : [row]).entries()) {
|
||||
const paragraph = cellParagraph(cell, childReduction(rowReduction, cellIndex))
|
||||
if (!paragraph.ok) return paragraph
|
||||
cells.push({ colspan: span(nodeAttrs(cell)['colspan']), paragraph: paragraph.value, rowspan: span(nodeAttrs(cell)['rowspan']) })
|
||||
}
|
||||
rows.push(cells)
|
||||
}
|
||||
const grid = spannedGrid(rows)
|
||||
const width = grid.reduce((widest, cells) => Math.max(widest, cells.length), 0)
|
||||
const tableRows = grid.map((cells, rowIndex) => ({
|
||||
content: Array.from({ length: width }, (_, column): AdfNode => ({ content: [cells[column] ?? { type: 'paragraph' }], type: rowIndex === 0 ? 'tableHeader' : 'tableCell' })),
|
||||
type: 'tableRow',
|
||||
}))
|
||||
return success(width === 0 ? [] : [{ content: tableRows, type: 'table' }])
|
||||
}
|
||||
|
||||
function span(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 1 ? value : 1
|
||||
}
|
||||
|
||||
// A span keeps its cell under its header by empty cells where it covered; they number no more than the table's cells.
|
||||
function spannedGrid(rows: readonly PlacedCell[][]): (AdfNode | undefined)[][] {
|
||||
const grid: (AdfNode | undefined)[][] = rows.map(() => [])
|
||||
const covered = rows.map(() => new Set<number>())
|
||||
let padding = rows.reduce((count, cells) => count + cells.length, 0)
|
||||
for (const [rowIndex, cells] of rows.entries()) {
|
||||
let column = 0
|
||||
for (const cell of cells) {
|
||||
while (covered[rowIndex]?.has(column) === true) column += 1
|
||||
setCell(grid, rowIndex, column, cell.paragraph)
|
||||
for (let row = rowIndex; row < Math.min(rows.length, rowIndex + cell.rowspan) && padding > 0; row += 1) {
|
||||
for (let spanned = row === rowIndex ? 1 : 0; spanned < cell.colspan && padding > 0; spanned += 1) {
|
||||
covered[row]?.add(column + spanned)
|
||||
setCell(grid, row, column + spanned, { type: 'paragraph' })
|
||||
padding -= 1
|
||||
}
|
||||
}
|
||||
column += 1
|
||||
}
|
||||
}
|
||||
return grid
|
||||
}
|
||||
|
||||
function setCell(grid: (AdfNode | undefined)[][], row: number, column: number, cell: AdfNode): void {
|
||||
const cells = grid[row]
|
||||
if (cells !== undefined && cells[column] === undefined) cells[column] = cell
|
||||
}
|
||||
|
||||
function cellParagraph(cell: AdfNode, reduction: Reduction): Result<AdfNode> {
|
||||
const blocks = cell.type === 'tableCell' || cell.type === 'tableHeader' ? nodeContent(cell) : [cell]
|
||||
const content = reduceInline(blocks, 'table-cell', reduction.path, reduction.depth)
|
||||
return content.ok ? success(content.value.length === 0 ? { type: 'paragraph' } : paragraph(content.value)) : content
|
||||
}
|
||||
|
||||
function reduceMedia(media: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||
const attrs = nodeAttrs(media)
|
||||
const url = attrs['url']
|
||||
if (attrs['type'] !== 'external' || typeof url !== 'string') return paragraphOfNode(media, reduction)
|
||||
const held = attrs['alt']
|
||||
const alt = typeof held === 'string' ? oneLine(held).trim() : ''
|
||||
const external: AdfNode = { attrs: alt === '' ? { type: 'external', url: writableHref(url) } : { alt, type: 'external', url: writableHref(url) }, type: 'media' }
|
||||
const image: AdfNode = { attrs: { layout: 'center' }, content: [external], type: 'mediaSingle' }
|
||||
return success([image])
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type LineContainer = 'heading' | 'paragraph' | 'table-cell'
|
||||
@@ -1,5 +0,0 @@
|
||||
import { spellDirectiveOpener } from './directive-syntax.ts'
|
||||
|
||||
export const listBreakName = 'listBreak'
|
||||
|
||||
export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type ThematicBreakTail,
|
||||
} from '../commonmark/grammar.ts'
|
||||
import { barePipeCells, isDelimiterRow, isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
|
||||
import { blockDirectiveForm } from '../block-directive-forms.ts'
|
||||
import { blockDirectiveForm } from '../block-directive.ts'
|
||||
import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts'
|
||||
import { readLinkDefinitions } from '../commonmark/link-reference-definitions.ts'
|
||||
|
||||
@@ -43,7 +43,7 @@ export type DirectiveBlock = Extract<Block, { kind: 'directive' }>
|
||||
|
||||
type ListBlock = Extract<Block, { items: Block[][] }>
|
||||
|
||||
type OpenDirective = { blocks: Block[]; depths: number[]; index: number; kind: 'directive'; name: string; parent: Block[]; position: SourcePosition }
|
||||
type OpenDirective = { blocks: Block[]; index: number; kind: 'directive'; name: string; parent: Block[]; position: SourcePosition }
|
||||
|
||||
type EdgeContainer = Extract<Block, { kind: 'blockquote' }> | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock }
|
||||
|
||||
@@ -64,13 +64,19 @@ type Line = { column: number; text: string }
|
||||
|
||||
type LeafOpener = { index: number; position: SourcePosition }
|
||||
|
||||
type ContainerStack = {
|
||||
directiveDepth: (name: string) => number | undefined
|
||||
drop: (depth: number) => OpenContainer[]
|
||||
edges: readonly { container: EdgeContainer; depth: number }[]
|
||||
open: readonly OpenContainer[]
|
||||
push: (container: OpenContainer) => void
|
||||
}
|
||||
|
||||
type Walk = ParsedBlocks & {
|
||||
directiveDepths: Map<string, number[]>
|
||||
edges: { container: EdgeContainer; depth: number }[]
|
||||
leaf: OpenLeaf | undefined
|
||||
leafOpeners: Map<Block[], Map<string, LeafOpener>>
|
||||
position: SourcePosition
|
||||
stack: OpenContainer[]
|
||||
stack: ContainerStack
|
||||
}
|
||||
|
||||
const indentedCodeColumns = 4
|
||||
@@ -81,12 +87,10 @@ export function parseBlocks(markdown: string): ParsedBlocks {
|
||||
const walk: Walk = {
|
||||
blocks: [],
|
||||
definitions: new Map(),
|
||||
directiveDepths: new Map(),
|
||||
edges: [],
|
||||
leaf: undefined,
|
||||
leafOpeners: new Map(),
|
||||
position: { line: 1, offset: 0 },
|
||||
stack: [],
|
||||
stack: containerStack(),
|
||||
}
|
||||
for (const line of sourceLines(markdown)) {
|
||||
walk.position = line.position
|
||||
@@ -96,16 +100,41 @@ export function parseBlocks(markdown: string): ParsedBlocks {
|
||||
return { blocks: walk.blocks, definitions: walk.definitions }
|
||||
}
|
||||
|
||||
function containerStack(): ContainerStack {
|
||||
const directiveDepths = new Map<string, number[]>()
|
||||
const depthsOf = (name: string): number[] => entryOf(directiveDepths, name, () => [])
|
||||
const edges: { container: EdgeContainer; depth: number }[] = []
|
||||
const open: OpenContainer[] = []
|
||||
return {
|
||||
directiveDepth: (name) => directiveDepths.get(name)?.at(-1),
|
||||
drop: (depth) => {
|
||||
const dropped = open.splice(depth)
|
||||
for (const container of dropped) {
|
||||
if (container.kind === 'directive') depthsOf(container.name).pop()
|
||||
else edges.pop()
|
||||
}
|
||||
return dropped
|
||||
},
|
||||
edges,
|
||||
open,
|
||||
push: (container) => {
|
||||
const depth = open.push(container) - 1
|
||||
if (container.kind === 'directive') depthsOf(container.name).push(depth)
|
||||
else edges.push({ container, depth })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function readLine(walk: Walk, line: Line): void {
|
||||
const matched = matchContainers(walk, line)
|
||||
// CommonMark: no container opens inside an open code or HTML block.
|
||||
if (matched.depth === walk.stack.length && swallowsLines(walk.leaf)) {
|
||||
if (matched.depth === walk.stack.open.length && swallowsLines(walk.leaf)) {
|
||||
readBlockLine(walk, matched.rest)
|
||||
return
|
||||
}
|
||||
const paragraphOpen = matched.depth === walk.stack.length && walk.leaf?.kind === 'paragraph'
|
||||
const paragraphOpen = matched.depth === walk.stack.open.length && walk.leaf?.kind === 'paragraph'
|
||||
const opened = openContainers(walk, matched.rest, paragraphOpen, matched.depth)
|
||||
if (!opened.opened && matched.depth < walk.stack.length) {
|
||||
if (!opened.opened && matched.depth < walk.stack.open.length) {
|
||||
if (continuesLazily(walk, opened.rest)) {
|
||||
appendParagraph(walk, opened.rest.text)
|
||||
return
|
||||
@@ -122,12 +151,12 @@ function swallowsLines(leaf: OpenLeaf | undefined): boolean {
|
||||
// A directive container has no continuation marker, so every line continues it.
|
||||
function matchContainers(walk: Walk, line: Line): { depth: number; rest: Line } {
|
||||
let rest = line
|
||||
for (const { container, depth } of walk.edges) {
|
||||
for (const { container, depth } of walk.stack.edges) {
|
||||
const next = continuesContainer(walk, container, rest)
|
||||
if (next === undefined) return { depth, rest }
|
||||
rest = next
|
||||
}
|
||||
return { depth: walk.stack.length, rest }
|
||||
return { depth: walk.stack.open.length, rest }
|
||||
}
|
||||
|
||||
function continuesContainer(walk: Walk, container: EdgeContainer, line: Line): Line | undefined {
|
||||
@@ -145,7 +174,7 @@ function blockquoteRest(opener: Line): Line | undefined {
|
||||
}
|
||||
|
||||
function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: number): { opened: boolean; rest: Line } {
|
||||
const unmatched = walk.stack[depth]
|
||||
const unmatched = walk.stack.open[depth]
|
||||
const tail = thematicBreakTail(line.text)
|
||||
let opened = false
|
||||
let rest = line
|
||||
@@ -202,16 +231,12 @@ function openContainer(walk: Walk, start: ContainerStart): void {
|
||||
if (start.kind === 'blockquote') {
|
||||
const blockquote: EdgeContainer = { blocks, kind: 'blockquote', position: walk.position }
|
||||
currentBlocks(walk).push(blockquote)
|
||||
pushEdge(walk, blockquote)
|
||||
walk.stack.push(blockquote)
|
||||
return
|
||||
}
|
||||
const list = openedList(walk, start)
|
||||
list.items.push(blocks)
|
||||
pushEdge(walk, { blocks, indentation: start.indentation, kind: 'item', list })
|
||||
}
|
||||
|
||||
function pushEdge(walk: Walk, container: EdgeContainer): void {
|
||||
walk.edges.push({ container, depth: walk.stack.push(container) - 1 })
|
||||
walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list })
|
||||
}
|
||||
|
||||
// Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md).
|
||||
@@ -226,7 +251,7 @@ function openedList(walk: Walk, start: Extract<ContainerStart, { kind: 'item' }>
|
||||
|
||||
function closeContainers(walk: Walk, depth: number): void {
|
||||
closeLeaf(walk)
|
||||
for (const container of dropContainers(walk, depth)) {
|
||||
for (const container of walk.stack.drop(depth)) {
|
||||
if (container.kind !== 'directive') continue
|
||||
container.parent[container.index] = {
|
||||
fault: malformedDirective(`the ${container.name} container is unclosed: no ${spellDirectiveCloser(container.name)} follows inside the block holding it; ${directiveEscape}`),
|
||||
@@ -236,15 +261,6 @@ function closeContainers(walk: Walk, depth: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function dropContainers(walk: Walk, depth: number): OpenContainer[] {
|
||||
const dropped = walk.stack.splice(depth)
|
||||
for (const container of dropped) {
|
||||
if (container.kind === 'directive') container.depths.pop()
|
||||
else walk.edges.pop()
|
||||
}
|
||||
return dropped
|
||||
}
|
||||
|
||||
function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void {
|
||||
if (directive.kind === 'closer') closeDirective(walk, directive.name)
|
||||
else openDirective(walk, directive)
|
||||
@@ -267,8 +283,7 @@ function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'op
|
||||
entryOf(walk.leafOpeners, parent, () => new Map<string, LeafOpener>()).set(name, { index, position })
|
||||
return
|
||||
}
|
||||
const depths = entryOf(walk.directiveDepths, name, (): number[] => [])
|
||||
depths.push(walk.stack.push({ blocks: block.blocks, depths, index, kind: 'directive', name, parent, position }) - 1)
|
||||
walk.stack.push({ blocks: block.blocks, index, kind: 'directive', name, parent, position })
|
||||
}
|
||||
|
||||
function closeDirective(walk: Walk, name: string): void {
|
||||
@@ -283,13 +298,13 @@ function closeDirective(walk: Walk, name: string): void {
|
||||
return
|
||||
}
|
||||
closeContainers(walk, depth + 1)
|
||||
dropContainers(walk, depth)
|
||||
walk.stack.drop(depth)
|
||||
}
|
||||
|
||||
// A closer crosses no list item or blockquote edge.
|
||||
function openDirectiveDepth(walk: Walk, name: string): number | undefined {
|
||||
const depth = walk.directiveDepths.get(name)?.at(-1)
|
||||
return depth === undefined || depth < (walk.edges.at(-1)?.depth ?? -1) ? undefined : depth
|
||||
const depth = walk.stack.directiveDepth(name)
|
||||
return depth === undefined || depth < (walk.stack.edges.at(-1)?.depth ?? -1) ? undefined : depth
|
||||
}
|
||||
|
||||
function faultLeafOpener(walk: Walk, name: string, fault: ConvertFault): void {
|
||||
@@ -497,7 +512,7 @@ function takeParagraph(walk: Walk): Extract<Block, { kind: 'paragraph' }> | unde
|
||||
}
|
||||
|
||||
function currentBlocks(walk: Walk): Block[] {
|
||||
return walk.stack.at(-1)?.blocks ?? walk.blocks
|
||||
return walk.stack.open.at(-1)?.blocks ?? walk.blocks
|
||||
}
|
||||
|
||||
function* sourceLines(markdown: string): Generator<{ position: SourcePosition; text: string }> {
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { BlockDirective } from '../../adf/block-directives.ts'
|
||||
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import type { ConvertFault } from '../../result.ts'
|
||||
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
|
||||
import type { Elsewhere } from './directive-attributes.ts'
|
||||
import { attributeNestingMessage, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
|
||||
import { blockArgument } from '../block-directive-arguments.ts'
|
||||
import { blockDirective } from '../../adf/block-directives.ts'
|
||||
import { blockDirectiveForm } from '../block-directive-forms.ts'
|
||||
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts'
|
||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { carryName } from '../opaque-carry.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { inlineDirective } from '../../adf/inline-directives.ts'
|
||||
import { inlineMarkSpellingFault } from './directive-marks.ts'
|
||||
import { marksAttribute, readMarkValues } from '../block-directive-marks.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { readVocabulary } from './directive-attributes.ts'
|
||||
import { slotLineEndingFault } from '../directive-syntax.ts'
|
||||
import { textDirectiveName } from '../text-directive.ts'
|
||||
|
||||
export type BlockDirectiveNode = { contentModel: BlockDirective['contentModel']; node: AdfNode }
|
||||
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; node: AdfNode }
|
||||
|
||||
export function readBlockDirectiveNode(
|
||||
name: string,
|
||||
@@ -28,13 +26,13 @@ export function readBlockDirectiveNode(
|
||||
if (name === carryName) {
|
||||
return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryName} fence`, path)
|
||||
}
|
||||
const directive = blockDirective(name)
|
||||
if (directive === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path)
|
||||
const model = blockNodeModel(name)
|
||||
if (model === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path)
|
||||
const argumentKey = blockArgument(name)
|
||||
const rest = new Map(attributes)
|
||||
rest.delete(marksAttribute)
|
||||
const elsewhere: Elsewhere | undefined = argumentKey === undefined ? undefined : { key: argumentKey, slot: 'argument' }
|
||||
const attrs = readVocabulary(name, rest, directive.attributes, elsewhere, path)
|
||||
const attrs = readVocabulary(name, rest, model.attributes, elsewhere, path)
|
||||
if (!attrs.ok) return attrs
|
||||
if (argument !== undefined) {
|
||||
if (argumentKey === undefined) return failure('unsupported-node-shape', `${name} takes no argument: this one spells one`, path)
|
||||
@@ -43,7 +41,7 @@ export function readBlockDirectiveNode(
|
||||
const spelled = attributes.get(marksAttribute)
|
||||
const marks: Result<AdfMark[] | undefined> = spelled === undefined ? success(undefined) : readMarks(name, spelled, path)
|
||||
if (!marks.ok) return marks
|
||||
return success({ contentModel: directive.contentModel, node: namedNode(name, attrs.value, marks.value) })
|
||||
return success({ contentModel: model.contentModel, node: namedNode(name, attrs.value, marks.value) })
|
||||
}
|
||||
|
||||
export function readInlineDirectiveNode(
|
||||
@@ -52,12 +50,12 @@ export function readInlineDirectiveNode(
|
||||
content: readonly AdfNode[] | undefined,
|
||||
path: ConvertErrorPath,
|
||||
): Result<AdfNode> {
|
||||
const directive = inlineDirective(name)
|
||||
if (directive === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name), path)
|
||||
const slot = directive.textAttribute
|
||||
const model = inlineNodeModel(name)
|
||||
if (model === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name), path)
|
||||
const slot = model.textAttribute
|
||||
if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path)
|
||||
const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' }
|
||||
const attrs = readVocabulary(name, attributes, directive.attributes, elsewhere, path)
|
||||
const attrs = readVocabulary(name, attributes, model.attributes, elsewhere, path)
|
||||
if (!attrs.ok) return attrs
|
||||
if (slot !== undefined && content !== undefined) {
|
||||
const text = slotText(content)
|
||||
@@ -71,11 +69,11 @@ export function readInlineDirectiveNode(
|
||||
return success(namedNode(name, attrs.value, undefined))
|
||||
}
|
||||
|
||||
// A name the other position spells names that spelling, never the code a later MINOR may fill (AGENTS.md §8).
|
||||
// A name the other position spells names that spelling, never the code a later MINOR may fill (docs/decisions.md §Which code a cause takes).
|
||||
function inlineSpellingFault(name: string): ConvertFault | undefined {
|
||||
const mark = inlineMarkSpellingFault(name)
|
||||
if (mark !== undefined) return mark
|
||||
if (inlineDirective(name) === undefined && name !== textDirectiveName) return undefined
|
||||
if (inlineNodeModel(name) === undefined && name !== textDirectiveName) return undefined
|
||||
return { code: 'unsupported-node-shape', message: `${name} takes the inline form, ${directivePrefix}${name}{…}, never the block form` }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts'
|
||||
import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
|
||||
import type { LineContainer } from '../emit/line-escaping.ts'
|
||||
import type { Flavour } from '../plain-conventions.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
|
||||
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||
import { commonMarkLink, linkHref } from '../mark-spellings.ts'
|
||||
import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { inlineDirective } from '../../adf/inline-directives.ts'
|
||||
import { mergeAdjacentText } from '../../adf/editor-normal.ts'
|
||||
import { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { mergeAdjacentText, sameMarks } from '../../adf/editor-normal.ts'
|
||||
import { noSpans, readInlineDirective } from '../directive-syntax.ts'
|
||||
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||
import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts'
|
||||
@@ -25,6 +27,8 @@ export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
|
||||
|
||||
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
|
||||
|
||||
type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; line: number; opens: boolean; position: number }
|
||||
|
||||
type Pairing = EmphasisPairing<Run>
|
||||
|
||||
type Piece =
|
||||
@@ -33,6 +37,7 @@ type Piece =
|
||||
| { alt: string; kind: 'image'; node: AdfNode }
|
||||
| { kind: 'nodes'; nodes: AdfNode[] }
|
||||
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
|
||||
| { closes: boolean; kind: 'highlight'; opens: boolean }
|
||||
|
||||
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number }
|
||||
|
||||
@@ -42,6 +47,7 @@ type Scan = {
|
||||
// Pieces below this have been walked for openers to deactivate: an image close folds the link-marked piece into alt text, leaving this the only record that the brackets around it are doomed.
|
||||
deactivatedBefore: number
|
||||
definitions: LinkDefinitions
|
||||
highlights: boolean
|
||||
openingSpellableLink: boolean
|
||||
path: ConvertErrorPath
|
||||
pending: string
|
||||
@@ -53,29 +59,21 @@ type Scan = {
|
||||
type SlotContent = { carry: boolean; nodes: AdfNode[] }
|
||||
|
||||
const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
|
||||
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
|
||||
const hreflessLink = 'the link mark spells its href: this one spells none'
|
||||
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
|
||||
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one'
|
||||
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
|
||||
|
||||
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> {
|
||||
return parseInline(source, definitions, path, container, noSpans)
|
||||
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
|
||||
return parseInline(source, definitions, path, container, noSpans, flavour === 'plain')
|
||||
}
|
||||
|
||||
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans): Result<InlineContent> {
|
||||
const scan: Scan = { container, deactivatedBefore: 0, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
|
||||
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans, highlights: boolean): Result<InlineContent> {
|
||||
const scan: Scan = { container, deactivatedBefore: 0, definitions, highlights, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
|
||||
let index = 0
|
||||
while (index < source.length) {
|
||||
switch (source.charAt(index)) {
|
||||
case '\\':
|
||||
index = readBackslash(scan, index)
|
||||
break
|
||||
case '\n':
|
||||
index = readLineEnding(scan, index)
|
||||
break
|
||||
case '`':
|
||||
index = readBackticks(scan, index)
|
||||
break
|
||||
case '<': {
|
||||
const angle = readAngle(scan, index)
|
||||
if (!angle.ok) return angle
|
||||
@@ -101,20 +99,34 @@ function parseInline(source: string, definitions: LinkDefinitions, path: Convert
|
||||
index = closed.value
|
||||
break
|
||||
}
|
||||
case '*':
|
||||
case '_':
|
||||
case '~':
|
||||
index = readDelimiterRun(scan, index)
|
||||
break
|
||||
default:
|
||||
scan.pending += source.charAt(index)
|
||||
index += 1
|
||||
index = readCharacter(scan, index)
|
||||
}
|
||||
}
|
||||
flush(scan, container !== undefined)
|
||||
return assemble(scan)
|
||||
}
|
||||
|
||||
function readCharacter(scan: Scan, index: number): number {
|
||||
switch (scan.source.charAt(index)) {
|
||||
case '\\':
|
||||
return readBackslash(scan, index)
|
||||
case '\n':
|
||||
return readLineEnding(scan, index)
|
||||
case '`':
|
||||
return readBackticks(scan, index)
|
||||
case '*':
|
||||
case '_':
|
||||
case '~':
|
||||
return readDelimiterRun(scan, index)
|
||||
case '=':
|
||||
return readEquals(scan, index)
|
||||
default:
|
||||
scan.pending += scan.source.charAt(index)
|
||||
return index + 1
|
||||
}
|
||||
}
|
||||
|
||||
function readBackslash(scan: Scan, index: number): number {
|
||||
if (scan.source.charAt(index + 1) === '\n') {
|
||||
// CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash.
|
||||
@@ -230,7 +242,7 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[
|
||||
|
||||
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
|
||||
if (span.content === undefined) return success(undefined)
|
||||
const parsed = parseInline(span.content, scan.definitions, scan.path, undefined, span.spans)
|
||||
const parsed = parseInline(span.content, scan.definitions, scan.path, undefined, span.spans, false)
|
||||
if (!parsed.ok) return parsed
|
||||
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
|
||||
return success(parsed.value)
|
||||
@@ -250,7 +262,7 @@ function assemble(scan: Scan): Result<InlineContent> {
|
||||
const only = scan.pieces[0]
|
||||
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
|
||||
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
|
||||
const nodes = resolveNodes(scan.pieces, scan.path)
|
||||
const nodes = resolveNodes(scan.pieces, scan.path, true)
|
||||
if (!nodes.ok) return nodes
|
||||
if (scan.openingSpellableLink) {
|
||||
const takesDirective = openingLinkTakesDirective(nodes.value, scan.path)
|
||||
@@ -289,6 +301,16 @@ function readDelimiterRun(scan: Scan, index: number): number {
|
||||
return index + length
|
||||
}
|
||||
|
||||
function readEquals(scan: Scan, index: number): number {
|
||||
if (!scan.highlights || !scan.source.startsWith(highlightDelimiter, index)) {
|
||||
scan.pending += '='
|
||||
return index + 1
|
||||
}
|
||||
flush(scan, false)
|
||||
scan.pieces.push({ ...highlightFlanking(scan.source, index), kind: 'highlight' })
|
||||
return index + highlightDelimiter.length
|
||||
}
|
||||
|
||||
function readAutolink(source: string, index: number): { length: number; node: AdfNode } | undefined {
|
||||
const bracketed = readBracketedAutolink(source, index)
|
||||
if (bracketed !== undefined) return { length: bracketed, node: linkedText(source.slice(index + 1, index + bracketed - 1), '') }
|
||||
@@ -361,7 +383,7 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition:
|
||||
}
|
||||
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
|
||||
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path)
|
||||
const resolved = resolveNodes(inner, scan.path)
|
||||
const resolved = resolveNodes(inner, scan.path, true)
|
||||
if (!resolved.ok) return resolved
|
||||
const nodes = resolved.value
|
||||
// An empty link text gives the mark no node to ride, so the brackets stay text.
|
||||
@@ -399,7 +421,7 @@ function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition:
|
||||
}
|
||||
|
||||
function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<string> {
|
||||
const nodes = resolveNodes(inner, path)
|
||||
const nodes = resolveNodes(inner, path, false)
|
||||
if (!nodes.ok) return nodes
|
||||
return success(nodes.value.map(altText).join(''))
|
||||
}
|
||||
@@ -407,25 +429,30 @@ function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<strin
|
||||
// spec/flavour.md, The CommonMark image: the description's plain text, the content slot included.
|
||||
function altText(node: AdfNode): string {
|
||||
if (node.type === 'hardBreak') return ' '
|
||||
const slot = inlineDirective(node.type)?.textAttribute
|
||||
const slot = inlineNodeModel(node.type)?.textAttribute
|
||||
const spelled = slot === undefined ? undefined : nodeAttrs(node)[slot]
|
||||
return typeof spelled === 'string' ? spelled : (node.text ?? '')
|
||||
}
|
||||
|
||||
function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath): Result<AdfNode[]> {
|
||||
// An image's alt text is plain, so `highlights` is off there and every `==` stays text.
|
||||
function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath, highlights: boolean): Result<AdfNode[]> {
|
||||
const nodes = pieces.map(pieceNodes)
|
||||
const runs = delimiterRuns(pieces)
|
||||
const pairings = matchEmphasis(runs)
|
||||
writeUnpaired(nodes, runs, pairings)
|
||||
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, path)
|
||||
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
|
||||
return success(mergeAdjacentText(nodes.flat()))
|
||||
}
|
||||
|
||||
// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first.
|
||||
// A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it.
|
||||
function pieceNodes(piece: Piece): AdfNode[] {
|
||||
switch (piece.kind) {
|
||||
case 'carry':
|
||||
return [piece.node]
|
||||
case 'highlight':
|
||||
return [{ text: '', type: 'text' }]
|
||||
case 'image':
|
||||
return piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }]
|
||||
case 'nodes':
|
||||
@@ -472,12 +499,69 @@ function markPairings(pieces: readonly Piece[], nodes: AdfNode[][], pairings: re
|
||||
return true
|
||||
}
|
||||
|
||||
function highlightDelimiters(pieces: readonly Piece[], nodes: readonly AdfNode[][]): HighlightDelimiter[] {
|
||||
const found: HighlightDelimiter[] = []
|
||||
let line = 0
|
||||
let position = 0
|
||||
for (const [index, piece] of pieces.entries()) {
|
||||
const held = nodes[index] ?? []
|
||||
const [holder] = held
|
||||
if (piece.kind === 'highlight' && holder !== undefined) {
|
||||
found.push({ closes: piece.closes, holder, index, line, opens: piece.opens, position })
|
||||
position += highlightDelimiter.length
|
||||
continue
|
||||
}
|
||||
for (const node of held) {
|
||||
if (node.type === 'text') position += node.text?.length ?? 0
|
||||
else line += 1
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// Each opener takes the next closer holding at least one character after it, both in one line and under the same marks.
|
||||
function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][]): { closer: number; opener: number }[] {
|
||||
const found = highlightDelimiters(pieces, nodes)
|
||||
const paired: { closer: number; opener: number }[] = []
|
||||
let closer = 0
|
||||
let resume = 0
|
||||
for (const opener of found) {
|
||||
if (!opener.opens || opener.position < resume) continue
|
||||
const earliest = opener.position + highlightDelimiter.length + 1
|
||||
let candidate = found[closer]
|
||||
while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)]
|
||||
if (candidate === undefined) break
|
||||
if (candidate.line !== opener.line || !sameMarks(opener.holder, candidate.holder)) continue
|
||||
paired.push({ closer: candidate.index, opener: opener.index })
|
||||
resume = candidate.position + highlightDelimiter.length
|
||||
}
|
||||
return paired
|
||||
}
|
||||
|
||||
// Atlassian's schema refuses a highlight on code, a node holds one highlight, and a rebuilt node would lose its attributes.
|
||||
function markHighlights(pieces: readonly Piece[], nodes: AdfNode[][], paired: readonly { closer: number; opener: number }[]): void {
|
||||
for (const [index, piece] of pieces.entries()) {
|
||||
if (piece.kind === 'highlight') nodes[index] = (nodes[index] ?? []).map((holder) => ({ ...holder, text: highlightDelimiter }))
|
||||
}
|
||||
for (const { closer, opener } of paired) {
|
||||
nodes[opener] = []
|
||||
nodes[closer] = []
|
||||
for (let index = opener + 1; index < closer; index += 1) nodes[index] = (nodes[index] ?? []).map(highlighted)
|
||||
}
|
||||
}
|
||||
|
||||
function highlighted(node: AdfNode): AdfNode {
|
||||
const marks = nodeMarks(node)
|
||||
if (node.type !== 'text' || Object.keys(nodeAttrs(node)).length > 0 || marks.some((mark) => mark.type === 'code' || mark.type === 'backgroundColor')) return node
|
||||
return { ...node, marks: [editorHighlight, ...marks] }
|
||||
}
|
||||
|
||||
function markType(character: string, used: number): string {
|
||||
if (character === '~') return 'strike'
|
||||
return used === 2 ? 'strong' : 'em'
|
||||
}
|
||||
|
||||
// A node cannot carry one mark type twice (AGENTS.md §14).
|
||||
// A node cannot carry one mark type twice (docs/decisions.md §No schema validation).
|
||||
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
|
||||
return nodes.map((node) => {
|
||||
const marks = nodeMarks(node)
|
||||
|
||||
@@ -2,30 +2,52 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { Block, DirectiveBlock } from './blocks.ts'
|
||||
import type { BlockDirectiveNode } from './directive-nodes.ts'
|
||||
import type { ConvertFault } from '../../result.ts'
|
||||
import type { LineContainer } from '../emit/line-escaping.ts'
|
||||
import type { Flavour } from '../plain-conventions.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import type { LinkDefinitions } from './inline-content.ts'
|
||||
import { carryName, readCarriedBlock } from '../opaque-carry.ts'
|
||||
import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts'
|
||||
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
|
||||
import { inlineLeaves } from '../emit/plain-inline.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { listBreakName, listBreakSpelling } from '../list-break.ts'
|
||||
import { nodeAttrs } from '../../adf/document.ts'
|
||||
import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain-conventions.ts'
|
||||
import { listBreakName, listBreakSpelling } from '../block-directive.ts'
|
||||
import { mintTaskIds } from './task-ids.ts'
|
||||
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
import { parseInlineContent } from './inline-content.ts'
|
||||
import { readBlockDirectiveNode } from './directive-nodes.ts'
|
||||
import { unsupportedNodeShape } from '../directive-syntax.ts'
|
||||
|
||||
type Paragraph = Extract<Block, { kind: 'paragraph' }>
|
||||
|
||||
// `inExpand` is whether an expand holds the blocks, which makes a folded callout a nestedExpand.
|
||||
type Reading = { carried: Set<AdfNode>; definitions: LinkDefinitions; flavour: Flavour; inExpand: boolean; memo: SpellingMemo }
|
||||
|
||||
const documentStart: SourcePosition = { line: 1, offset: 0 }
|
||||
const imageAfterMarker = 'an image fits only as a paragraph of its own: this one continues the paragraph a marker opens, which a blank line before it ends'
|
||||
const imageOnMarkerLine = 'an image fits only as a paragraph of its own: this one shares a line with a marker'
|
||||
|
||||
export function markdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||
const parsed = parseBlocks(markdown)
|
||||
const content = positioned(blockNodes(parsed.blocks, parsed.definitions, [], 0, new Map()), documentStart)
|
||||
if (!content.ok) return content
|
||||
return success(content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 })
|
||||
return readDocument(markdown, 'lossless')
|
||||
}
|
||||
|
||||
function blockNodes(blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result<AdfNode[]> {
|
||||
export function plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||
return readDocument(markdown, 'plain')
|
||||
}
|
||||
|
||||
function readDocument(markdown: string, flavour: Flavour): Result<AdfDocument, ParseError> {
|
||||
const parsed = parseBlocks(markdown)
|
||||
const reading: Reading = { carried: new Set(), definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() }
|
||||
const content = positioned(readBlocks(parsed.blocks, reading, [], 0), documentStart)
|
||||
if (!content.ok) return content
|
||||
const document: AdfDocument = content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 }
|
||||
if (flavour === 'plain') mintTaskIds(document, markdown, reading.carried)
|
||||
return success(document)
|
||||
}
|
||||
|
||||
function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path)
|
||||
const content: AdfNode[] = []
|
||||
for (const [index, block] of blocks.entries()) {
|
||||
@@ -35,7 +57,7 @@ function blockNodes(blocks: readonly Block[], definitions: LinkDefinitions, path
|
||||
if (fault !== undefined) return positioned(faulted(fault, nodePath), block.position)
|
||||
continue
|
||||
}
|
||||
const node = positioned(blockNode(block, definitions, nodePath, depth, memo), block.position)
|
||||
const node = positioned(readBlock(block, reading, nodePath, depth), block.position)
|
||||
if (!node.ok) return node
|
||||
content.push(node.value)
|
||||
}
|
||||
@@ -55,50 +77,148 @@ function partsFault(): ConvertFault {
|
||||
return unsupportedNodeShape(`${listBreakName} parts two adjacent lists of one type: this one parts something else`)
|
||||
}
|
||||
|
||||
function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result<AdfNode> {
|
||||
function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
switch (block.kind) {
|
||||
case 'blockquote':
|
||||
return containerNode({ type: 'blockquote' }, block.blocks, definitions, path, depth, memo)
|
||||
return reading.flavour === 'plain' ? quoteNode(block.blocks, reading, path, depth) : containerNode({ type: 'blockquote' }, block.blocks, reading, path, depth)
|
||||
case 'bulletList':
|
||||
return listNode({ type: 'bulletList' }, block.items, definitions, path, depth, memo)
|
||||
return reading.flavour === 'plain' ? bulletNode(block.items, reading, path, depth) : listNode({ type: 'bulletList' }, block.items, reading, path, depth)
|
||||
case 'code':
|
||||
return codeBlockNode(block.language, block.text, path, depth)
|
||||
return codeBlockNode(block.language, block.text, reading, path, depth)
|
||||
case 'directive':
|
||||
return directiveNode(block, definitions, path, depth, memo)
|
||||
return directiveNode(block, reading, path, depth)
|
||||
case 'fault':
|
||||
return faulted(block.fault, path)
|
||||
case 'heading':
|
||||
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path, 'heading')
|
||||
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, reading, path, 'heading')
|
||||
case 'html':
|
||||
return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path)
|
||||
case 'orderedList':
|
||||
return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, definitions, path, depth, memo)
|
||||
return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, reading, path, depth)
|
||||
case 'paragraph':
|
||||
return paragraphNode(block.text, definitions, path)
|
||||
return paragraphNode(block.text, reading, path)
|
||||
case 'rule':
|
||||
return success({ type: 'rule' })
|
||||
case 'table':
|
||||
return tableNode(block.rows, definitions, path)
|
||||
return tableNode(block.rows, reading, path)
|
||||
}
|
||||
}
|
||||
|
||||
function directiveNode(block: DirectiveBlock, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result<AdfNode> {
|
||||
function markerLed<T extends { length: number }>(block: Block | undefined, read: (text: string) => T | undefined): { marker: T; position: SourcePosition; text: string } | undefined {
|
||||
if (block?.kind !== 'paragraph') return undefined
|
||||
const marker = leadingMarker(block.text, read)
|
||||
return marker === undefined ? undefined : { marker, position: block.position, text: block.text.slice(marker.length) }
|
||||
}
|
||||
|
||||
function markerLine(text: string): { line: string; rest: string } {
|
||||
const lineEnd = text.indexOf('\n')
|
||||
const line = lineEnd === -1 ? text : text.slice(0, lineEnd)
|
||||
const hardBreak = lineEnd !== -1 && /(?:^|[^\\])(?:\\\\)*\\$/.test(line)
|
||||
return { line: (hardBreak ? line.slice(0, -1) : line).replace(/^[ \t]+/, ''), rest: lineEnd === -1 ? '' : text.slice(lineEnd + 1) }
|
||||
}
|
||||
|
||||
function paragraphsOf(position: SourcePosition, ...texts: string[]): Paragraph[] {
|
||||
return texts.filter((text) => text !== '').map((text) => ({ kind: 'paragraph', position, text }))
|
||||
}
|
||||
|
||||
function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const [first, ...body] = blocks
|
||||
const led = markerLed(first, readAlertMarker)
|
||||
if (led === undefined) return containerNode({ type: 'blockquote' }, blocks, reading, path, depth)
|
||||
const { folded, panelType } = led.marker
|
||||
const { line, rest } = markerLine(led.text)
|
||||
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readMarked(paragraphsOf(led.position, line, rest), line !== '', body, reading, path, depth))
|
||||
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', 'lossless')
|
||||
if (!title.ok) return title
|
||||
if (title.value.image !== undefined) return failure('unmappable-image', imageOnMarkerLine, path)
|
||||
const leaves: AdfNode[] = []
|
||||
for (const [index, node] of title.value.nodes.entries()) {
|
||||
const held = node.text === undefined ? inlineLeaves([node], 'paragraph', [...path, 'content', index], depth) : success([node])
|
||||
if (!held.ok) return held
|
||||
leaves.push(...held.value)
|
||||
}
|
||||
const text = titleText(leaves)
|
||||
const type = reading.inExpand ? 'nestedExpand' : 'expand'
|
||||
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readMarked(paragraphsOf(led.position, rest), false, body, { ...reading, inExpand: true }, path, depth))
|
||||
}
|
||||
|
||||
// docs/decisions.md, A callout title keeps its link targets.
|
||||
function titleText(nodes: readonly AdfNode[]): string {
|
||||
let text = ''
|
||||
let linked = ''
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
const href = linkTarget(node)
|
||||
text += node.text ?? ''
|
||||
linked += href === undefined ? '' : node.text ?? ''
|
||||
if (href === undefined || linkTarget(nodes[index + 1]) === href) continue
|
||||
if (href !== linked && href !== `mailto:${linked}`) text += ` (${href})`
|
||||
linked = ''
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function linkTarget(node: AdfNode | undefined): string | undefined {
|
||||
const href = node?.marks?.find((mark) => mark.type === 'link')?.attrs?.href
|
||||
return typeof href === 'string' ? href : undefined
|
||||
}
|
||||
|
||||
// Atlassian's schema requires a panel and an expand to hold a block.
|
||||
function filledNode(node: AdfNode, content: Result<AdfNode[]>): Result<AdfNode> {
|
||||
if (!content.ok) return content
|
||||
return success({ ...node, content: content.value.length === 0 ? [{ type: 'paragraph' }] : content.value })
|
||||
}
|
||||
|
||||
// A paragraph split off a marker still refuses the image it held beside it; `onMarkerLine` is whether the first one opens on the marker's line.
|
||||
function readMarked(marked: readonly Paragraph[], onMarkerLine: boolean, others: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||
const read = readBlocks([...marked, ...others], reading, path, depth + 1)
|
||||
if (!read.ok) return read
|
||||
const image = read.value.slice(0, marked.length).findIndex((node) => node.type === 'mediaSingle')
|
||||
if (image === -1) return read
|
||||
return failure('unmappable-image', image === 0 && onMarkerLine ? imageOnMarkerLine : imageAfterMarker, [...path, 'content', image])
|
||||
}
|
||||
|
||||
// A task list trailing an item's blocks stands beside it, as ADF nests one.
|
||||
function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const led = []
|
||||
for (const [first, ...others] of items) {
|
||||
const marked = markerLed(first, readTaskMarker)
|
||||
if (marked === undefined) return listNode({ type: 'bulletList' }, items, reading, path, depth)
|
||||
led.push({ ...marked, others })
|
||||
}
|
||||
const tasks: AdfNode[] = []
|
||||
for (const [index, { marker, others, position, text }] of led.entries()) {
|
||||
const read = readMarked(paragraphsOf(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), markerLine(text).line !== '', others, reading, [...path, 'content', index], depth)
|
||||
if (!read.ok) return read
|
||||
let beside = read.value.length
|
||||
while (read.value[beside - 1]?.type === 'taskList') beside -= 1
|
||||
const kept = read.value.slice(0, beside)
|
||||
const [only] = kept
|
||||
const attrs = { state: marker.state }
|
||||
const inline = kept.length <= 1 && (only === undefined || only.type === 'paragraph')
|
||||
tasks.push(inline ? { attrs, content: nodeContent(only ?? {}).slice(), type: 'taskItem' } : { attrs, content: kept, type: 'blockTaskItem' })
|
||||
for (const nested of read.value.slice(beside)) tasks.push(nested)
|
||||
}
|
||||
return success({ content: tasks, type: 'taskList' })
|
||||
}
|
||||
|
||||
function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const read = readBlockDirectiveNode(block.name, block.argument, block.attributes, path)
|
||||
if (!read.ok) return read
|
||||
const built = directiveBody(read.value, block.blocks, definitions, path, depth, memo)
|
||||
const inExpand = reading.inExpand || read.value.node.type === 'expand' || read.value.node.type === 'nestedExpand'
|
||||
const built = directiveBody(read.value, block.blocks, { ...reading, inExpand }, path, depth)
|
||||
if (!built.ok) return built
|
||||
const readable = commonMarkSpelling(built.value, path, depth, memo)
|
||||
const readable = commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', memo: reading.memo })
|
||||
if (readable === undefined) return built
|
||||
if (!readable.ok) return readable
|
||||
return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path)
|
||||
}
|
||||
|
||||
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result<AdfNode> {
|
||||
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const { contentModel, node } = read
|
||||
if (blocks === undefined) return success(node)
|
||||
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
|
||||
if (contentModel === 'inline') return inlineBodyNode(node, blocks, definitions, path)
|
||||
return containerNode(node, blocks, definitions, path, depth, memo)
|
||||
if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path)
|
||||
return containerNode(node, blocks, reading, path, depth)
|
||||
}
|
||||
|
||||
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
|
||||
@@ -114,13 +234,13 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver
|
||||
return success(withContent(spelled, only.text === '' ? [] : [{ text: only.text, type: 'text' }]))
|
||||
}
|
||||
|
||||
function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
|
||||
function tableNode(rows: readonly string[][], reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
|
||||
const content: AdfNode[] = []
|
||||
for (const [rowIndex, cells] of rows.entries()) {
|
||||
const type = rowIndex === 0 ? 'tableHeader' : 'tableCell'
|
||||
const row: AdfNode[] = []
|
||||
for (const [cellIndex, cell] of cells.entries()) {
|
||||
const paragraph = contentNode({ type: 'paragraph' }, cell, definitions, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], 'table-cell')
|
||||
const paragraph = contentNode({ type: 'paragraph' }, cell, reading, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], 'table-cell')
|
||||
if (!paragraph.ok) return paragraph
|
||||
row.push({ content: [paragraph.value], type })
|
||||
}
|
||||
@@ -129,16 +249,16 @@ function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path
|
||||
return success({ content, type: 'table' })
|
||||
}
|
||||
|
||||
function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
|
||||
function inlineBodyNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
|
||||
if (blocks.length === 0) return success(node)
|
||||
const only = blocks.length === 1 ? blocks[0] : undefined
|
||||
if (only?.kind === 'fault') return positioned(faulted(only.fault, path), only.position)
|
||||
if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body: this body is not one`, path)
|
||||
return positioned(contentNode(node, only.text, definitions, path, 'paragraph'), only.position)
|
||||
return positioned(contentNode(node, only.text, reading, path, 'paragraph'), only.position)
|
||||
}
|
||||
|
||||
function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result<AdfNode> {
|
||||
const content = blockNodes(blocks, definitions, path, depth + 1, memo)
|
||||
function containerNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const content = readBlocks(blocks, reading, path, depth + 1)
|
||||
if (!content.ok) return content
|
||||
return success(withContent(node, content.value))
|
||||
}
|
||||
@@ -147,20 +267,21 @@ function withContent(node: AdfNode, content: readonly AdfNode[]): AdfNode {
|
||||
return content.length === 0 ? node : { ...node, content: [...content] }
|
||||
}
|
||||
|
||||
function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result<AdfNode> {
|
||||
function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
const content: AdfNode[] = []
|
||||
for (const [index, blocks] of items.entries()) {
|
||||
const item = containerNode({ type: 'listItem' }, blocks, definitions, [...path, 'content', index], depth, memo)
|
||||
const item = containerNode({ type: 'listItem' }, blocks, reading, [...path, 'content', index], depth)
|
||||
if (!item.ok) return item
|
||||
content.push(item.value)
|
||||
}
|
||||
return success({ ...node, content })
|
||||
}
|
||||
|
||||
function codeBlockNode(language: string, text: string, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
function codeBlockNode(language: string, text: string, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||
if (language === carryName) {
|
||||
const carried = readCarriedBlock(text, depth)
|
||||
if (carried.fault !== undefined) return faulted(carried.fault, path)
|
||||
reading.carried.add(carried.value)
|
||||
return success(carried.value)
|
||||
}
|
||||
const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' }
|
||||
@@ -168,15 +289,15 @@ function codeBlockNode(language: string, text: string, path: ConvertErrorPath, d
|
||||
}
|
||||
|
||||
// spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs.
|
||||
function paragraphNode(text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
|
||||
const content = parseInlineContent(text, definitions, path, 'paragraph')
|
||||
function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
|
||||
const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.flavour)
|
||||
if (!content.ok) return content
|
||||
const image = content.value.image
|
||||
return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image)
|
||||
}
|
||||
|
||||
function contentNode(node: AdfNode, text: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
|
||||
const content = parseInlineContent(text, definitions, path, container)
|
||||
function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
|
||||
const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour)
|
||||
if (!content.ok) return content
|
||||
if (content.value.image !== undefined) return failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path)
|
||||
return success(withContent(node, content.value.nodes))
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import { adfToMarkdown } from '../emit/adf-to-markdown.ts'
|
||||
import { adfToPlainMarkdown } from '../emit/plain-reduction.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
|
||||
import { toEditorNormal } from '../../adf/editor-normal.ts'
|
||||
|
||||
const code: AdfMark = { type: 'code' }
|
||||
const em: AdfMark = { type: 'em' }
|
||||
const highlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
|
||||
const strong: AdfMark = { type: 'strong' }
|
||||
|
||||
const taskTypes = ['blockTaskItem', 'taskItem', 'taskList']
|
||||
|
||||
function read(markdown: string): readonly AdfNode[] | string {
|
||||
const parsed = plainMarkdownToAdf(markdown)
|
||||
if (!parsed.ok) return parsed.error.code
|
||||
const blocks = toEditorNormal(parsed.value).content ?? []
|
||||
const pending = [...blocks]
|
||||
for (let block = pending.pop(); block !== undefined; block = pending.pop()) {
|
||||
for (const child of block.content ?? []) pending.push(child)
|
||||
if (!taskTypes.includes(block.type)) continue
|
||||
const { localId, ...attrs } = block.attrs ?? {}
|
||||
assert.equal(typeof localId, 'string', `${block.type} in ${JSON.stringify(markdown)}`)
|
||||
if (Object.keys(attrs).length === 0) delete block.attrs
|
||||
else block.attrs = attrs
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
function normal(...blocks: AdfNode[]): readonly AdfNode[] {
|
||||
return toEditorNormal(document(...blocks)).content ?? []
|
||||
}
|
||||
|
||||
function document(...content: AdfNode[]): AdfDocument {
|
||||
return { content, type: 'doc', version: 1 }
|
||||
}
|
||||
|
||||
function roundTripped(...content: AdfNode[]): readonly AdfNode[] | string {
|
||||
const markdown = adfToPlainMarkdown(document(...content))
|
||||
return markdown.ok ? read(markdown.value) : markdown.error.code
|
||||
}
|
||||
|
||||
function text(value: string, ...marks: AdfMark[]): AdfNode {
|
||||
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
|
||||
}
|
||||
|
||||
function node(type: string, attrs: AdfAttributes, ...content: AdfNode[]): AdfNode {
|
||||
return { attrs, content, type }
|
||||
}
|
||||
|
||||
function bare(type: string, ...content: AdfNode[]): AdfNode {
|
||||
return { content, type }
|
||||
}
|
||||
|
||||
function paragraph(...content: AdfNode[]): AdfNode {
|
||||
return bare('paragraph', ...content)
|
||||
}
|
||||
|
||||
function said(value: string): AdfNode {
|
||||
return paragraph(text(value))
|
||||
}
|
||||
|
||||
function panel(panelType: string, ...content: AdfNode[]): AdfNode {
|
||||
return node('panel', { panelType }, ...content)
|
||||
}
|
||||
|
||||
function task(state: string, ...content: AdfNode[]): AdfNode {
|
||||
return node('taskItem', { state }, ...content)
|
||||
}
|
||||
|
||||
test('reads an alert to a panel by its GitHub word, in any case', () => {
|
||||
const alert = (word: string): readonly AdfNode[] | string => read(`> [!${word}]\n>\n> Check it.\n`)
|
||||
assert.deepEqual(alert('NOTE'), [panel('info', said('Check it.'))])
|
||||
assert.deepEqual(alert('IMPORTANT'), [panel('note', said('Check it.'))])
|
||||
assert.deepEqual(alert('TIP'), [panel('tip', said('Check it.'))])
|
||||
assert.deepEqual(alert('WARNING'), [panel('warning', said('Check it.'))])
|
||||
assert.deepEqual(alert('CAUTION'), [panel('error', said('Check it.'))])
|
||||
assert.deepEqual(alert('Warning'), [panel('warning', said('Check it.'))])
|
||||
assert.deepEqual(alert('caution'), [panel('error', said('Check it.'))])
|
||||
})
|
||||
|
||||
test('reads an Obsidian callout to a panel by what its word means, any other word info', () => {
|
||||
const alert = (word: string): unknown => {
|
||||
const blocks = read(`> [!${word}]\n> Body.\n`)
|
||||
return typeof blocks === 'string' ? blocks : blocks[0]?.attrs
|
||||
}
|
||||
assert.deepEqual(alert('hint'), { panelType: 'tip' })
|
||||
for (const word of ['success', 'check', 'Done']) assert.deepEqual(alert(word), { panelType: 'success' }, word)
|
||||
assert.deepEqual(alert('attention'), { panelType: 'warning' })
|
||||
for (const word of ['danger', 'error', 'failure', 'fail', 'missing', 'BUG']) assert.deepEqual(alert(word), { panelType: 'error' }, word)
|
||||
for (const word of ['info', 'note', 'question', 'my-type']) assert.deepEqual(alert(word), { panelType: 'info' }, word)
|
||||
})
|
||||
|
||||
test('reads the rest of an alert marker line as the panel first body paragraph, the lines after as the next', () => {
|
||||
assert.deepEqual(read('> [!NOTE]\n> Line **one**.\n>\n> Two.\n'), [panel('info', paragraph(text('Line '), text('one', strong), text('.')), said('Two.'))])
|
||||
assert.deepEqual(read('> [!tip] Title\n'), [panel('tip', said('Title'))])
|
||||
assert.deepEqual(read('> [!tip] Title\n> body\n'), [panel('tip', said('Title'), said('body'))])
|
||||
assert.deepEqual(read('> [!tip] Title\\\n> body\n'), [panel('tip', said('Title'), said('body'))])
|
||||
assert.deepEqual(read('> [!NOTE]\\\n> Broken.\n'), [panel('info', said('Broken.'))])
|
||||
assert.deepEqual(read('> [!NOTE]\n'), normal(panel('info', paragraph())))
|
||||
assert.deepEqual(read('> > [!WARNING]\n> > Inner.\n'), [bare('blockquote', panel('warning', said('Inner.')))])
|
||||
})
|
||||
|
||||
test('leaves a quote plain where its first line is no alert marker', () => {
|
||||
for (const markdown of ['> \\[!NOTE]\n> x\n', '> [!NOTE]x\n', '> **[!NOTE]**\n', '> See [!NOTE]\n', '> [!NOTE]**x**\n', '> [!]\n', '> ```\n> [!NOTE]\n> ```\n']) {
|
||||
const blocks = read(markdown)
|
||||
assert.equal(typeof blocks !== 'string' && blocks[0]?.type, 'blockquote', markdown)
|
||||
}
|
||||
})
|
||||
|
||||
test('reads a folded callout to an expand titled by the rest of its marker line, whatever the word', () => {
|
||||
assert.deepEqual(read('> [!NOTE]- Build log\n>\n> Line.\n'), [node('expand', { title: 'Build log' }, said('Line.'))])
|
||||
assert.deepEqual(read('> [!bug]+ Open **by** default\n> body\n>\n> Line.\n'), [node('expand', { title: 'Open by default' }, said('body'), said('Line.'))])
|
||||
const link: AdfMark = { attrs: { href: 'https://example.com' }, type: 'link' }
|
||||
assert.deepEqual(read('> [!faq]- Why?\n> See [the docs](https://example.com), **now**.\n'), [
|
||||
node('expand', { title: 'Why?' }, paragraph(text('See '), text('the docs', link), text(', '), text('now', strong), text('.'))),
|
||||
])
|
||||
assert.deepEqual(read('> [!NOTE]- Two\\\n> lines\n'), [node('expand', { title: 'Two' }, said('lines'))])
|
||||
assert.deepEqual(read('> [!faq]- See [x](http://y)\n'), normal(node('expand', { title: 'See x (http://y)' }, paragraph())))
|
||||
assert.deepEqual(read('> [!faq]- [a **b**](u "t")[c](u) and [d](v)\n'), normal(node('expand', { title: 'a bc (u) and d (v)' }, paragraph())))
|
||||
assert.deepEqual(read('> [!faq]- <http://y> or <a@b.c> or <http://a\\b>\n'), normal(node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph())))
|
||||
assert.deepEqual(read('> [!faq]- [a b](u)\n'), normal(node('expand', { title: 'a\nb (u)' }, paragraph())))
|
||||
assert.deepEqual(read('> [!faq]- [!adf:mention[@M]{id=5}](u) !adf:inlineCard{url="http://y"}\n'), normal(node('expand', { title: '@M (u) http://y' }, paragraph())))
|
||||
assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), normal(node('expand', { title: 'Set ==x== here' }, paragraph())))
|
||||
assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))])
|
||||
})
|
||||
|
||||
test('reads a folded callout inside an expand to a nested expand', () => {
|
||||
const markdown = '> [!NOTE]- Outer\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n>\n> > [!TIP]\n> >\n> > > [!NOTE]-\n'
|
||||
assert.deepEqual(read(markdown), normal(node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph())))))
|
||||
assert.deepEqual(read('- > [!NOTE]-\n'), normal(bare('bulletList', bare('listItem', bare('expand', paragraph())))))
|
||||
assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), normal(bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph()))))
|
||||
})
|
||||
|
||||
test('reads a bullet list whose every item leads with a task marker to a task list', () => {
|
||||
assert.deepEqual(read('- [x] Write the spec\n- [ ] Ship **it**\n- [X] Tell\n'), [
|
||||
bare('taskList', task('DONE', text('Write the spec')), task('TODO', text('Ship '), text('it', strong)), task('DONE', text('Tell'))),
|
||||
])
|
||||
assert.deepEqual(read('- [x]\n- [ ]\\\n after\n'), normal(bare('taskList', task('DONE'), task('TODO', text('after')))))
|
||||
const minted = plainMarkdownToAdf('- [x] Parent\n - [ ] Child\n')
|
||||
assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [
|
||||
node(
|
||||
'taskList',
|
||||
{ localId: '51470556-7c91-46cb-b140-16e225a9b1f2' },
|
||||
node('taskItem', { localId: 'e04cbd87-eb04-4737-ae72-6d56d7799874', state: 'DONE' }, text('Parent')),
|
||||
node('taskList', { localId: '9ab33f3f-8c58-428f-b4eb-343a32a177d6' }, node('taskItem', { localId: 'b16a2f09-9543-499b-8a97-b88fc342b918', state: 'TODO' }, text('Child'))),
|
||||
),
|
||||
])
|
||||
})
|
||||
|
||||
test('moves a nested task list beside its item and makes an item holding more than one block a block task item', () => {
|
||||
assert.deepEqual(read('- [x] Parent\n - [ ] Child\n- [ ] Next\n'), [bare('taskList', task('DONE', text('Parent')), bare('taskList', task('TODO', text('Child'))), task('TODO', text('Next')))])
|
||||
assert.deepEqual(read('- [x] First.\n\n Second.\n- [ ]\n\n ```\n x\n ```\n'), [
|
||||
bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.')), node('blockTaskItem', { state: 'TODO' }, bare('codeBlock', text('x')))),
|
||||
])
|
||||
assert.deepEqual(read('- [x] A\n - plain\n'), [bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('A'), bare('bulletList', bare('listItem', said('plain')))))])
|
||||
})
|
||||
|
||||
test('leaves mixed, ordered and unmarked lists plain', () => {
|
||||
for (const markdown of ['- \\[x] a\n', '- [x] a\n- \\[ ] b\n', '- [x] a\n- b\n', '1. [x] a\n', '- [x]a\n', '- **[x]** a\n', '- [x]**a**\n', '- [-] a\n', '- > [x] a\n']) {
|
||||
const blocks = read(markdown)
|
||||
assert.notEqual(typeof blocks !== 'string' && blocks[0]?.type, 'taskList', markdown)
|
||||
assert.equal(JSON.stringify(blocks).includes('taskItem'), false, markdown)
|
||||
}
|
||||
assert.deepEqual(read('- plain\n - [ ] nested\n'), [bare('bulletList', bare('listItem', said('plain'), bare('taskList', task('TODO', text('nested')))))])
|
||||
})
|
||||
|
||||
test('reads a == pair to the editor default highlight, Yellow200 #f8e6a0 in @atlaskit/adf-schema 57.6.8', () => {
|
||||
assert.deepEqual(read('a ==hi there== b\n'), [paragraph(text('a '), text('hi there', highlight), text(' b'))])
|
||||
assert.deepEqual(read('**==hi==** b\n'), [paragraph(text('hi', highlight, strong), text(' b'))])
|
||||
assert.deepEqual(read('==**a**_b_ `c`==\n'), [paragraph(text('a', highlight, strong), text('b', highlight, em), text(' ', highlight), text('c', code))])
|
||||
assert.deepEqual(read('==`a`==\n'), [paragraph(text('a', code))])
|
||||
assert.deepEqual(read('x==y==z ==a == b==, (==c==) _d_==e==\n'), [paragraph(text('x==y==z '), text('a == b', highlight), text(', ('), text('c', highlight), text(') '), text('d', em), text('e', highlight))])
|
||||
assert.deepEqual(read('😀==b== ==c==😀 é==d==\n'), [paragraph(text('😀'), text('b', highlight), text(' '), text('c', highlight), text('😀 é==d=='))])
|
||||
assert.deepEqual(read('この機能は==日本語==でのみ、中文==重点==内容、ภาษา==ไทย==ดี 𠀀==𠀁==𠀂 이 기능은 ==한국어==에서만 サーバー==停止==中 このiPhone==専用==アプリ\n'), [
|
||||
paragraph(
|
||||
text('この機能は'), text('日本語', highlight), text('でのみ、中文'), text('重点', highlight), text('内容、ภาษา'), text('ไทย', highlight), text('ดี 𠀀'), text('𠀁', highlight),
|
||||
text('𠀂 이 기능은 '), text('한국어', highlight), text('에서만 サーバー'), text('停止', highlight), text('中 このiPhone'), text('専用', highlight), text('アプリ'),
|
||||
),
|
||||
])
|
||||
assert.deepEqual(read('# ==h==\n\n| ==c== |\n| --- |\n'), [
|
||||
node('heading', { level: 1 }, text('h', highlight)),
|
||||
bare('table', bare('tableRow', bare('tableHeader', paragraph(text('c', highlight))))),
|
||||
])
|
||||
assert.deepEqual(read('> [!NOTE]\n> ==x==\n'), [panel('info', paragraph(text('x', highlight)))])
|
||||
assert.deepEqual(read('==a==\\\n==b==\n'), [paragraph(text('a', highlight), { type: 'hardBreak' }, text('b', highlight))])
|
||||
})
|
||||
|
||||
test('leaves a == no pair flanks as text', () => {
|
||||
for (const markdown of ['\\==x==\n', '==x\\==\n', 'a == b == c\n', 'if a==b and c==d then\n', 'a==b== c\n', '==a==b\n', '====\n', '`==x==`\n', '==a\\\nb==\n', '**==a**==\n', '==a', '== a==\n', '==a ==\n']) {
|
||||
assert.equal(JSON.stringify(read(markdown)).includes('backgroundColor'), false, markdown)
|
||||
}
|
||||
})
|
||||
|
||||
test('reads what the reduction wrote back to the node it reduced, less the attributes it drops', () => {
|
||||
const localId = '01a0d99b-1f59-7e2c-a3d4-62c1f0b8e7a1'
|
||||
for (const panelType of ['info', 'note', 'tip', 'warning', 'error']) {
|
||||
assert.deepEqual(roundTripped(node('panel', { localId, panelType }, said('Check.'))), [panel(panelType, said('Check.'))], panelType)
|
||||
}
|
||||
const expand = node('expand', { localId, title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))
|
||||
assert.deepEqual(roundTripped(node('panel', { panelType: 'tip' }, paragraph()), node('expand', { title: 'Empty' }, paragraph())), normal(panel('tip', paragraph()), node('expand', { title: 'Empty' }, paragraph())))
|
||||
assert.deepEqual(roundTripped(expand), [node('expand', { title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))])
|
||||
const tasks = bare(
|
||||
'taskList',
|
||||
node('taskItem', { localId, state: 'DONE' }, text('Write')),
|
||||
bare('taskList', task('TODO', text('Review'))),
|
||||
node('blockTaskItem', { state: 'TODO' }, said('First.'), said('Second.')),
|
||||
node('blockTaskItem', { state: 'DONE' }, bare('codeBlock', text('x'))),
|
||||
)
|
||||
const plainTasks = bare(
|
||||
'taskList',
|
||||
task('DONE', text('Write')),
|
||||
bare('taskList', task('TODO', text('Review'))),
|
||||
node('blockTaskItem', { state: 'TODO' }, said('First.'), said('Second.')),
|
||||
node('blockTaskItem', { state: 'DONE' }, bare('codeBlock', text('x'))),
|
||||
)
|
||||
assert.deepEqual(roundTripped(tasks), [plainTasks])
|
||||
const colour: AdfMark = { attrs: { color: '#c6edfb' }, type: 'backgroundColor' }
|
||||
assert.deepEqual(roundTripped(paragraph(text('a '), text('hi', colour, strong), text(' b'))), [paragraph(text('a '), text('hi', highlight, strong), text(' b'))])
|
||||
assert.deepEqual(roundTripped(paragraph(text('=', colour), text(' '), text('a==b', colour))), [paragraph(text('=', highlight), text(' '), text('a==b', highlight))])
|
||||
assert.deepEqual(roundTripped(paragraph(text('x'), text('y', colour))), [said('xy')])
|
||||
assert.deepEqual(roundTripped(node('expand', { title: '**x** [y](z)' }, said('b'))), [node('expand', { title: '**x** [y](z)' }, said('b'))])
|
||||
})
|
||||
|
||||
test('reads text the writer kept from reading as a marker back as text', () => {
|
||||
const quote = bare('blockquote', said('[!NOTE] x'))
|
||||
const list = bare('bulletList', bare('listItem', said('[x] a')), bare('listItem', said('[ ] b')))
|
||||
assert.deepEqual(roundTripped(said('==x== a==b 日==本==語'), quote, list), [said('==x== a==b 日==本==語'), quote, list])
|
||||
assert.deepEqual(roundTripped(bare('taskList', task('DONE', text('[x] ==a==')))), [bare('taskList', task('DONE', text('[x] ==a==')))])
|
||||
})
|
||||
|
||||
test('keeps what markdownToAdf reads that no row reads, and refuses only what it refuses', () => {
|
||||
assert.deepEqual(read('!adf:panel warning\n- [x] a\n!adf:/panel\n'), [panel('warning', bare('taskList', task('DONE', text('a'))))])
|
||||
assert.deepEqual(read('!adf:taskList\n!adf:taskItem TODO\nb\n!adf:/taskItem\n!adf:/taskList\n'), [bare('taskList', task('TODO', text('b')))])
|
||||
const listed = plainMarkdownToAdf('!adf:taskList {localId=01a0eeb2-be48-7ea7-8587-db5e013c374a}\n- [ ] b\n!adf:/taskList\n')
|
||||
assert.deepEqual(listed.ok ? listed.value.content?.[0]?.attrs : listed.error.code, { localId: '01a0eeb2-be48-7ea7-8587-db5e013c374a' })
|
||||
const future = bare('futureBlock', text('==x=='))
|
||||
const carried = adfToMarkdown(document(future))
|
||||
assert.deepEqual(carried.ok ? read(carried.value) : carried.error.code, [future])
|
||||
const uncarriable = bare('taskList', node('taskItem', { extra: { a: 1 }, state: 'TODO' }, text('a')))
|
||||
const carriedPanel = adfToMarkdown(document(node('panel', { extra: true, panelType: 'info' }, uncarriable)))
|
||||
const restored = carriedPanel.ok ? plainMarkdownToAdf(carriedPanel.value) : carriedPanel
|
||||
assert.deepEqual(restored.ok ? restored.value.content : restored.error.code, [node('panel', { extra: true, panelType: 'info' }, uncarriable)])
|
||||
const red: AdfMark = { attrs: { color: '#ff0000' }, type: 'backgroundColor' }
|
||||
const held = paragraph(text('a ==b== c', red), text(' ==d '), { attrs: { note: 'x' }, text: 'e==f', type: 'text' }, text(' g=='))
|
||||
const spelled = adfToMarkdown(document(held))
|
||||
assert.deepEqual(spelled.ok ? read(spelled.value) : spelled.error.code, [paragraph(text('a ==b== c', red), text(' '), text('d ', highlight), { attrs: { note: 'x' }, text: 'e==f', type: 'text' }, text(' g', highlight))])
|
||||
const lossless = markdownToAdf('> [!NOTE]\n\n- [x] ==a==\n')
|
||||
assert.deepEqual(lossless.ok ? lossless.value.content : lossless.error.code, [bare('blockquote', said('[!NOTE]')), bare('bulletList', bare('listItem', said('[x] ==a==')))])
|
||||
assert.equal(read('!adf:panel\n'), 'malformed-directive')
|
||||
const refusal = (markdown: string): unknown => {
|
||||
const parsed = plainMarkdownToAdf(markdown)
|
||||
return parsed.ok ? parsed.value : [parsed.error.code, parsed.error.message]
|
||||
}
|
||||
for (const markdown of ['> [!tip] \n', '> [!NOTE]- \n', '- [x] \n']) {
|
||||
assert.deepEqual(refusal(markdown), ['unmappable-image', 'an image fits only as a paragraph of its own: this one shares a line with a marker'], markdown)
|
||||
}
|
||||
for (const markdown of ['> [!tip]\n> \n', '> [!tip] t\n> \n', '> [!NOTE]- t\n> \n', '- [x]\n \n']) {
|
||||
assert.deepEqual(refusal(markdown), ['unmappable-image', 'an image fits only as a paragraph of its own: this one continues the paragraph a marker opens, which a blank line before it ends'], markdown)
|
||||
}
|
||||
let deep = 'x\n'
|
||||
for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}`
|
||||
assert.equal(typeof read(deep), 'object')
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import { mintTaskIds } from './task-ids.ts'
|
||||
|
||||
const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
|
||||
function taskIds(document: AdfDocument): unknown[] {
|
||||
const ids: unknown[] = []
|
||||
const pending: AdfNode[] = [...(document.content ?? [])].reverse()
|
||||
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
||||
if (['blockTaskItem', 'taskItem', 'taskList'].includes(node.type)) ids.push(node.attrs?.['localId'])
|
||||
for (const child of [...(node.content ?? [])].reverse()) pending.push(child)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function tasks(...content: AdfNode[]): AdfDocument {
|
||||
return { content, type: 'doc', version: 1 }
|
||||
}
|
||||
|
||||
test('mints each task node lacking a localId a UUID v4 in document order, unique and the same every run', () => {
|
||||
const unminted = (): AdfDocument => tasks({ content: [{ attrs: { state: 'TODO' }, type: 'taskItem' }, { content: [{ attrs: { state: 'DONE' }, type: 'blockTaskItem' }], type: 'taskList' }], type: 'taskList' })
|
||||
const minted = unminted()
|
||||
mintTaskIds(minted, '- [ ] a\n', new Set())
|
||||
const ids = taskIds(minted)
|
||||
assert.equal(ids.length, 4)
|
||||
for (const id of ids) assert.match(String(id), uuidV4)
|
||||
assert.equal(new Set(ids).size, 4)
|
||||
const again = unminted()
|
||||
mintTaskIds(again, '- [ ] a\n', new Set())
|
||||
assert.deepEqual(taskIds(again), ids)
|
||||
const other = unminted()
|
||||
mintTaskIds(other, '- [ ] b\n', new Set())
|
||||
assert.equal(taskIds(other).some((id) => ids.includes(id)), false)
|
||||
})
|
||||
|
||||
test('keeps a localId the document spells and skips it when minting, a carried one too', () => {
|
||||
const first = tasks({ type: 'taskList' })
|
||||
mintTaskIds(first, 'x', new Set())
|
||||
const [taken] = taskIds(first)
|
||||
const carried: AdfNode = { content: [{ attrs: { localId: String(taken) }, type: 'taskList' }], type: 'futureBlock' }
|
||||
const spelled = tasks({ type: 'taskList' }, carried)
|
||||
mintTaskIds(spelled, 'x', new Set([carried]))
|
||||
const [minted, kept] = taskIds(spelled)
|
||||
assert.equal(kept, taken)
|
||||
assert.notEqual(minted, taken)
|
||||
assert.match(String(minted), uuidV4)
|
||||
})
|
||||
|
||||
test('leaves a node the carry restores as carried, minting neither it nor what it holds', () => {
|
||||
const list = (): AdfNode => ({ content: [{ attrs: { state: 'TODO' }, type: 'taskItem' }], type: 'taskList' })
|
||||
const carriedList = list()
|
||||
const carriedPanel: AdfNode = { attrs: { panelType: 'info' }, content: [list()], type: 'panel' }
|
||||
const future: AdfNode = { content: [list()], type: 'futureBlock' }
|
||||
const document = tasks(carriedList, carriedPanel, future)
|
||||
mintTaskIds(document, 'x', new Set([carriedList, carriedPanel]))
|
||||
assert.deepEqual(document, tasks(list(), { attrs: { panelType: 'info' }, content: [list()], type: 'panel' }, { content: [list()], type: 'futureBlock' }))
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
|
||||
const taskTypes = new Set(['blockTaskItem', 'taskItem', 'taskList'])
|
||||
|
||||
export function mintTaskIds(document: AdfDocument, markdown: string, carried: ReadonlySet<AdfNode>): void {
|
||||
const taken = new Set<string>()
|
||||
for (const node of preorder(document, () => true)) {
|
||||
const localId = nodeAttrs(node)['localId']
|
||||
if (typeof localId === 'string') taken.add(localId)
|
||||
}
|
||||
const seed = hash128(markdown).join(' ')
|
||||
let count = 0
|
||||
for (const node of preorder(document, (held) => !carried.has(held) && blockNodeModel(held.type)?.contentModel === 'block')) {
|
||||
if (!taskTypes.has(node.type) || carried.has(node) || typeof nodeAttrs(node)['localId'] === 'string') continue
|
||||
let localId = ''
|
||||
do {
|
||||
count += 1
|
||||
localId = uuidV4(hash128(`${seed} ${count}`))
|
||||
} while (taken.has(localId))
|
||||
taken.add(localId)
|
||||
node.attrs = { ...node.attrs, localId }
|
||||
}
|
||||
}
|
||||
|
||||
function* preorder(document: AdfDocument, entered: (node: AdfNode) => boolean): Generator<AdfNode> {
|
||||
const pending: AdfNode[] = []
|
||||
const pushReversed = (nodes: readonly AdfNode[]): void => {
|
||||
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
||||
const node = nodes[index]
|
||||
if (node !== undefined) pending.push(node)
|
||||
}
|
||||
}
|
||||
pushReversed(document.content ?? [])
|
||||
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
||||
yield node
|
||||
if (entered(node)) pushReversed(nodeContent(node))
|
||||
}
|
||||
}
|
||||
|
||||
// cyrb128, public domain.
|
||||
function hash128(text: string): number[] {
|
||||
let h1 = 1779033703
|
||||
let h2 = 3144134277
|
||||
let h3 = 1013904242
|
||||
let h4 = 2773480762
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const unit = text.charCodeAt(index)
|
||||
h1 = h2 ^ Math.imul(h1 ^ unit, 597399067)
|
||||
h2 = h3 ^ Math.imul(h2 ^ unit, 2869860233)
|
||||
h3 = h4 ^ Math.imul(h3 ^ unit, 951274213)
|
||||
h4 = h1 ^ Math.imul(h4 ^ unit, 2716044179)
|
||||
}
|
||||
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067)
|
||||
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233)
|
||||
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213)
|
||||
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179)
|
||||
h1 ^= h2 ^ h3 ^ h4
|
||||
h2 ^= h1
|
||||
h3 ^= h1
|
||||
h4 ^= h1
|
||||
return [h1 >>> 0, h2 >>> 0, h3 >>> 0, h4 >>> 0]
|
||||
}
|
||||
|
||||
function uuidV4(lanes: readonly number[]): string {
|
||||
const hex = lanes.map((lane) => lane.toString(16).padStart(8, '0')).join('')
|
||||
const variant = ((Number.parseInt(hex.charAt(16), 16) & 3) | 8).toString(16)
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { isWordCharacter } from './commonmark/emphasis-matching.ts'
|
||||
|
||||
export type Flavour = 'lossless' | 'plain'
|
||||
|
||||
type AlertMarker = { folded: boolean; length: number; panelType: string }
|
||||
|
||||
export const foldedAlertMarker = '[!NOTE]-'
|
||||
export const highlightDelimiter = '=='
|
||||
|
||||
const alertWords: Readonly<Record<string, string>> = {
|
||||
error: 'CAUTION',
|
||||
info: 'NOTE',
|
||||
note: 'IMPORTANT',
|
||||
success: 'TIP',
|
||||
tip: 'TIP',
|
||||
warning: 'WARNING',
|
||||
}
|
||||
|
||||
// ー, ー and the kana voicing marks sit outside the kana scripts; Script_Extensions would also take Latin combining marks.
|
||||
const boundingScript = /^[\u3099\u309a\u30fc\uff70\uff9e\uff9f\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Khmer}\p{Script=Lao}\p{Script=Myanmar}\p{Script=Thai}]$/u
|
||||
|
||||
const panelTypesByWord: Readonly<Record<string, string>> = {
|
||||
attention: 'warning',
|
||||
bug: 'error',
|
||||
caution: 'error',
|
||||
check: 'success',
|
||||
danger: 'error',
|
||||
done: 'success',
|
||||
error: 'error',
|
||||
fail: 'error',
|
||||
failure: 'error',
|
||||
hint: 'tip',
|
||||
important: 'note',
|
||||
missing: 'error',
|
||||
success: 'success',
|
||||
tip: 'tip',
|
||||
warning: 'warning',
|
||||
}
|
||||
|
||||
export function alertMarker(panelType: unknown): string {
|
||||
const word = typeof panelType === 'string' && Object.hasOwn(alertWords, panelType) ? alertWords[panelType] : undefined
|
||||
return `[!${word ?? 'NOTE'}]`
|
||||
}
|
||||
|
||||
export function readAlertMarker(text: string): AlertMarker | undefined {
|
||||
const marker = /^\[!([\w-]+)\]([+-]?)/.exec(text)
|
||||
if (marker === null) return undefined
|
||||
const word = (marker[1] ?? '').toLowerCase()
|
||||
const panelType = Object.hasOwn(panelTypesByWord, word) ? panelTypesByWord[word] : undefined
|
||||
return { folded: marker[2] !== '', length: marker[0].length, panelType: panelType ?? 'info' }
|
||||
}
|
||||
|
||||
// A marker leads text that ends at it or goes on past whitespace or a hard break.
|
||||
export function leadingMarker<T extends { length: number }>(text: string, read: (text: string) => T | undefined): T | undefined {
|
||||
const marker = read(text)
|
||||
if (marker === undefined) return undefined
|
||||
const rest = text.slice(marker.length, marker.length + 2)
|
||||
return rest === '' || /^(?:[ \t\n]|\\\n)/.test(rest) ? marker : undefined
|
||||
}
|
||||
|
||||
// A delimiter is bounded outside by the code point beyond it, and flanks by the character inside it.
|
||||
export function highlightFlanking(source: string, index: number): { closes: boolean; opens: boolean } {
|
||||
const end = index + highlightDelimiter.length
|
||||
const before = Array.from(source.slice(Math.max(0, index - 2), index)).at(-1) ?? ''
|
||||
const after = Array.from(source.slice(end, end + 2))[0] ?? ''
|
||||
return { closes: flanks(before) && bounds(after, before), opens: flanks(after) && bounds(before, after) }
|
||||
}
|
||||
|
||||
function bounds(outside: string, inside: string): boolean {
|
||||
return !isWordCharacter(outside) || boundingScript.test(outside) || boundingScript.test(inside)
|
||||
}
|
||||
|
||||
function flanks(character: string): boolean {
|
||||
return character !== '' && !/\s/.test(character)
|
||||
}
|
||||
|
||||
export function taskMarker(state: unknown): string {
|
||||
return state === 'DONE' ? '[x]' : '[ ]'
|
||||
}
|
||||
|
||||
export function readTaskMarker(text: string): { length: number; state: 'DONE' | 'TODO' } | undefined {
|
||||
const marker = /^\[([ xX])\]/.exec(text)
|
||||
if (marker === null) return undefined
|
||||
return { length: marker[0].length, state: marker[1] === ' ' ? 'TODO' : 'DONE' }
|
||||
}
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
// Levels count per AGENTS.md §11.
|
||||
// A level is one block-list recursion in either direction: a readable list's items sit one below it, its directive spelling's two.
|
||||
export const largestNesting = 500
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ function calledCodes(): string[] {
|
||||
return [...called].sort()
|
||||
}
|
||||
|
||||
// The list is frozen at 0.1.0 (AGENTS.md §8), so a code outliving its cause is a removal that costs a MAJOR.
|
||||
// Removing a code is breaking (docs/decisions.md §The code list), so a code must not outlive its cause.
|
||||
test('every ConvertErrorCode is the code of a production call site, and every call site names a declared one', () => {
|
||||
assert.deepEqual(calledCodes(), declaredCodes())
|
||||
})
|
||||
|
||||
-996
@@ -1,996 +0,0 @@
|
||||
# Todo history
|
||||
|
||||
The done `todo.md` items in full, as they were written. `todo.md` keeps a one-line summary of each.
|
||||
|
||||
## Milestones
|
||||
|
||||
- [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:
|
||||
`runs-on: docker-host`, actions pinned to semver tags.
|
||||
- [x] **1a — The directive grammar** (`spec/flavour.md`): inline/block/leaf directive forms,
|
||||
attributes, escaping, nesting, canonical form, the opaque-carry spelling, the raw-HTML
|
||||
input policy.
|
||||
- [x] **1b — Block node syntaxes** in `spec/flavour.md`: panel, expand/nestedExpand, the media
|
||||
family, the pipe-vs-directive table rule and the directive table form, task and decision
|
||||
lists, layout, extensions, syncBlock.
|
||||
- [x] **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) —
|
||||
escape-based, never literal, since pipe cells trim and pad. At `mediaInline`, check real
|
||||
payloads for external-URL support — if it exists, revisit the media section's
|
||||
mid-text-image error and its "no slot" ground.
|
||||
- [x] **1d — Corpus start** (§10): checked-in fixtures per spec'd node, in `corpus/`, one
|
||||
directory per contract kind (`corpus/README.md`).
|
||||
**Settled** (the maintainer, 2026-08-26): the nodes CommonMark spells get directive sections
|
||||
of their own, rather than riding the opaque carry. Per `@atlaskit/adf-schema` 57.1.0 every
|
||||
block node it spells — `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`,
|
||||
`orderedList`, `paragraph`, `rule` — carries a `localId` with no spelling, `codeBlock` also
|
||||
`hideLineNumbers`, `uniqueId` and `wrap`, `blockquote` also marks, and `hardBreak` `text`
|
||||
and `localId`; 2f gives each a place, and the plain spelling stays wherever the attributes
|
||||
are absent. That hands the two collision sites in `corpus/unspellable/` the second spelling
|
||||
they lacked, so each takes the directive form as a `media` with an empty `alt` already does
|
||||
(`spec/flavour.md`, the CommonMark image): a `codeBlock` whose info string is empty, and an
|
||||
`orderedList` starting at 1.
|
||||
**Also blocked**: the link rule covers destination spaces only, so two shapes
|
||||
have no spelling and are refused meanwhile — href `https://example.com/a)b` and title
|
||||
`He said "hi"`, both in `corpus/unspellable/`. Two defensible spellings each — angle
|
||||
brackets or a backslash escape, and for titles `'…'` or `(…)` besides — so §8 leaves
|
||||
the pick here. **Also blocked**: block separation is unstated for a CommonMark block beside a
|
||||
directive block in a container body — an `expand` whose content is `paragraph` "A" then a
|
||||
`panel` (`panelType` `warning`) holding "B" spells `A` and `:::panel warning` either on
|
||||
consecutive lines or with a blank line between. Two defensible spellings, so §8 leaves the
|
||||
pick here; the answer governs every unknown node type too, the block carry counting as a
|
||||
CommonMark block since its spelling is a fenced code block.
|
||||
`unspelled-block-separation` refuses the pair meanwhile, an empty paragraph's
|
||||
`::paragraph` beside a CommonMark block included — and, since a `mediaSingle`'s spelling now
|
||||
follows whether CommonMark can spell its URL, two sibling images differing only by an
|
||||
`&` land in the same refusal.
|
||||
- [x] **1d1 — The CommonMark subset**: blockquote, bulletList, codeBlock, heading, orderedList,
|
||||
paragraph, rule, listItem, hardBreak, text, code spans, and the `code`, `em`, `link`,
|
||||
`strike` and `strong` marks — one mark per text node; nesting is 1d3's.
|
||||
- [x] **1d2 — Block nodes**: panel, expand/nestedExpand, the media family and the CommonMark
|
||||
image shape, both table forms, task and decision lists, layout, extensions, syncBlock —
|
||||
with the reserved `marks` attribute and the fence lengths nesting forces.
|
||||
- [x] **1d3 — Inline nodes and marks**: date, emoji, inlineCard, mediaInline, mention, status;
|
||||
border, subsup, textColor, underline; the content slot's `text` attribute and the
|
||||
`:text{text="…"}` whitespace spelling.
|
||||
- [x] **2 — `adfToMarkdown`.** First real code. Each sub-item turns one corpus directory green;
|
||||
the two that have no fixtures yet write them in the same chunk, tests first (§10).
|
||||
- [x] **2a — The runner and the CommonMark subset.** The corpus runner: walk
|
||||
`corpus/round-trip/`, assert `adfToMarkdown` emits each `.md` byte for byte. Decide here
|
||||
where §10's coverage check lives, and gate that every `corpus/**/*.json` re-serializes to
|
||||
itself under the library's own canonical serializer — one implementation, keys sorted, two
|
||||
spellings: two-space indent for the corpus files and the block carry's body, compact for
|
||||
the inline carry. `commonmark-subset/` green.
|
||||
- [x] **2b — Block nodes.** `block-nodes/` green. A nested list that cannot interrupt the block
|
||||
above it is refused meanwhile, not spelled: the maintainer's answer on tight-versus-blank
|
||||
separation turns that refusal into an emission. The test is broader than the name it
|
||||
carries — `interruptsParagraph` reads the next list alone, so a list after a block no
|
||||
paragraph continues, a code block say, is refused too — and the same answer narrows it.
|
||||
Block separation becomes
|
||||
`separationBetween(previous, next, container)` here — a boolean cannot hold the third case
|
||||
`spec/flavour.md` states for two directive blocks in a container body, and the maintainer's
|
||||
answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give
|
||||
the emitter's refusals a corpus home while the directories grow: `corpus/unspellable/`,
|
||||
a `.json` beside the `ConvertErrorCode` it must return, the emitter half of `corpus/errors/`.
|
||||
- [x] **2c — Inline nodes and marks.** `inline-nodes/` green. `InlineSegment` splits into its
|
||||
two axes — escapability (`backslash`, `bracketed`, `none`) and the emphasis role. A lone
|
||||
surrogate in a text node emits verbatim and becomes U+FFFD on any UTF-8 encode, a §2 break
|
||||
plain text still holds open — attribute values already escape it. The pipe form's fallback
|
||||
reads the emitted segments rather than naming the nodes whose attribute values spell a pipe
|
||||
as syntax, so 2e's `\u007c` narrows it in one place.
|
||||
- [x] **2d — The opaque carry** (§3). Fixtures and emitter together, into
|
||||
`corpus/round-trip/opaque-carry/`: an unknown node in both positions, the reserved `adf`
|
||||
info string, and the `codeBlock` whose language is `adf` — carried whole ahead of the
|
||||
attribute fallback 2e owes, since the reservation leaves that node no other spelling
|
||||
whatever 1d decides for its `localId`.
|
||||
- [x] **2e — Carve-outs and combinations.** Fixtures and emitter together, into
|
||||
`corpus/round-trip/combinations/`.
|
||||
- [x] **2e1 — The carve-outs and the claimed line.** The three carve-outs
|
||||
and their escapes, and a paragraph line inside a container body shaped like a closing
|
||||
fence (`:::`, `::: x`). Guard `fenceNestingFault`'s bare-run pop here too — a run shorter
|
||||
than the open fence is a fault, not a close — which today's emitter cannot reach.
|
||||
- [x] **2e2 — Mark runs and the runs a carry breaks.** The longest-run rule, attributes
|
||||
included, and a mark spelling that cannot open where it sits (`un**-real**istic`; the spec
|
||||
owes the carry a trigger). One mark vocabulary lands here, before 2e3 changes the
|
||||
attribute spelling: `emphasisSpellings`, `linkAttributes` and the `code`/`link` names join
|
||||
the mark table (3a parted it across `adf/mark-attributes.ts` and
|
||||
`markdown/mark-spellings.ts`), which holds four of the nine marks while the rest are
|
||||
branch literals in the emitter — and the parser (3) needs every name to make `:em[x]`
|
||||
the named error `spec/flavour.md` promises.
|
||||
- [x] **2e3 — Attribute canonicalization and the quoted value's escape.**
|
||||
**Settled** (the maintainer, 2026-08-26): a quoted attribute value escapes `` ` ``, `&`,
|
||||
`<` and `|` as `\u0060`, `\u0026`, `\u003c` and `\u007c`, in every directive, block and
|
||||
inline alike — the constructs those four open all bind at or before a directive does, and
|
||||
nothing else reaches into `{attrs}`. Emitted attributes being inert leaves 3 free to keep
|
||||
CommonMark's own precedence between a directive and a code span, and collapsed
|
||||
`escaping: 'attribute'` into `none`. The escaper's link-opener scan skips emitted syntax
|
||||
to match: a `](` inside a directive escapes no text `[`.
|
||||
- [x] **2e4 — The carry's fallback triggers.** `spec/flavour.md` carries a node its section
|
||||
cannot spell — an attrs key no section lists, a value that is not the section's type, an
|
||||
arg slot holding no bare token, marks no nesting spells — where the emitter still refuses,
|
||||
which leaves the refusals a container's own spelling owns. The flanking trigger 2e2
|
||||
added to that list is the odd one out: `unspellableMark` finds it after assembly and
|
||||
names a mark type against the line's path, so the failing run needs identifying before
|
||||
the carry can replace the refusal `mark-inside-word` pinned.
|
||||
- [x] **2e5 — Combined documents and the collision property.** Documents combining nodes rather
|
||||
than isolating one, and the gate's collision property: no two corpus documents may emit
|
||||
the same bytes — one spelling for two documents is a round-trip break no parser can undo,
|
||||
and it is provable without one.
|
||||
**Settled** (the maintainer, 2026-08-27): the approximation this item inherited — flanking
|
||||
exact, CommonMark's *matching* unmodelled — had two round-trip breaks reachable by hand,
|
||||
so the emitter now models the matching. `process_emphasis` runs over the runs the emitter
|
||||
wrote (`emphasis-matching.ts`) and a pair it hands to another delimiter rides the carry,
|
||||
which is what the multiple-of-3 rule did to the em in `un*a**b*****c**istic`. A delimiter
|
||||
run in text now escapes wherever CommonMark could open or close with it, not only open:
|
||||
one that could only close stole the spelling around it (`un*a* b*istic`), and escaping
|
||||
both ways keeps every delimiter the emitter did not write out of the matching. The
|
||||
canonical form gained a backslash where a run only closes — `\*not emphasis\*`, and
|
||||
2e1's `carve-out-strike` a third and fourth.
|
||||
- [x] **2f — The attributes CommonMark cannot hold.** 1d's settled answer: the block nodes
|
||||
CommonMark spells — `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`,
|
||||
`orderedList`, `paragraph`, `rule` — get directive sections in `spec/flavour.md` carrying
|
||||
`localId`, `codeBlock`'s `hideLineNumbers`, `uniqueId` and `wrap`, and `blockquote`'s
|
||||
marks, while `hardBreak`'s `text` and `localId` join the inline directive it already has.
|
||||
The plain spelling stays wherever those attributes are absent, so only a node that carries
|
||||
one takes the directive form — which is what keeps a real payload readable rather than a
|
||||
page of carried JSON. Fixtures and emitter together, and the three documents the answer
|
||||
settles leave `corpus/unspellable/` as round-trip pairs: `block-local-id`,
|
||||
`code-block-empty-language`, `ordered-list-start-one`.
|
||||
**Settled** (the maintainer, 2026-08-27): the `codeBlock` directive's body is one fenced
|
||||
code block, the language staying on the fence line so every renderer still highlights it;
|
||||
a language no info string holds — empty, a backtick, edge whitespace, an entity reference
|
||||
or the reserved `adf` — rides the `language` attribute with the fence bare, which retires
|
||||
2d's carry for the reserved name along with the premise that left it no other spelling.
|
||||
The plain spelling gives way wherever it cannot render what the node carries rather than
|
||||
only where it has no place for it, so a heading level absent or outside 1-6 and an order
|
||||
whose markers would run past 999999999 take the directive form too, and
|
||||
`ambiguous-attribute-spelling`, `unspellable-code-block-language`,
|
||||
`unspellable-list-marker`, `unspelled-block-marks` and `unsupported-heading-level` leave
|
||||
`ConvertErrorCode`; content and placement refusals stay, which leaves the directive form
|
||||
spelling an empty list or a non-`listItem` child that the plain form refuses. `order` is
|
||||
the first marker, so `order: 1` keeps the plain `1.` — what a real payload carries — and a
|
||||
list carrying no `order` has no number to take and takes the directive form.
|
||||
2f raises what 1d's unspelled block separation costs: a single `localId` on a paragraph
|
||||
beside a plain one now refuses every container body that is a directive's — a panel, an
|
||||
expand, a table cell — where before 2f the attribute refused the document anyway.
|
||||
- [x] **3 — `markdownToAdf` (`0.1.0`).** Each sub-item lands the fixtures its own code reads, and
|
||||
the runner grows a parse half as they do: readers for `corpus/normalization/` (setext,
|
||||
indented code, loose lists, `*`/`+`
|
||||
bullets, entity references, soft wraps — one-way, the markdown not canonical) and
|
||||
`corpus/errors/` (a markdown input per named error, the code in a `.error` beside it) with
|
||||
the first fixture each. `commonmark-subset/` cannot be the first to green — `::paragraph`
|
||||
and `:hardBreak{}` sit in it — so 3b through 3f answer to their own tests and the one-way
|
||||
fixtures they land, and 3g is where the first directory reads back. The raw-HTML element
|
||||
mapping is empty until milestone 6, so at `0.1.0` every raw-HTML construct in input — a
|
||||
block, an inline tag, a comment, a processing instruction — is a named error. Input is where
|
||||
unbounded nesting actually arrives, so §11's 500 binds all three of the emitter's guards
|
||||
here: block depth at 3c and again at 3f's container fences, inline and mark depth at 3f and
|
||||
3i, a carried value's JSON at 3j, where `isJsonValue` already bounds it.
|
||||
- [x] **3a — The hierarchy.** Mechanical, ahead of the first parser file: `src/adf/` and
|
||||
`src/markdown/` (`html/` arrives with its first file, 6-7), the grammar module shared
|
||||
inside `markdown/`, and `emphasis-matching.ts` beside it — the parser reuses it whole,
|
||||
`delimiterFlags` and `matchEmphasis` taking CommonMark's own run vocabulary rather than
|
||||
the emitter's, so no second `process_emphasis` exists to drift from the first.
|
||||
`block-directives.ts` and `inline-directives.ts` each part by file, a node table
|
||||
milestones 6-7 need in `adf/` beside a markdown spelling that belongs in `markdown/`.
|
||||
`directive-attributes.ts` cannot: `vocabularyPairs` walks the vocabulary and spells the
|
||||
value in one pass, the type check living inside `spellAttributeValue`, so the check comes
|
||||
out as its own predicate and goes to `adf/` with the walk while the spelling stays in
|
||||
`markdown/`, `isBareToken` with it — only spelling calls it. `markSpellings` is the one
|
||||
table whose keys part rather than its file, so key the markdown half off the ADF half's
|
||||
type: a mark named in one and not the other is then a compile error instead of a false
|
||||
refusal. `spellDestination`, `spellTitle` and `balanced` leave `markdown-inline.ts` here
|
||||
too — CommonMark destination spelling `emitLink` and `tryImageLine` share, and the six
|
||||
concerns that file carries are one fewer for it. `AttributeKind` and `AttributeVocabulary`
|
||||
follow the walk into `adf/`, the vocabulary a string-typed attribute grammar needs and
|
||||
HTML will want too, not a markdown spelling.
|
||||
**Settled** (the maintainer, 2026-08-27): `markdown/` parts here as well, into `emit/` and
|
||||
`parse/` with the shared set at the root — the grammar module, emphasis matching,
|
||||
the tables' markdown halves — and `parse/` arriving with 3b's first
|
||||
file, the rule `html/` already follows. And the node tables, a second copy of
|
||||
`spec/flavour.md`'s prose whose mistyped attribute name degrades into a false refusal no
|
||||
test catches, get their guard: a test reads the spec's node sections, takes each
|
||||
`name (type)` list and asserts it equals the table, leaving the spec the source a human
|
||||
writes with no build step and no generated file. It is built at 3g, where a wrong entry
|
||||
starts refusing documents.
|
||||
- [x] **3b — The leaf blocks.** The line walk that opens and closes a block, ahead of any inline
|
||||
parsing: paragraph, ATX and setext heading, thematic break, fenced and indented code
|
||||
block, the HTML block whose lines it swallows whether or not the construct then errors,
|
||||
the link reference definitions a closing paragraph gives up, and the blank lines between
|
||||
them. The openers are `commonmark-grammar.ts`'s — one table answers both directions, or
|
||||
the emitter under-escapes a line the parser reads as a block and §2 breaks in silence —
|
||||
the HTML block's start conditions excepted, which are new here since the emitter writes
|
||||
none. Block-level claiming lands here too: a colon run or an unescaped leading `|` is
|
||||
claimed, the parse behind it 3f's and 3h's, a claim with nothing yet to parse it the named
|
||||
error the claim promises meanwhile. The runner's parse half comes with it, and the first
|
||||
`normalization/` fixtures, holding inline-trivial content so 3d and 3e add beside them
|
||||
rather than editing them.
|
||||
- [x] **3c — The container blocks.** Blockquote, bullet and ordered list: the continuation a
|
||||
marker's width sets, lazy continuation, and the tightness ADF does not record — `> `
|
||||
repeated being two bytes a level, so this is the cheapest way to reach §11's 500. 3b's leaf
|
||||
readers scan the physical line themselves, so a container re-cuts the walk rather than adding
|
||||
to it: the open containers' prefix comes off the line first and the readers take one line at a
|
||||
time, `LeafBlock` renamed with the union they join and `blockNode`'s chain gaining their
|
||||
branches.
|
||||
**Settled** (the maintainer, 2026-08-27): a claimed line ends lazy continuation, so a
|
||||
closing fence on the line after a blockquote's open paragraph closes its container instead
|
||||
of continuing the paragraph CommonMark would fold it into. Claiming at block level is
|
||||
already absolute, and this binds input alone — 2e1's `closing-fence-line` orders the
|
||||
emitter's blockquote away from the edge either way — so `spec/flavour.md`'s claiming
|
||||
paragraph gains the case here. And 2b's tight-versus-blank, one answer for both
|
||||
directions: the tight spelling stays wherever it parses back, a blank line going in only
|
||||
where the nested list would be swallowed — `interruptsParagraph` inverted from a refusal
|
||||
into the separation it names, and `spec/flavour.md`'s "none between a nested list and a
|
||||
CommonMark block above it" gaining that exception. Every fixture spelled tight today keeps
|
||||
its bytes, and `nested-list-tight` becomes the round-trip pair `nested-list-separation`.
|
||||
- [x] **3d — Inline text.** The inline scanner over a block's content: backslash escapes, entity
|
||||
references decoding to their characters, code spans and the literal they hold — directive
|
||||
syntax and `~~` included — CommonMark's own hard breaks, a trailing backslash and two
|
||||
trailing spaces alike, a soft line break as one space, the fenced info string's own decoding
|
||||
the block walk leaves raw, and the raw inline tag, comment and processing instruction
|
||||
refused by name, recognized by the `commonmark-grammar.ts` predicates the emitter already
|
||||
escapes against, under 3b's one-table rule.
|
||||
**Settled** (the maintainer, 2026-08-30): entity references decode against HTML5's whole
|
||||
named table, checked in packed (§5) — a curated subset leaves 3k an exception class and a
|
||||
cutoff line nobody can defend. And the escape superset the emitter reads for raw HTML
|
||||
tightens into one precise CommonMark inline reader both directions share, the email
|
||||
autolink parting off as 3e's own predicate: refusing on the superset would refuse
|
||||
`1 <b 2`, a fourth carve-out §4 and the README do not list. `holdsEntityReference` reads
|
||||
the table for the same reason, so `¬areference;` is emitted bare.
|
||||
- [x] **3e — Emphasis and links.** `_`, `*` and `~~` runs through `matchEmphasis` to the `em`,
|
||||
`strong` and `strike` marks; links inline and reference, 3b's definitions resolved here,
|
||||
autolinks, and the image gap's named errors — a titled image, and one amid other text.
|
||||
`spec/flavour.md` does not yet pin `~`'s `can_open`/`can_close`, which is transcription
|
||||
rather than a decision: `delimiterFlags` already gives it CommonMark flanking, as for `*`,
|
||||
and §8 fixed that the moment the emitter shipped.
|
||||
**Settled** (the maintainer, 2026-08-27): 1d's deferred pair takes the backslash inside
|
||||
the delimiters it already has — `[a](https://example.com/a\)b)` and
|
||||
`[a](/url "He said \"hi\"")`. `<…>` stays reserved for the destination holding a space,
|
||||
where nothing else works, so each construct keeps one spelling and a destination holding
|
||||
both composes. `link-destination-parenthesis` and `link-title-quote` become round-trip
|
||||
pairs.
|
||||
**Settled** (the maintainer, 2026-08-31): the destination escapes only the parenthesis it
|
||||
leaves unbalanced, so `/wiki/Foo_(bar)` keeps its bytes, and the backslash stays refused in
|
||||
both the destination and the title — which leaves `[a](/a\b)` a §2 hole 3k's exception list
|
||||
answers, as `<http://x?a=1&b=2>` is, autolinks decoding neither escapes nor references.
|
||||
The image gap mints `unmappable-image`, mirroring `unmappable-html` — a construct in input
|
||||
no ADF node carries. And the CommonMark image shape lands here rather than at 3h: once
|
||||
`[…](…)` reads, a lone `` would otherwise misparse as text plus a link, so 3h
|
||||
keeps the rest of the media family and loses only that line.
|
||||
**Settled** (the maintainer, 2026-08-31, on the review): an empty link text — `[](/u)` —
|
||||
leaves the brackets the text they are rather than minting a refusal or dropping the
|
||||
destination, giving the label back the way an unresolved pair does, so the shortcut behind
|
||||
`[][r]` still reads. A description holding an image flattens to that image's own alt, which
|
||||
is what alt text means and what keeps the documented gap to mid-text and titled images; a
|
||||
break of either kind inside one reads as a space. And a destination or title whose entity
|
||||
reference decodes to a control character — `[a](/x y)` — joins 3k's exception list
|
||||
beside the two above: the reader takes cmark's reading, the emitter has no spelling for it.
|
||||
- [x] **3f — The directive grammar.** The three forms — inline `:name[content]{attrs}`,
|
||||
container `:::name arg {attrs}`, leaf `::name arg {attrs}` — the attribute grammar with
|
||||
its quoting and escapes, the fence-length and nesting rules, and the malformed list
|
||||
`spec/flavour.md` spells, each a named error. `corpus.test.ts`'s `fenceNestingFault` stays a
|
||||
second reading of the fence rule over emitted bytes: the double entry is the check.
|
||||
**Settled** (the maintainer, 2026-08-27): the code span, the entity and raw HTML bind
|
||||
first in input, as 2e3 already assumed of the emitted side — a raw `` ` ``, `&`, `<` or
|
||||
`|` inside `{attrs}` breaks the directive and is a named error, the author writing the
|
||||
`\u0060` the emitter writes. One precedence covers both directions, and CommonMark's own
|
||||
ordering stays untouched.
|
||||
**Settled** (the maintainer, 2026-09-01): a directive whose name reads back to no node
|
||||
takes its own code, `unknown-directive-name` — a well-formed spelling the vocabulary does
|
||||
not hold is not a malformed one, and §8's "erroring input gaining meaning later is MINOR"
|
||||
is what a consumer switches the two apart for. And input reads canonical spacing only: one
|
||||
space parting the name, the argument, `{attrs}` and each attribute pair, no padding inside
|
||||
the braces, trailing whitespace on a directive block line tolerated — §8 makes loosening a
|
||||
MINOR, so strict is the reversible direction. `directive-attributes.ts` becomes
|
||||
`directive-syntax.ts` with the readers in it: the whole directive grammar, both
|
||||
directions, beside the escaping regexes and the spellings it must not drift from. And the
|
||||
500-level guards compose here for the first time — a recursive reader stacked on the block
|
||||
walk — so `nesting-depth-composed` pins both axes now rather than waiting for 3i's third.
|
||||
A closing fence closes the innermost open container however long its run, which
|
||||
`spec/flavour.md`'s closing-fence sentence now says: a run reaching past the innermost
|
||||
leaves the fence it did not close a named error, which §2 prefers to closing more than the
|
||||
author wrote.
|
||||
- [x] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first
|
||||
directory to. A parsed directive becomes its node: the name to the type and an unknown one
|
||||
to a named error, the arg to the attribute it names, each value to the type its section
|
||||
assigns, the body to `content`, the reserved `marks` key to the marks array. 3a's drift
|
||||
guard is built here if the answer there was yes.
|
||||
3f leaves two here: `Read<T>` moves to `src/result.ts` once a second reader takes it, and
|
||||
the reserved `adf` name in block position needs an error of its own — 3f reports it as
|
||||
`unknown-directive-name`, which §8 makes the signal that a later MINOR may give the name
|
||||
meaning, and `adf` never will.
|
||||
**Settled** (the maintainer, 2026-09-01): the reserved `adf` name in block position is a
|
||||
`malformed-directive` — the grammar section states the reservation, so it is that spelling
|
||||
the name breaks — and a well-formed directive the tables refuse is `unsupported-node-shape`,
|
||||
the emitter's code for the same mismatch read the other way; AGENTS.md §8 carries the
|
||||
split. And input reads canonical `{attrs}` alone, keys in order and every value spelled as
|
||||
the emitter spells it, the error naming the spelling to write instead: §8 makes loosening a
|
||||
MINOR, so strict is the reversible direction, as 3f already settled for spacing.
|
||||
**Settled** (the maintainer, 2026-09-01, on the review): 2f's plain-versus-directive
|
||||
choice is read back here rather than at 3h — a directive spelling a node CommonMark holds
|
||||
is refused, so `::rule` and `:::blockquote` are errors while `::rule {localId=…}` is not.
|
||||
The parser asks `spellsCommonMark`, the emitter's own choice, rather than restating the
|
||||
per-node conditions: a copy would refuse the list whose first item reads back as a
|
||||
thematic break, which the emitter does spell as a directive, and §2 breaks in silence.
|
||||
Two refusals land here for a later chunk to lift, on the same rule: the inline `[content]`
|
||||
slot, which 3i opens for `emoji`, `mention` and `status`, and the `codeBlock` content
|
||||
model's fenced body, 3h's. `Read<T>` stays where 3f left it — the node reader knows its
|
||||
path and returns `Result`, so no second reader took it. The drift guard earned itself on
|
||||
the way in: the spec's `text` attribute was missing from three inline table entries, which
|
||||
the content slot spells and the vocabulary walk already passes over.
|
||||
- [x] **3h — The block nodes.** `block-nodes/` reads back: the `codeBlock` directive's fenced
|
||||
body and the `language` attribute a bare fence leaves it; the media family's composition;
|
||||
and both table forms, the pipe table's cell split and its named errors. `fenceInfo` is a
|
||||
rule both directions answer alike and moves to the `markdown/` root with the language
|
||||
attribute.
|
||||
**Settled** (the maintainer, 2026-08-27): 1d's last pick, the one
|
||||
`container-block-separation` holds — a CommonMark block and a directive block sit adjacent
|
||||
in a container body with no blank line between them. That reduces the three cases to one
|
||||
rule, separation only where its absence would merge the blocks: the `:::` fence is
|
||||
separation already, and 3c's claim ends the lazy continuation that would otherwise swallow
|
||||
it. The fixture becomes a round-trip pair, and with `nested-list-separation` and 3e's pair
|
||||
that empties `corpus/unspellable/`: this chunk settles the directory's own guard in
|
||||
`corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here.
|
||||
The emitter's other refusals survive on causes no fixture in that directory covers, so
|
||||
3k's one-list pass is where they get fixtures or the directory goes.
|
||||
**Settled** (the maintainer, 2026-09-01): losing that cause closed one of the shapes input
|
||||
accepted and emit refused, not the last. Two adjacent lists of a kind are what
|
||||
`adfToMarkdown` refuses and one `- ` spelling cannot hold apart, and the walk reached them
|
||||
two ways — a marker change, which CommonMark opens a second list on, and an empty last item,
|
||||
whose blank line pops the container the list's identity hung from. The parser opens no list
|
||||
beside one of its own kind instead, the way it already drops the blank lines between items;
|
||||
3k owes the CommonMark suite an exception where the reference HTML holds two `<ul>`. The
|
||||
`normalization/` arm emits each document and reads it back from here, so the population that
|
||||
class lives in is checked rather than read. The README's canonical-fixpoint sentence still
|
||||
claims more than the parser keeps — 3e's three shapes — which stays milestone 5's to
|
||||
narrow.
|
||||
- [x] **3i — The inline nodes and the marks.** `inline-nodes/` reads back: the content slot's
|
||||
`text` attribute and the error a slot holding anything but one unmarked text node is; the
|
||||
`:text{text="…"}` whitespace spelling; the four directive marks and their nesting order,
|
||||
outermost first; and `:em[x]` as the error `spec/flavour.md` promises. Editor-normal's
|
||||
merging half lands here, `text-whitespace` being the first fixture that forces it, and 4's
|
||||
`toEditorNormal` is built on it.
|
||||
3g's shape leaves three: `readInlineDirectiveNode` takes the name, the attributes and the
|
||||
slot's parsed text rather than the span, since `inline-content.ts` already imports it and
|
||||
parsing the slot inside it is a cycle; the four directive marks get `parse/directive-marks.ts`
|
||||
that `inline-content.ts` tries ahead of the node reader, as `mark-spellings.ts` sits apart
|
||||
from `emit/inline-directive-spelling.ts`; and the five markdown-spelled mark names in inline
|
||||
directive position take `unsupported-node-shape` rather than a code of their own — §8
|
||||
already answers a well-formed directive the node tables refuse, and the message names the
|
||||
spelling to use (`*x*`), while `unknown-directive-name`'s "a later MINOR may give the name
|
||||
meaning" stays the wrong signal, as it was for `adf`. `corpus/errors/directive-content-slot` goes when the slot opens.
|
||||
The marks a spelling wraps answer the same question 3g settled for a block's form: only the
|
||||
nesting the emitter writes parses back.
|
||||
**Settled** (the maintainer, 2026-09-01): `:text` reads back what the emitter writes and
|
||||
nothing else — one run of spaces and tabs, or one run of newlines. A mixed run, and text
|
||||
CommonMark carries plainly, are named errors, as 3g refuses the directive form of a node
|
||||
CommonMark spells. The reader takes the slot's parsed nodes rather than its text, so the
|
||||
rule refusing anything but one unmarked text node sits beside the node tables that own the
|
||||
slot. Only `text`, read ahead of the slot, names a refusal before the slot's own: a
|
||||
doubly-broken span reports what its content holds, `:date[<div>]{timestamp=1}` being
|
||||
`unmappable-html` rather than `date takes no content`, which the maintainer pinned with an
|
||||
assertion rather than reordering the readers. `directive-content-slot` stays with the
|
||||
fixtures, its cause now a marked slot rather than a slot at all. The slot's own whitespace
|
||||
answers the rule the spelling does: `:text{text="\n"}` and ` ` alike reach a slot the
|
||||
emitter refuses a line ending in, so one function answers both directions.
|
||||
**Settled** (the maintainer, 2026-09-01): a name the other position spells names that
|
||||
spelling rather than reading as unknown — `:::em` and `::date` take
|
||||
`unsupported-node-shape` naming the inline form, `:paragraph[a]` the block one — leaving
|
||||
`unknown-directive-name` for a name no table holds, which is the meaning §8 gives it. The
|
||||
two readers lean on the tables being disjoint, so that is a test beside the spec drift
|
||||
guard now.
|
||||
The same read found the hole the other way: `attemptLine` refused a line edged with a
|
||||
vertical tab or a form feed, where CommonMark strips spaces and tabs alone, so valid
|
||||
CommonMark parsed to a document `adfToMarkdown` then refused. The edges that check covered
|
||||
are carried before the line is assembled, so narrowing it to spaces and tabs left it no
|
||||
cause and it goes with them.
|
||||
- [x] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back:
|
||||
the `adf` fence and `:adf{json="…"}` restoring a deep-equal node, invalid JSON in either a
|
||||
named error, a carry inside a mark spelling another, and the three carve-outs' escapes
|
||||
reading as the literal text they hold. 3g refuses the `adf` fence rather than reading a
|
||||
`codeBlock` from it; the refusal goes when the carry reads it. 3i left the slot parse
|
||||
contextless, so the refusal a carry inside a mark spelling earns needs a channel — a reader
|
||||
context in place of `parseInline`'s `strip` flag, or a return arm from the slot — and
|
||||
`directiveNodes` takes its fourth reader beside it.
|
||||
`index.ts` gains `markdownToAdf` here, and the README's status line with it: this is the
|
||||
last parser chunk, so `parsingDirectories` becomes `emittingDirectories` and the whole
|
||||
corpus round-trips both ways — `0.1.0`'s proof, which 4 widens rather than replaces.
|
||||
- [x] **3k — The CommonMark spec suite (`0.2.0`).** Checked in at `corpus/commonmark-spec/`,
|
||||
pinned to the version it ships — the one `commonmark-grammar.ts` names for its start
|
||||
conditions — `corpus/README.md` gaining the kind.
|
||||
**Settled** (the maintainer, 2026-08-27): three checks an example must pass, the reference
|
||||
HTML each ships read as corpus data — which adds no format and no direction (§1). §2's
|
||||
canonical fixpoint: a named error, or markdown that parses and emits to itself byte for
|
||||
byte. That HTML's text, tags stripped and entities decoded, against the parsed document's
|
||||
concatenated `text`. And a count of the dozen elements the CommonMark subset covers
|
||||
against the marks and nodes they map to — counting distinct mark types per text node, since
|
||||
3e collapses a spelling nested inside its own kind and `*(*a*)*` is two `<em>` against one
|
||||
`em`. The fixpoint alone is self-consistency a parser
|
||||
returning the empty document passes, and the text alone one dropping every emphasis; the
|
||||
counts close both. The exception list stays the maintainer's, and one entry is owed
|
||||
already: 3h continues a list across the marker change CommonMark splits on, so an example
|
||||
the reference HTML gives two `<ul>` counts one `bulletList`. One outcome is no
|
||||
exception and must not be filed as one: a fixable §2 hole — valid CommonMark parsing to a
|
||||
document `adfToMarkdown` refuses — which is what `corpus/unspellable/` held until 3c, 3e
|
||||
and 3h landed their answers and emptied it. The permanent ones — a link destination or
|
||||
title no escape spells, a paragraph opening with a code span — are the exceptions, named
|
||||
by AGENTS.md §2.
|
||||
- [x] **4 — Round-trip property tests (`0.2.0`)**, widening 3j's corpus round-trip past the
|
||||
documents a human wrote — the thing that proves 2 and 3 beyond them.
|
||||
**Settled** (the maintainer, 2026-09-13): `fast-check` generates and shrinks. The gate runs a
|
||||
fixed seed, the properties together adding about five seconds per engine; an environment
|
||||
variable raises the runs and randomizes the seed for local digging, and a counterexample
|
||||
found becomes a round-trip fixture. The generators draw from the node tables — each node's
|
||||
content model and attribute vocabulary as `adf/` records them, which 11 holds to Atlassian's
|
||||
schema — and misplace a share of nodes so the carry (§3) is exercised; no JSON Schema walker
|
||||
enters the tests. `toEditorNormal` stays internal. 2e5's collision test goes, since a
|
||||
collision already fails the round-trip on the same fixtures; the fixture-duplicate test
|
||||
stays.
|
||||
- [x] **4.1 — Editor-normal and the node accessors.** `toEditorNormal(doc)` in
|
||||
`src/adf/editor-normal.ts`, on 3i's merging: adjacent text nodes carrying identical marks and
|
||||
no attributes merged, an empty `attrs`, `marks` or `content` the absent key, `-0` read as `0`
|
||||
(§2); the round-trip tests compare the parser's output through it, and `serializeCanonicalJson`
|
||||
beneath it walks iteratively. `nodeContent`/`nodeAttrs`/`nodeMarks` replace the 46 inline
|
||||
`?? []`/`?? {}` reads in `src/` (23 `content`, 12 `marks`, 11 `attrs`) and the `attrs?.[key]`
|
||||
reads, and the branch floor rises to the integer floor of what the suite then measures.
|
||||
**Settled** (the maintainer, 2026-09-14): a text node carrying attributes never merges —
|
||||
`0.1.0` merged a carried one into its neighbour on read-back — and the fix lands here, as does
|
||||
the iterative serializer.
|
||||
- [x] **4.2 — The ADF property.** `fast-check` joins `devDependencies`, AGENTS.md §5 naming what
|
||||
it earns — shrinking a failing document to the nodes that break it — and §10 the properties
|
||||
beside the corpus. A generated editor-normal document either refuses in `adfToMarkdown`
|
||||
with a `ConvertError` or reads back through `markdownToAdf` to an equal document, and
|
||||
nothing throws, under Node, Deno and Bun alike. 2e5's collision test is deleted.
|
||||
**Settled** (the maintainer, 2026-09-14): about half the block positions draw attribute-less
|
||||
CommonMark shapes — single-type lists, headings, blockquotes, pipe-table-shaped tables — where
|
||||
the escaping lives. A round-trip break the property finds is fixed inside 4.2, one commit per
|
||||
break with its round-trip fixture seen red first, and 4.2 lands when a deep run of about
|
||||
10,000 per engine passes clean; a break needing design goes to the maintainer. The first two,
|
||||
both shipped in `0.1.0`: an empty `href` with a title spelled `[a]( "")`, which reads back as
|
||||
the href `""`, and a `[` or `]` in a link's destination or title inside a directive mark,
|
||||
refused as the emitter's own output or, with `]`, losing the link. Later, settled the same
|
||||
day: an autolink whose href holds a backtick takes the `[text](url)` form inside a
|
||||
directive's content; and a V8 fault the deep runs hit — once `JSON.parse` has read a key
|
||||
holding an escaped backslash, a later escaped quote or newline key comes back as that
|
||||
backslash, on Node and Deno but not Bun — is accepted rather than worked around, since the
|
||||
library only refuses such a document, so the generators' JSON keys avoid those characters; the
|
||||
fault is reported upstream (https://issues.chromium.org/issues/521080746, nodejs/node#63785),
|
||||
where the maintainer added to both on 2026-09-14. The review found one more break of the
|
||||
same class, fixed the same way: a would-be inline directive in a link target inside a
|
||||
directive's content.
|
||||
- [x] **4.3 — The markdown property.** Generated markdown through `markdownToAdf` never throws,
|
||||
and the runs fit the budget; where it parses and `adfToMarkdown` spells the result, that
|
||||
spelling parses and emits to itself byte for byte (§2).
|
||||
**Settled** (the maintainer, 2026-09-15): the generators and run parameters 4.2 and 4.3
|
||||
share live in one test-only module in `src/`, kept out of the build and coverage, with
|
||||
10c's properties as its third user. Under the gate seed the property asserts floors on the
|
||||
runs reaching the fixpoint and on the directive-shaped ones. It lands when hunts of several
|
||||
hundred thousand runs per engine pass clean, since the breaks hit once per ~150,000 runs,
|
||||
past a 10,000-run bar. Two breaks, both shipped in `0.1.0`, are fixed inside it. A backtick
|
||||
string an escape formed closed an earlier bare run's code span, since CommonMark reads no
|
||||
escape inside one: an escaped backtick alone or, as the review found, one joined to the bare
|
||||
run after it. A backtick run now escapes whole, and a bare run escapes wherever a later
|
||||
string of its length forms around an escape in the same inline content: the generalized pass
|
||||
the maintainer chose (2026-09-15). A paragraph's opening read as a link
|
||||
reference definition across a `]` the emitter spelled: the emitter now escapes the opening
|
||||
`[` exactly when the parser's own definition reader accepts the paragraph, and a link
|
||||
opening it rides the carry until 13b.
|
||||
- [x] **4.4 — The real payloads.** `corpus/real-payloads/` holds ADF Atlassian's editor wrote,
|
||||
each round-tripped ADF→markdown→ADF with no expected markdown.
|
||||
**Settled** (the maintainer, 2026-09-15): the chunk authors the payloads itself on the
|
||||
maintainer's Atlassian test site — invented content, so nothing needs sanitizing — driving the
|
||||
editor with Playwright, and reads the ADF back over REST. Only the documents are committed; no
|
||||
client or fetch script enters the repo (§7). Later, settled the same day: the mentions keep
|
||||
the test user's real account id (the maintainer, 2026-09-15).
|
||||
- [x] **4b — The block walk's retry (`0.2.0`).** `emitBlock` walks a subtree twice wherever
|
||||
`readableBlock` reads it whole and then gives up — a list item whose first line reads back
|
||||
as a thematic break — and the walk below does the same, so the cost doubles per level:
|
||||
3.4kB of nested lists takes half a second, depth 20 about eight, depth 24 minutes. It
|
||||
predates 3g on both directions, and 3g's `commonMarkSpelling` gave it a second entry point.
|
||||
The README's bot and pipeline personas feed markdown nobody typed, so this ships as a hang
|
||||
on a small input; §11's scanning rule is the same argument one shape further in. The retry
|
||||
is what to remove — one walk answering both the readable question and the directive
|
||||
fallback. Memoizing `emitBlock` is the shortcut, and the node reference is the wrong key: a
|
||||
caller may hold one node object at two positions, where the cached depth and path are
|
||||
another node's. `0.1.0` ships with the retry in it, so a deep document is slow rather than
|
||||
wrong until the patch. `adfDocumentFault` is the second site to look at: `isNodeArray` reads
|
||||
every node and attribute value, then `nestingFault` reads them again, so the emit entry the
|
||||
export persona runs in bulk walks the document twice. Both walks are linear, so this is a
|
||||
constant factor rather than 4b's class change, and the parting is what gives depth its own
|
||||
code (§8) — measure before joining them back.
|
||||
**Settled** (the maintainer, 2026-09-18): the limit stays 500 readable lists, the walk
|
||||
reporting its headroom (§11). Counting every list twice was rejected for halving the limit,
|
||||
counting the directive form once for doubling the parser's frames per level.
|
||||
**Measured** (2026-09-18): `adfDocumentFault` walks a 9 MB document in 52 ms against 314 ms
|
||||
for the emit, so its two walks stay parted.
|
||||
- [x] **4c — The scanning rule's remaining sites (`0.2.0`).** A trailing-anchored regex re-walks
|
||||
its run from every start position, so an interior whitespace run costs quadratic time rather
|
||||
than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk
|
||||
replaced the regex. The sites the same sweep did not reach: `normalizeLabel` in
|
||||
`link-syntax.ts`, whose shortcut-reference input is `scan.source.slice(...)` rather than the
|
||||
999-capped `readLabel` value, and `carryEdges` in `emit/inline-line.ts`. A third of another
|
||||
shape joins them: `readNestedDirective` restarts its depth counter per level, so each parse
|
||||
level re-scans the region below it and nested inline directives cost O(depth × content),
|
||||
bounded by the 500-level guard. A fourth predates 12c: the list-item walk re-scans the rest
|
||||
of a line once per item level — `isThematicBreak` in `containerStart` on an opener line,
|
||||
`isBlankLine` and `leadingColumns` in `continuesContainer` on a continuation line, and a
|
||||
blank line continues every open item without consuming input; 30000 nested items take 4.4s
|
||||
at 59 KB (the stability-reviewer, 2026-09-16). §11's scanning rule is the whole argument; the
|
||||
pipeline persona feeds documents nobody typed. A fifth is a throw rather than a cost:
|
||||
`adfDocumentFault` pushes a node's content with a spread, so past about 125k sibling nodes
|
||||
the guard throws a `RangeError` where §11 owes a `Result` (the stability-reviewer and the
|
||||
maintainer, 2026-09-18).
|
||||
**Settled** (the maintainer, 2026-09-18): the five sites land in one PR rather than split
|
||||
into sub-items, and a behaviour-preserving cost fix is accepted on the suite staying green
|
||||
with no fixture output changed, plus the measurement below — §14 promises no figure, so
|
||||
nothing times the gate. The guard's spread is the one behavioural fix and carries a test.
|
||||
**Corrected** (2026-09-18): the entry filed two sites in `emit/inline-line.ts` on 2026-09-01
|
||||
and the file has changed since — `tryImageLine`'s alternation measures linear (3.4 / 1.9 /
|
||||
5.3 ms over 10k / 20k / 40k spaces), leaving `carryEdges`' trailing trim the only one.
|
||||
**Measured** (2026-09-18), each at the size its filing named: `normalizeLabel` 1026 ms → 5 ms
|
||||
at 40k interior spaces, `carryEdges` 1024 ms → 7 ms (its heading path 978 ms → 5 ms),
|
||||
`readNestedDirective` 434 ms → 10 ms at 397 kB and 200 levels, the list-item walk 4196 ms →
|
||||
39 ms at 30000 items, and the document guard a `RangeError` → 42 ms at 200k siblings under
|
||||
one node. The list-item walk's mixed-marker shape, which the fix had to answer too, reads
|
||||
38 ms where the tail scan alone would have left it quadratic.
|
||||
The sixth site the sweep found went to 18 rather than landing here (the maintainer,
|
||||
2026-09-18).
|
||||
**Left as is** (the stability-reviewer, 2026-09-18): of the list-item walk's three re-scans
|
||||
only `containerStart`'s is fixed. `continuesContainer`'s pair costs the same either way — 400
|
||||
levels at 627 kB read 469 ms before and 448 ms after, linear in the line count and only
|
||||
mildly superlinear in a depth the 500-level guard bounds — so it is measured and left rather
|
||||
than made an item.
|
||||
**Widened** (the systems-architect, 2026-09-18): the guard's spread was a class rather than a
|
||||
site, and two more threw out of the public API — `readIndentedCodeLine` releasing the blank
|
||||
lines an indented code block held (200k of them at 200 kB), and `emitRun` joining a mark
|
||||
run's segments (200k nodes under one mark). Both fixed here with the same loop and a test
|
||||
each, and §11 gained the rule so the spelling cannot walk back in.
|
||||
- [x] **4d — What the gate says while it runs (`0.2.0`).** `ci.sh` runs nine legs and announces
|
||||
none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
|
||||
a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its
|
||||
own fix. The legs need markers: `plainpages`' `ci.sh` prints a `step()` header per leg and
|
||||
this one prints nothing, so name the leg and the image before each. The longest leg is the
|
||||
quietest: `test_output=$(… npm test 2>&1)` buffers the whole Node run to replay it after,
|
||||
because the zero-test guard greps the count — stream it and grep a copy (`tee`), rather than
|
||||
trading the output for the guard. And two legs are silenced outright, `npm pack` and the
|
||||
tarball install, whose `>/dev/null` predates the offline install that made them quick and
|
||||
quiet. `publish.sh` owes the same: today it says nothing between reading `private` and the
|
||||
registry answering, which is where its `npm ci` and rebuild sit — the seconds §9 accepts
|
||||
rather than promoting the gate's `dist`, and unmeasured until the log shows them. Per-leg
|
||||
timing is what turns "slow or hung" from a guess into a reading; the browser leg's own
|
||||
5.4–7.9s against a 17s warm gate is the number that made it obviously cheap.
|
||||
**Measured** (2026-09-20): ten legs, not the nine counted above, each naming the image it runs
|
||||
in where it runs in one, on a 28.4 s warm gate — install 1.5 s, typecheck 1.2 s, Node tests
|
||||
4.9 s, Deno 6.0 s, Bun 4.5 s, build 1.0 s, pack and install 1.6 s, consumer typecheck 1.0 s,
|
||||
engines floor 0.4 s, browser 5.6 s. The browser leg lands in the 5.4–7.9 s the item quotes,
|
||||
and the markers cost nothing measurable: 28.9 s before against 28.4 s after. `publish.sh`
|
||||
reads its fields in 2.6 s and the registry in 1.4 s; its `npm ci` and rebuild are the gate's
|
||||
own 1.5 s and 1.0 s, so the seconds §9 accepts for rebuilding rather than promoting the gate's
|
||||
`dist` are about 2.5.
|
||||
Four things the writing turned up, three of them bash scoping a rule differently than it
|
||||
reads. The markers print to stderr, so a leg whose value is read — `publish.sh` asking npmjs —
|
||||
stays capturable. `leg`'s locals carry its own name because bash scopes them into whatever the
|
||||
leg runs: unprefixed, `name` was swallowed by the leg reading `package.json`. `leg` returns
|
||||
its command's status the way `with_firefox` already did, because the bare call dropped a
|
||||
non-zero one wherever `set -e` is suspended, which also gets the elapsed time printed for the
|
||||
leg that failed. And the `||` that captures that status suspends `set -e` for everything the
|
||||
leg calls, so a function a leg runs chains its statements with `&&` or every statement but the
|
||||
last runs unchecked: `read_package_fields` read on past a failed read, and `push_tag` pushed a
|
||||
tag the tag step had refused to write, both of which aborted before this chunk (the
|
||||
stability-reviewer, 2026-09-20). §10 carries the rule so the next leg cannot reintroduce it,
|
||||
and `EPOCHREALTIME` is guarded at `source` so an older bash names itself rather than dying as
|
||||
an unbound variable on the first leg.
|
||||
|
||||
- [x] **5a — Rename to `@larvit/adf-codec` (`0.1.0`).** Before the first publish, the name being
|
||||
the published identity: `package.json` `name` and `repository`, the Gitea repo and its
|
||||
remote, the README title, §6's published-as line, the checkout directory.
|
||||
**Settled** (the maintainer, 2026-09-01): ADF's own `A` is "Atlassian", and "converter" is
|
||||
the one-way lossy tool §2 exists to replace, where a codec is both directions. It names the
|
||||
hub, not the formats around it.
|
||||
- [x] **5b — The consumer's error surface (`0.1.0`).** A product-owner read of the public surface
|
||||
found the error result legible to the library and opaque to the consumer holding it, and the
|
||||
README documenting no part of it. The sub-items are that read's answers, and they land before
|
||||
5 because §8 freezes the code list at `0.1.0` and 5b4's table is what reads the list before
|
||||
the freeze closes it.
|
||||
- [x] **5b1 — The error's source position.** A parse error names an ADF path into a document the
|
||||
caller does not hold yet — `unmappable-html` at `["content", 5]` for a `<span>` on line
|
||||
12 — and no coordinate into the markdown string it passed in. `ConvertError` gains an
|
||||
optional `position` the parser carries to every parse-side mint, and the README's published
|
||||
shape gains it.
|
||||
**Settled** (the maintainer, 2026-09-03): the position is the parse side's alone — an
|
||||
emitter has no source string to point into, so emit-side errors keep `path` unchanged. The
|
||||
representation and where the position is captured are implementation judgment.
|
||||
The block walk mints it and the node walk attaches it as results return — at `blockNodes`,
|
||||
and at the inline body a directive holds — so the innermost block wins and the emitter's
|
||||
own refusals, which the parser re-enters for the CommonMark spelling, get an input
|
||||
coordinate too. `markdownToAdf` wraps the walk once more, which is what turns the wide
|
||||
`Result<T>` into the `Result<T, ParseError>` its signature promises rather than guarding
|
||||
anything: the depth guard under it cannot fire at depth 0. A paragraph names the line its
|
||||
kept text starts on, never a link reference definition it gave up. Line endings stay as
|
||||
the input spells them, so an offset indexes the string the caller passed rather than a
|
||||
normalized copy of it. §8 records the framings the review settled beside it:
|
||||
`unsupported-node-shape` stays one code across the two directions, `unmappable-html` names
|
||||
the version rather than the element, and a direction that reads a source returns the
|
||||
narrowed error type.
|
||||
- [x] **5b2 — The error messages.** Most state the rule and leave the violation to be inferred —
|
||||
`a text node holds text` for a node holding none — so `rule: violation` becomes house style
|
||||
across the sites that do. `not-an-adf-document` gives one sentence of eight words to `null`,
|
||||
a string, a missing `version`, a `type` that is not `doc` and a REST envelope around the
|
||||
document; naming the check that failed makes the highest-frequency integrator mistake
|
||||
self-diagnosing without the library naming a REST shape (§7). The three carve-out claim
|
||||
messages name the escape that unclaims the line — `\|`, `\~~`, `\:::` — which today only
|
||||
`spec/flavour.md` holds. `unmappable-html` reads as §8 now frames it: this version converts
|
||||
no raw HTML, never a permanent judgment on the element.
|
||||
Thirty-odd sites gained the violation clause and §8 gained the house style. `isAdfDocument`
|
||||
parts into `adfDocumentFault`, the guard reading it, so the first failing check is the
|
||||
message — the wrapper mistake names the key it found. Two carve-outs claim a line, not
|
||||
three: a matched `~~` pair spells `strike` silently, so nothing refuses it and no message
|
||||
names `\~~`. The escape lands on the refusals a prose line hits, in the form that was
|
||||
claimed — `\:::` on the directive line's, `\|` on the pipe table's, `\:` on the inline
|
||||
directive's, the attribute-pair and unknown-name faults taking whichever form read them.
|
||||
`a pipe table row holds 1 cells` gained its plural.
|
||||
- [x] **5b3 — The code list and the flavour's gaps.** A second read of the surface, this one on
|
||||
the fifteen names §8 freezes at `0.1.0`: two pairs of them are one cause each, and one
|
||||
names a state the flavour leaves no way out of. `unspellable-character` and the text half
|
||||
of `unspellable-whitespace` are one refusal — a character CommonMark rewrites, the message
|
||||
naming it — and merge, `unspellable-whitespace` keeping the code for its other cause, the
|
||||
content slot no inline directive spans. `unspellable-link-destination` and
|
||||
`unspellable-link-title` become `unspellable-link`, the message naming the attribute.
|
||||
`unspellable-adjacent-lists` goes entirely: two adjacent `bulletList` nodes are valid ADF a
|
||||
site writes, and refusing them leaves the viewer persona a document it cannot render at
|
||||
all, so the flavour gains the separator that spells the pair apart, both directions,
|
||||
`spec/flavour.md` and fixtures. Thirteen codes stand — `unspellable-whitespace` keeps its own.
|
||||
The bare pipe table — `a | b` over `--- | ---`, GFM's shape without the leading pipes — is
|
||||
the one input that loses structure silently, reading back as a paragraph of prose; it
|
||||
becomes a `malformed-pipe-table` naming the form a row takes. That code keeps its name for
|
||||
the alignment colon: the flavour's own delimiter row is `-` runs, so the grammar is what
|
||||
refuses, and §8 records it rather than answering it again each review. The ninth
|
||||
`adfDocumentFault` branch names no node and carries the document's own path, the one branch
|
||||
the other eight outshine; §8 records why the guard stays a boolean.
|
||||
`::listBreak` is the separator's spelling: the grammar's leaf form, and a second reserved
|
||||
name beside `adf` — every other name is an ADF node or mark type, and this one builds none.
|
||||
It reads only between two adjacent lists of one type, and takes the separation any
|
||||
directive block takes where it sits, so a directive container holds it with no blank line.
|
||||
A hard break is the one spelling that can put a bare delimiter row under a row of its own,
|
||||
so the emitter escapes that line's first character rather than refusing the document.
|
||||
`spec/flavour.md` had two directive blocks inside a container taking no blank line; the
|
||||
rule both directions keep is that a pair holding one takes none.
|
||||
- [x] **5b4 — The README's consumer surface.** §8 invites an exhaustive switch on `code` and no
|
||||
code name appears in the README, so it gains a table — code, when it fires, what the
|
||||
consumer does — grouped by direction, over the thirteen names 5b3 settled. Four things a
|
||||
reader who has not opened the code cannot know: raw
|
||||
HTML is core CommonMark and every construct in input is an error until `0.3.0`, which the
|
||||
guarantees' "three carve-outs and one gap" denies and which is the bot and LLM personas'
|
||||
most common failure; `adfToHtml`, `htmlToAdf`, `markdownToHtml` and `htmlToMarkdown` sit
|
||||
unmarked in the code block people copy from, as do the two HTML guarantee bullets, and take
|
||||
a `0.3.0` mark or leave the block; `adfToMarkdown` is partial on valid ADF — a text node
|
||||
holding a carriage return, a link destination no canonical escape spells — which the viewer
|
||||
persona needs told along with
|
||||
what to do about it; and GFM past tables and strikethrough is literal text, task lists
|
||||
taking `:::taskList`. One sentence for the LLM persona: `code` is stable across minors,
|
||||
`message` is free text. The type-level surface freezes at the same moment and gets the same
|
||||
read: what `index.ts` exports and what it withholds, `ParseError` against `ConvertError`
|
||||
where a direction reads a source, and `ConvertFault` staying internal — the README table
|
||||
names the shapes a consumer switches on, so the two audits are one.
|
||||
The direction grouping is read off the call sites rather than the code prefixes, which do
|
||||
not partition by direction: the parser asks the emitter which CommonMark spelling a node
|
||||
takes (§11), so six codes reach a `markdownToAdf` caller as well as an `adfToMarkdown` one.
|
||||
The trailing pipe of a pipe-table row is optional in input, not required; the leading one
|
||||
is what every row must carry.
|
||||
- [x] **5c — The build and the release pipeline.** Split out of 5, which kept only the
|
||||
maintainer's own acts. The build: `tsconfig.build.json` gains emit of JS and `.d.ts` to
|
||||
`dist/` (its own `allowImportingTsExtensions` forces `noEmit`, so
|
||||
`rewriteRelativeImportExtensions` lands beside it), plus `exports`/`files` in
|
||||
`package.json`. Publish-on-version-change (§9) as `publish.sh`, run by a `main`-only job
|
||||
needing the gate. The `ConvertErrorCode` freeze (§8) is checkable here: 3h landed the last
|
||||
decision `corpus/unspellable/` held and the directory went with it, so what the code list
|
||||
holds from here is permanent. The parser's own code additions are read here as one list
|
||||
before that freeze — nine sessions mint them independently, and one cause wearing two codes
|
||||
is breaking to undo after `0.1.0`. That read gets a test rather than an eye — every
|
||||
`ConvertErrorCode` member named at a production call site, the way `spec.test.ts` guards the
|
||||
node tables — since `unspelled-block-separation` outlived its cause until 3h went looking.
|
||||
All thirteen have a call site; the audit's find was the depth one 5 predicted, read wrong in
|
||||
its own text: an attribute value past 500 levels was `unsupported-node-shape` on parse and
|
||||
`not-an-adf-document` on emit, the document guard counting the `attrs` object as a level the
|
||||
parser does not, so a value at exactly 500 parsed into a document the emitter then refused.
|
||||
Depth left the shape predicates on both sides: `isJsonValue` structural and `overNested`
|
||||
beside it, `adfDocumentFault` returning the code with the message and `attributeValue` the
|
||||
reason it refused, so both directions answer with `unsupported-nesting-depth` naming the
|
||||
attribute, and `isAdfDocument` calls a deep document a document as it always did a deep
|
||||
block.
|
||||
`engines.node` gets its one-line proof too — the built entrypoint imported and round-tripped
|
||||
under a pinned Node 18 image, which cannot run the suite that type stripping wants 22+ for,
|
||||
but proves exactly what the field claims. Beside it, the emitted `.d.ts` typechecked from a
|
||||
consumer's position: declaration emit leaves the `.ts` specifiers `rewriteRelativeImportExtensions`
|
||||
rewrites in the JavaScript, and nothing else in the repo reads them the way an installed
|
||||
consumer would. 3k's exception list landing after the release left the README's
|
||||
canonical-fixpoint sentence claiming more than `0.1.0` keeps — 3e names three shapes that
|
||||
parse and then refuse — so it now says a parse succeeding is no promise of a way back, and
|
||||
names them.
|
||||
- [x] **5d — The browser leg.** §6's browser half is checkable on the emitted `dist/index.js` a
|
||||
browser can load — the compile gate names no host API, and a real page converting the corpus
|
||||
is the other half. Headless Firefox is that page, settling both at once: the browser proof,
|
||||
and the only SpiderMonkey there is, the gate's three engine legs being two V8s and a
|
||||
JavaScriptCore that is not Safari's. The mechanism is the decision this item opens with: a
|
||||
browser leg wants an image, a driver and a way to carry a verdict back out, none of which
|
||||
the gate's plain `docker run` per engine has. The answer is `with_firefox`, which runs the
|
||||
Firefox image beside the node one in a shared network namespace, so the page's server and
|
||||
the driver are each other's `127.0.0.1` and no user-defined network, container name or
|
||||
geckodriver `--allow-hosts` entry is wanted; its `EXIT INT TERM` trap bakes in the container
|
||||
id, since the `local` holding it is gone by the time the trap fires. `browser-tests/run.js`
|
||||
serves the repo, drives one `execute/sync` and asserts the results against the corpus with the
|
||||
Node-side `assert.deepEqual` the corpus runner uses, so the browser page holds no second copy
|
||||
of the comparison. The whole corpus fits: 118 fixtures in 8s warm against a 120s script
|
||||
timeout — no slice was worth choosing. A `try` around the dynamic import is what turns a
|
||||
broken build into SpiderMonkey's own message rather than an undefined global.
|
||||
**Settled** (the maintainer, 2026-09-04): `selenium/standalone-firefox` over the smaller
|
||||
`instrumentisto/geckodriver`, currency over size — the leg's whole worth is a real
|
||||
SpiderMonkey, which decays the moment the pin stops moving, and the smaller image was four
|
||||
Firefox majors behind with a publisher that may go quiet while Renovate stays silent.
|
||||
- [x] **11 — Atlassian's ADF schema as the tables' truth (`0.2.0`).** `@atlaskit/adf-schema`'s two
|
||||
JSON Schemas vendored rather than the package installed (AGENTS.md §5), and the node tables
|
||||
gated against them (§10). **Settled** (the maintainer, 2026-09-13): vendored at
|
||||
`spec/adf-schema/` and re-pinned by hand when a need shows; the gate compares attribute names
|
||||
and kinds, never value sets, over `full.json` and `stage-0.json` together.
|
||||
- [x] **11a — The vendored schema.** `full.json` and `stage-0.json`, byte-exact from
|
||||
`@atlaskit/adf-schema@57.4.9`'s `dist/json-schema/v1/`, at `spec/adf-schema/`, each pinned
|
||||
by its SHA-256 in a test the way `spec.json` is. The version, the source and the Apache-2.0
|
||||
attribution sit beside them with the licence text; no gate re-serializes either file.
|
||||
- [x] **11b — The gate.** For each node and mark type the tables spell, the attribute names and
|
||||
kinds equal the union over every definition in both files whose `type` enum names it,
|
||||
`anyOf`/`allOf` branches included, the argument slot (`panelType`, `state`) counting as
|
||||
spelled. Kinds: `string`; `number`, `integer` included; `boolean`; `json` for an object, an
|
||||
array or an untyped value; an `enum`-only attribute takes its values' kind. What the schema
|
||||
holds past the tables is pinned in two exact lists — an entry the schema no longer needs is
|
||||
red, like a difference neither list names: gaps, attributes of a spelled type (57.4.9:
|
||||
`link` `collection` `id` `occurrenceKey`, `rule` `color` `style` `weight`, `layoutSection`
|
||||
`columnRuleStyle`), emptied by 13; and carried, types the tables do not spell (`alignment`
|
||||
`annotation` `backgroundColor` `blockCard` `bodiedRule` `breakout` `dataConsumer`
|
||||
`embedCard` `fontSize` `fragment` `indentation` `inlineExtension` `placeholder`), `doc` and
|
||||
`text` counting as the grammar's own.
|
||||
- [x] **12 — The `!adf:` re-spelling (`0.2.0`).** Replace the colon directive grammar with the
|
||||
namespaced prefix, a breaking change to the emitted contract (shipped `0.1.0`, so §8 makes it
|
||||
`0.2.0`). Forms: block container `!adf:name arg {attrs}` … `!adf:/name` — the `/` parts open
|
||||
from close, nestable without a fence-length discipline, so the `::::`/`:::::` runs and their
|
||||
length rule go and every container opens the constant `!adf:`; block leaf `!adf:name arg
|
||||
{attrs}` with no closer; inline node `!adf:name[content]{attrs}`; directive marks
|
||||
`!adf:border`/`subsup`/`textColor`/`underline` `[content]{attrs}`. Attributes and their
|
||||
escaping stay `{key=value}`; the literal escape is `\!adf:`. Leaf vs container is decided by
|
||||
the node's content model rather than syntax — the `::`/`:::` split goes, a simplification the
|
||||
carry makes safe (an unknown *block* node already rides the fence, not the directive). The
|
||||
carry's reserved name becomes `carry`, both spellings — the block fence info string `carry`
|
||||
and the inline `!adf:carry{json="…"}` — named for what it does: it carries a node verbatim,
|
||||
never "unknown-node", since a known node no section spells where it stands rides it too. No
|
||||
`ConvertErrorCode` is added, removed or renamed, and the round-trip guarantee and the carry
|
||||
both hold through it. Mechanical surface: the grammar in `spec/flavour.md`,
|
||||
`src/adf/block-directives.ts` + `inline-directives.ts`, `src/markdown/`'s
|
||||
`directive-syntax.ts`, `opaque-carry.ts` and the `emit/` + `parse/` readers, every corpus
|
||||
fixture (round-trip, normalization and `errors/`), the prose reader over `spec/flavour.md`,
|
||||
the markdown property's generator, and the README's examples.
|
||||
**Settled** (the maintainer, 2026-09-13):
|
||||
- A line opening `!adf:name` is a block line when a space or the line's end follows the name,
|
||||
and a paragraph when `[` or `{` does. Claiming stays syntactic and structure comes from the
|
||||
tables: an unknown name is `unknown-directive-name` at the opener, whatever follows it.
|
||||
- An unescaped `!adf:` claims on its own anywhere inline: one completing no directive is
|
||||
`malformed-directive`, the emitter escapes every literal `!adf:`, and `!adf:hardBreak{}`
|
||||
keeps its braces. Block and inline share the one `\!adf:` escape hint.
|
||||
- A closer names the innermost open container, crosses no list-item or blockquote edge,
|
||||
indents as a fence does and carries nothing after the name; anything else is
|
||||
`malformed-directive`.
|
||||
- A node holding no content whose content model takes some is an empty opener–closer pair,
|
||||
never a leaf.
|
||||
- A spelled node's content model is frozen with its spelling: changing it is MAJOR (§8).
|
||||
- The colon spellings are dropped, not refused: `0.1.0` markdown reads back as prose, `adf`
|
||||
is no longer a reserved language, and `MIGRATION.md` tells a consumer to convert stored
|
||||
markdown through `0.1.0`'s parser and `0.2.0`'s emitter.
|
||||
- Inputs moving between codes ride the break: a leaf given a body, a container missing its
|
||||
closer and `listBreak` with a body are `malformed-directive`, and an empty inline-body
|
||||
container parses.
|
||||
- Split by construct, each sub-item both directions: 55 of 78 round-trip fixtures feed both
|
||||
the emit and the read-back test, so an emit-only chunk cannot land green.
|
||||
- The spec leads the code from 12a to 12d: `spec/flavour.md` and `AGENTS.md` §4 spell the
|
||||
`!adf:` grammar whole, while code and fixtures reach it one form at a time. A reader landing
|
||||
in either without `todo.md` sees a gap that is the plan, not a defect.
|
||||
- [x] **12a — The spec and the decision.** `spec/flavour.md` rewritten to the `!adf:` grammar and
|
||||
the settled answers above, no colon directive form left in it; AGENTS.md §4's directive
|
||||
bullet and prior-art line, and §8's escape hints and `::adf`/`::listBreak` examples, name
|
||||
the new forms, §8 gaining the frozen content model.
|
||||
- [x] **12b — The inline form.** Inline nodes, directive marks, `text` and the inline carry
|
||||
`!adf:carry{json=…}` spelled and read as `!adf:name[content]{attrs}`, with the prefix claim
|
||||
and its escape; the round-trip, normalization and `errors/` fixtures holding inline forms
|
||||
re-spelled, and the gate green. The content slot of `emoji`, `mention` and `status` refuses a
|
||||
text node carrying attributes as `unsupported-node-shape`, which it drops silently today (the
|
||||
maintainer, 2026-09-14).
|
||||
- [x] **12c — The block form.** Openers and `!adf:/name` closers, leaf vs container by content
|
||||
model, empty pairs, `listBreak` and the `carry` fence, spelled and read; the fence-length
|
||||
rule and the corpus test's fence nesting check deleted; the remaining fixtures re-spelled
|
||||
and `errors/` re-derived under the shifted codes, and the gate green.
|
||||
12b's two temporary seams expire here: `carryFence` folds back into `carryName` once the
|
||||
fence reads `carry`, and `directiveLineEscape` into `inlineDirectiveEscape` once one escape
|
||||
serves both forms. `spellLeafDirective` takes the `Inline` its reader-side regex already
|
||||
carries, and `header` versus `opener` settles as one word in spec and code. While
|
||||
`readNestedDirective` is open, its `[content]` and `{attrs}` reads lift out as named steps,
|
||||
and the two `charAt`-against-`!` fast paths ahead of `claimsDirectivePrefix` — in
|
||||
`readDirectiveContent` and `line-escaping`'s `bracketed-link-target` arm — either earn a
|
||||
reason or go (the systems-architect, 2026-09-16).
|
||||
- [x] **12d — The README, `MIGRATION.md` and the sweep.** The README's examples and error tables
|
||||
follow, `MIGRATION.md` linked from one README line; docs and fixtures swept for any stale
|
||||
`::`/`:name` spelling.
|
||||
- [x] **13 — The schema's gap attributes (`0.2.0`).** Spell the attributes 11b pins as gaps, in
|
||||
12's grammar, and empty the list.
|
||||
**Settled** (the maintainer, 2026-09-13): a link `[text](url "title")` cannot hold takes the
|
||||
directive mark `!adf:link[text]{attrs}` — one carrying `collection`, `id` or `occurrenceKey`,
|
||||
or an `href` or `title` no CommonMark escape writes — and a directive link CommonMark could
|
||||
spell is `unsupported-node-shape`. That leaves `unspellable-link` no cause, so it leaves
|
||||
`ConvertErrorCode` in `0.2.0`, §8 recording the removal.
|
||||
- [x] **13a — `rule` and `layoutSection`.** `rule`'s `color`, `style` and `weight` and
|
||||
`layoutSection`'s `columnRuleStyle` join their tables and `spec/flavour.md` bullets, with
|
||||
round-trip fixtures; their gap entries go.
|
||||
- [x] **13b — The directive link.** `link` spelled as above in both directions, with a round-trip
|
||||
fixture per trigger, `spec/flavour.md`'s Marks section following; `unspellable-link` removed
|
||||
from the code list, its `errors/` fixtures and the CommonMark suite's `unspellable`
|
||||
exceptions it cures re-derived, the README's code table and its "not every document
|
||||
converts back" guarantee following, and `MIGRATION.md` naming the removed code; the gap
|
||||
list is empty. A round-trip fixture holds the shape 4.2's review left refused until then:
|
||||
an autolink-shaped link under a directive mark whose href holds `\!adf:name{`. A link
|
||||
opening a paragraph whose opening reads as a link reference definition, which 4.3 leaves
|
||||
riding the carry, takes the directive link too, with its round-trip fixture (the
|
||||
maintainer, 2026-09-15).
|
||||
|
||||
- [x] **14 — The CommonMark subset's directory (`0.2.0`).** `src/markdown/` holds 16 source
|
||||
files at its root and 10 adds more there. The CommonMark subset moves under
|
||||
`src/markdown/commonmark/` — `backtick-runs.ts`, `commonmark-grammar.ts` as `grammar.ts`,
|
||||
`emphasis-matching.ts`, `entity-references.ts` with its test, `link-reference-definitions.ts`
|
||||
and `link-syntax.ts` — leaving the flavour's own constructs at the root, the split
|
||||
`spec/flavour.md` draws between the subset and the flavour (the systems-architect and the
|
||||
maintainer, 2026-09-16).
|
||||
- [x] **15 — The href-less directive link (`0.2.0`).** Refuse `!adf:link[text]` spelling no `href`
|
||||
with `unsupported-node-shape` naming the attribute, so the mark has one spelling: today it
|
||||
parses to a mark the emitter writes back as a carry, while the schema requires `href` and
|
||||
every other directive mark spells without attributes in both directions alike (the
|
||||
stability-reviewer, 2026-09-16; the maintainer, 2026-09-17).
|
||||
- [x] **16 — The link wrapping a link (`0.2.0`).** Read `[<http://x/>](/v)` and
|
||||
`[!adf:link[a]{href="/u"}](/v)` as `[[a](/u)](/v)` reads — the inner link wins and the outer
|
||||
brackets stay literal text, CommonMark's rule that no link holds another — rather than
|
||||
dropping the outer link silently as `closeLink`'s `applyMark` does today, with a
|
||||
normalization fixture per shape (the stability-reviewer, 2026-09-16; the maintainer,
|
||||
2026-09-17).
|
||||
- [x] **17 — A machine-enforced size ratchet (`0.2.0`).** Add a per-function line ceiling to the
|
||||
gate, set at today's worst and only ever moving down, so the largest body of new code cannot
|
||||
exceed what is already here (the systems-architect, 2026-09-16; narrowed by the comprehension
|
||||
panel, 2026-09-20). oxlint's `eslint/max-lines-per-function` measures it — one devDependency,
|
||||
carrying the musl binding the gate's image needs, since TypeScript 7 is the native compiler
|
||||
and exposes no parser to write the check against. No cyclomatic rule: `eslint/complexity`
|
||||
charges `?.` and `??` a point each, the guards §10 already exempts from the branch floor, and
|
||||
its two worst functions, `readBlockLine` and `parseInline`, went unnamed by all nine readers
|
||||
while `isNodeArray` and `blockNode` were volunteered as among the clearest code here. Length
|
||||
ranks no better: `emitList` and `blockNode` are both 26 lines, one the panel's unanimous top
|
||||
four and the other the clearest map of the format in the repo. So the ceiling guards against
|
||||
drift and never drives a refactor — 19 to 27 are where the hard work actually is.
|
||||
**Done** (2026-09-20): `.oxlintrc.json` carries the one rule, `correctness` off so nothing
|
||||
else runs, over the 40 files `tsconfig.build.json` builds — the tests and
|
||||
`property-harness.ts` out, five functions over the ceiling with them, the worst 64. The
|
||||
ceiling is 52, `parseInline`'s length and the built set's worst; at 51 the gate reddens on
|
||||
it. `skipBlankLines` and `skipComments` are spelled at oxlint 1.83.0's defaults, so a changed
|
||||
default cannot move what 52 counts. The leg runs `npm run size-ratchet` beside the typecheck
|
||||
at 0.8s, and the lockfile carries every platform binding, so `npm ci` resolves the musl one
|
||||
inside the image. §10 holds the rule and what each switch guards (the stability-reviewer,
|
||||
2026-09-20).
|
||||
- [x] **18 — The subtree the directive spelling asks about (`0.2.0`).** The parser asks
|
||||
`commonMarkSpelling` at every directive-spelled block and the answer emits the whole subtree
|
||||
below, so a node at depth d is spelled d times: three nested rule-first directive lists cost
|
||||
18 asks over 10 nodes, and 250 levels parse in 1.2 s at 16.4 kB, 4.9 s at 261 kB with a
|
||||
kilobyte of content per level. The depth guard bounds the levels at about 250, never the
|
||||
content, so this is the pipeline persona's hang on an input nobody typed (§11). Keeping each
|
||||
child's emitted result for its parent's ask is not a straight handover: the same node object
|
||||
is asked at different depths — 4, 3 and 2 for the innermost list of three — because the
|
||||
parser counts a list and its item as two levels where the emitter's readable list counts one
|
||||
(4b), and `headroom` is that guard's slack. The parts that survive the measurement: the paths
|
||||
agree, `text` and `spelling` carry no depth, `headroom` is affine in it, and the parser asks
|
||||
first at the deepest of them, so a kept result rebases by the difference. Either rebase and
|
||||
record that argument in `AGENTS.md`, or give both directions one list accounting so a node
|
||||
has one depth and nothing needs rebasing — which reopens 4b. A single post-build walk was
|
||||
rejected: it reports the outer offender where the build reports the inner one (the
|
||||
maintainer, 2026-09-18).
|
||||
**Measured** (2026-09-19): the parse keeps each node's readable spelling (AGENTS.md §11), and
|
||||
250 nested directive lists fall from 1.22 s to 0.04 s at 16.1 kB and from 5.20 s to 0.12 s at
|
||||
261 kB; a panel between every pair of lists 1.00 s to 0.05 s, an opaque carry in every item
|
||||
1.08 s to 0.03 s. No figure enters the gate (§14), as 4c settled for a behaviour-preserving
|
||||
cost fix; what the gate holds is the refusal. An ordered list past the marker cap gives way,
|
||||
so the emitter spends two levels where the parser spent one: one such list between an ask and
|
||||
a kept entry cancels the credit the directive form starts with, and two put the read below the
|
||||
depth that filled it — which a hit would answer without the depth guards the walk runs. That
|
||||
read re-spells. Three cases hold the arithmetic between them: the two depth-boundary tests
|
||||
already there pin the sign, the two-overflow case pins the guard, and the one-overflow case
|
||||
pins the magnitude, a constant rebase accepting there a document the emitter then refuses
|
||||
(the stability-reviewer, 2026-09-19). The one list accounting was not
|
||||
taken: 4b settled that accounting the day this was filed, and reopening it is an ask rather
|
||||
than a chunk.
|
||||
- [x] **19 — A home for what both formats read (`0.2.0`).** Settle where a construct both formats
|
||||
need lives, and say so in AGENTS.md §11. Today `adf/` may hold no format knowledge and each
|
||||
format directory holds its own shared layer, so there is no third place; the first ADF-shaped
|
||||
but format-touching helper either breaks the layering or becomes a second spelling of one
|
||||
rule, which is the loss §2 exists to stop. Both architects ranked this first and the only
|
||||
item cheaper before the feature than after.
|
||||
**Settled** (the maintainer, 2026-09-21): `adf/` is that place, and the test is the vocabulary
|
||||
the answer is in — a node type, an attribute kind, a content model, never a delimiter, an
|
||||
element name or an escape. A helper that cannot answer that way is the ADF question there and
|
||||
a spelling per format, the seam `markAttributes` and `markSpellings` already draw; one that
|
||||
cannot be split is a gap to ask. `markdown/` and `html/` are peers with no third directory
|
||||
between them, and `src/` root keeps the primitives knowing neither ADF nor a format. No code
|
||||
moved: a construct rises on its second consumer, so `linkHref` (`markdown/mark-spellings.ts`)
|
||||
and the external-image read (`markdown/emit/image.ts`), both pure ADF attribute reads, move
|
||||
when `html/` reads them (7).
|
||||
- [x] **28 — `emitLine`'s retry loop cannot spin (`0.2.0`).** `emit/inline-line.ts:67` is a
|
||||
`for (;;)` that re-emits the line until every unspellable node has been carried, and its
|
||||
termination rests on a comment: each pass carries at least one more node, or flips
|
||||
`openingLinkAsDirective`, which happens once. Ten `return success({ carry: … })` sites in
|
||||
that file have to honour it and nothing checks them — a range already inside `carried` loops
|
||||
forever. The library has no I/O and no timeout, so that is a hung caller rather than an
|
||||
error result, and §1's pipeline persona feeds documents nobody typed. Make the loop hold its
|
||||
own guarantee: refuse a carry that adds no node and return an error. Reads first in `0.2.0`
|
||||
because it is the only known way this library fails without a `Result`. Found by the
|
||||
comprehension panel, 2026-09-20; the ten sites are confirmed, a document that reaches the
|
||||
spin is not.
|
||||
**Done** (2026-09-20): the loop's progress is one named state and every pass takes a
|
||||
fallback through `takeFallback`, which refuses a carry adding no node and an opening link
|
||||
asked for the directive form a second time. Both are `unsupported-node-shape` under §8's
|
||||
rule that a new cause takes an existing code reading true of it: the emitter has no spelling
|
||||
left for that node arrangement, and a code a consumer can never switch on costs a removal
|
||||
later. Neither refusal is reachable — a carried node takes `emitLeaf`'s carried branch
|
||||
before any run forms, so every range a site names holds an uncarried node, and the
|
||||
directive-spelled opening link leaves the first segment with no node range for `escape` to
|
||||
ask about — so both are uncovered branches like the repo's other guards, 98.92% to 98.84%
|
||||
against the floor of 98.
|
||||
- [x] **29 — The README reads raw HTML as refused for good (`0.2.0`).** §Goals 3 says "the three
|
||||
carve-outs and the one gap below are the whole of the exception" and §The guarantees says
|
||||
"Raw HTML in markdown input is an error result", both reading as settled, where
|
||||
`spec/flavour.md` §Raw HTML in input says the opposite: `markdownToAdf` routes each construct
|
||||
through the foreign HTML element mapping, and only a construct without one is refused. The
|
||||
spec stands — commonplace markdown is accepted, and every tool writes some HTML (the
|
||||
maintainer, 2026-09-20). So rewrite the two README texts to name the exception that survives
|
||||
6 and 7, a construct outside the documented element set, and state it in one place, since
|
||||
three already spell this one rule. `markdown-to-adf.ts:73` and `inline-content.ts:158` are
|
||||
the whole of the refusal and already say "at this version"; 7 is what makes them route.
|
||||
**Done** (2026-09-20): the rule has one home, the `unmappable-html` row, naming the element
|
||||
set and what its absence covers at this version; §Goals 3 bounds the exceptions without
|
||||
listing them, the standalone raw-HTML guarantee goes, and the `0.2.0` guarantee says
|
||||
markdown's raw HTML reads the same set. That guarantee's "never a silent drop" went with it:
|
||||
6 settled that `<script>` and `<style>` drop whole, so the claim does not survive 7.
|
||||
- [x] **30 — AGENTS.md says each thing once (`0.2.0`).** §15's ask protocol — name the class, cite
|
||||
the earlier asks of it, never "A or B?" — is the rule reviewers cite most and has no heading,
|
||||
two thirds down a 50-line section in a file with no index. Give it one. §15 also offers "the
|
||||
gate's seconds" as a stated number that is kept, and no such number is stated anywhere, §14
|
||||
forbidding the category outright; drop the example. Then the restatements: §15 repeats the
|
||||
one-chunk rule three times and `version`/`NPM_TOKEN` twice, §12 says the default is delete
|
||||
twice, and §5's "few, each earning its keep; they never reach a consumer" is npm's own
|
||||
definition of the field. Cut to one copy each, the one carrying the why.
|
||||
**Done** (2026-09-20): §15 gains three sub-headings — Ask, don't guess; Rules the loop has
|
||||
settled; The continuous loop — so the protocol is one of four entries rather than a
|
||||
paragraph two thirds down. The one-chunk rule keeps the head paragraph, which now carries
|
||||
the chain-of-sessions why the settled bullet held; `version`/`NPM_TOKEN` keeps the reserved
|
||||
paragraph, which carries §9's publish-on-bump why, and moves up beside the chunk steps.
|
||||
§12's "every prose comment in a diff is a review question" and §5's devDependencies clause
|
||||
go whole. The gate's seconds is confirmed stated nowhere: `docker-runner.sh` measures each
|
||||
leg's elapsed time and no number bounds it.
|
||||
|
||||
## 5 — Ship `0.1.0`
|
||||
|
||||
- [ ] **5 — Ship `0.1.0`.** Only the maintainer's own acts are left (§15): make the Gitea repo
|
||||
public (§6), create the `NPM_TOKEN` secret, confirm the Actions token may push tags — the
|
||||
publish succeeds and the tag push then reddens the run, though the next push to `main`
|
||||
retries the tag alone — and open the bump PR that sets `version` to `0.1.0` and drops
|
||||
`private: true`, the guard against any earlier publish. The bump and the drop go in one
|
||||
commit: dropping `private` alone publishes `0.0.0`, which also differs from npm's nothing. `0.1.0` is the
|
||||
markdown round-trip: both markdown directions, the types, `isAdfDocument`, proved over the
|
||||
checked-in corpus.
|
||||
**Settled** (the maintainer, 2026-09-01): the round-trip proved over the checked-in corpus
|
||||
is what `0.1.0` ships on, and the open-ended proof work follows it rather than gating it —
|
||||
3k's spec suite and 4's generators and maintainer-supplied payloads are `0.2.0`, 4b's retry
|
||||
`0.1.1`. A consumer using the library is worth more than a wider proof nobody has needed
|
||||
yet, and §8's pre-1.0 rules cover what the wider proof then finds.
|
||||
|
||||
**Shipped** 2026-09-05: `@larvit/adf-codec@0.1.0` published and `v0.1.0` tagged on `8a847de`. Publishing needed a
|
||||
bypass-2FA token — the account carrying no write-2FA requirement was not enough, npm demanded an
|
||||
OTP until the token itself bypassed it.
|
||||
@@ -1,305 +1,180 @@
|
||||
# Todo
|
||||
# todo
|
||||
|
||||
The plan. Design questions are settled in `AGENTS.md`; remaining spec detail is settled at its own
|
||||
milestone. A done item shrinks to its title here; its full text moves to `todo-history.md`.
|
||||
## Scoring
|
||||
|
||||
## Next session
|
||||
`Score = -R - S/4 + 2*A + 2*G*W`
|
||||
|
||||
Start a session with: `Read AGENTS.md and todo.md, then do what todo.md's "Next session" says.`
|
||||
`Bar = 9`
|
||||
|
||||
1. `git fetch origin` first and read this file at `origin/main`, then branch off it, not the
|
||||
worktree left behind: a checkout behind the remote reads a merged item as unchecked.
|
||||
2. The first unchecked item in shipping order, per AGENTS.md §15 — the order the Milestones line
|
||||
states, which wins over where an item's bullet sits: a newly filed item is written beside the
|
||||
one it came in with, not at its own place in the order. Where that item has no release, the
|
||||
planning chunk §15 describes.
|
||||
3. In flight: nothing.
|
||||
4. Before stopping, rewrite this section: the in-flight line, and the prompt itself wherever the
|
||||
session found it wrong or short.
|
||||
`Next ID = 52`
|
||||
|
||||
## Milestones
|
||||
| Goal | W |
|
||||
|---|---|
|
||||
| 1 | 1.00 |
|
||||
| 2 | 0.89 |
|
||||
| 3 | 0.78 |
|
||||
| 4 | 0.67 |
|
||||
| 5 | 0.56 |
|
||||
| 6 | 0.44 |
|
||||
| 7 | 0.33 |
|
||||
| 8 | 0.22 |
|
||||
| 9 | 0.11 |
|
||||
|
||||
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 3k, 11, 4, 12, 13, 4b,
|
||||
4c, 14, 15, 16, 18, 4d, 28, 17, 29, 19, 20, 21, 22, 23, 24, 25, 30, 26, 27, 10, 6, 7, 5f, 5g →
|
||||
`0.2.0`;
|
||||
8, 9 → TBD; 5e last.
|
||||
The numbering is the order the work was planned in, not the order it ships. Everything known and
|
||||
shaped ships in one release rather than a string of them: nothing waits on a version, and no
|
||||
consumer is served by the churn (the maintainer, 2026-09-18). So `0.2.0` completes §1's three
|
||||
formats, and `0.2.1` and `0.3.0` are gone. `8` and `9` stay out as the two goals nothing has shaped
|
||||
yet. `0.2.0`'s order is settled (the maintainer, 2026-09-13, extended 2026-09-18): 11 makes the
|
||||
tables 4 generates from answer to Atlassian's schema, 4 proves 12, 13 spells 11's gaps in 12's
|
||||
grammar, and 12 rewrites code 4b and 4c change; then 14 moves the files 15, 16 and 10 edit and HTML
|
||||
is written against that layout, 4d marks the gate legs before 17 adds one, 17 puts the size ratchet
|
||||
under the largest body of new code, and 5f and 5g read last because 7 is what changes the
|
||||
bundle size and the tagline.
|
||||
19 to 27 come from a comprehension panel — nine readers across four experience levels, none of
|
||||
them able to see this file, reporting what defeated them and whether the project's shape fits in a
|
||||
head (2026-09-20). They read ahead of 6, 7 and 10 because every one of them is cheaper before the
|
||||
HTML format lands than after: 19 and 20 because HTML has no answer without them, 21 to 24 because
|
||||
HTML doubles the importers and the file count they touch, and 25 to 27 because they are what the
|
||||
panel says the next reader pays for.
|
||||
29 and 30 come from 17's prose pass (2026-09-20). 29 reads first because every goal is what a later
|
||||
ask is settled against, 19's included; 30 sits beside 25, the other chunk rereading AGENTS.md.
|
||||
## Items
|
||||
|
||||
- [ ] **20 — The give-way channel is unmistakable (`0.2.0`).** `emitBlockquote`, `emitCodeBlock`,
|
||||
`emitHeading`, `emitList`, `emitParagraph` and `emitRule` return
|
||||
`Result<EmittedBlock> | undefined`, where `undefined` gives way to the directive form and an
|
||||
error refuses the document. Give the six the `try` prefix the repo already uses for
|
||||
`tryImage`, `tryPipeTable` and `tryPipeCell`, or a return type that cannot hold both, so a
|
||||
newcomer meeting an unspellable shape cannot reach for `failure` and silently narrow what
|
||||
converts. Named as the first thing a new senior would break, and it lands on §1.
|
||||
- [ ] **21 — The ADF tables carry ADF's nouns (`0.2.0`).** `adf/block-directives.ts` and
|
||||
`adf/inline-directives.ts` hold the ADF node tables — `paragraph`, `heading`, `blockquote`
|
||||
and `rule` among them — under the markdown flavour's word, inside the directory §11 forbids
|
||||
to know a format. Rename to the noun `spec/flavour.md` uses, types and accessors with them.
|
||||
Before 7 doubles the import sites.
|
||||
- [ ] **22 — `LineContainer` sits at the markdown level (`0.2.0`).** Two of the four
|
||||
`parse/` → `emit/` imports fetch this type from `emit/line-escaping.ts`, camouflaging the two
|
||||
that are the deliberate spelling consultation. Move it, and name those two in §11 as the whole
|
||||
of that surface, so a reviewer checks the seam with one grep.
|
||||
- [ ] **23 — The block-directive fragments are one file (`0.2.0`).** `block-directive-arguments.ts`,
|
||||
`-forms.ts` and `-marks.ts` are three files under 25 lines answering one question. Fold them,
|
||||
and take `src/markdown/` — the worst level both architects named, 13 entries with no
|
||||
organising question — down with them.
|
||||
- [ ] **24 — The conformance gates have a directory (`0.2.0`).** Six root tests with no sibling
|
||||
source (`adf-property`, `adf-schema`, `commonmark-spec`, `corpus`, `flavour`,
|
||||
`markdown-property`) plus `property-harness.ts` are the machinery that makes the docs
|
||||
executable, and they read as leftovers. Give them one, so `src/` root shows what it holds.
|
||||
- [ ] **25 — AGENTS.md §8 and §11 are findable (`0.2.0`).** Both are single unindexed paragraphs
|
||||
holding the answer to nearly every question the panel had, and six readers and both
|
||||
architects independently reported that finding the sentence cost more than reading the code
|
||||
it governed. Sub-headings or an index at each section's head; delete whatever the code,
|
||||
a type or a test name already says rather than reorganising it.
|
||||
- [ ] **26 — The two mutable structures say what they guarantee (`0.2.0`).** `escapedIndexes` fills
|
||||
the `escaped` set left to right while the predicates it calls read the half-built set, then
|
||||
`escapeClosedRuns` walks the same set right to left and adds to it; the order is load-bearing
|
||||
and asserted nowhere, and a refactor to `filter`/`map` breaks it silently. Separately,
|
||||
`walk.edges` is the blockquote and list-item subsequence of `walk.stack` with each entry's
|
||||
stack index, maintained by hand in six places and stated in none. Put each invariant where it
|
||||
cannot be got wrong — a type, a derived value, a named phase — rather than in a comment. The
|
||||
panel's first and second hardest places.
|
||||
- [ ] **27 — The dead `headroom` write goes (`0.2.0`).** `directiveItems`
|
||||
(`emit/adf-to-markdown.ts:258`) writes `item.walk.headroom - 1` onto each `PlacedBlock`, and
|
||||
nothing on that path reads a block's `headroom`: `joinBlocks` and `separationBetween` read
|
||||
`text` and `spelling`, and `emitDirectiveBlock` takes the level from the `Walk`. Of the two
|
||||
subtractions three readers flagged as double-counting, this is the one that is dead.
|
||||
- [x] **0 — Scaffold.**
|
||||
- [x] **1a — The directive grammar.**
|
||||
- [x] **1b — Block node syntaxes.**
|
||||
- [x] **1c — Inline node syntaxes and marks.**
|
||||
- [x] **1d — Corpus start.**
|
||||
- [x] **1d1 — The CommonMark subset.**
|
||||
- [x] **1d2 — Block nodes.**
|
||||
- [x] **1d3 — Inline nodes and marks.**
|
||||
- [x] **2 — `adfToMarkdown`.**
|
||||
- [x] **2a — The runner and the CommonMark subset.**
|
||||
- [x] **2b — Block nodes.**
|
||||
- [x] **2c — Inline nodes and marks.**
|
||||
- [x] **2d — The opaque carry.**
|
||||
- [x] **2e — Carve-outs and combinations.**
|
||||
- [x] **2e1 — The carve-outs and the claimed line.**
|
||||
- [x] **2e2 — Mark runs and the runs a carry breaks.**
|
||||
- [x] **2e3 — Attribute canonicalization and the quoted value's escape.**
|
||||
- [x] **2e4 — The carry's fallback triggers.**
|
||||
- [x] **2e5 — Combined documents and the collision property.**
|
||||
- [x] **2f — The attributes CommonMark cannot hold.**
|
||||
- [x] **3 — `markdownToAdf`.**
|
||||
- [x] **3a — The hierarchy.**
|
||||
- [x] **3b — The leaf blocks.**
|
||||
- [x] **3c — The container blocks.**
|
||||
- [x] **3d — Inline text.**
|
||||
- [x] **3e — Emphasis and links.**
|
||||
- [x] **3f — The directive grammar.**
|
||||
- [x] **3g — The node tables read backwards.**
|
||||
- [x] **3h — The block nodes.**
|
||||
- [x] **3i — The inline nodes and the marks.**
|
||||
- [x] **3j — The carry and the combinations.**
|
||||
- [x] **3k — The CommonMark spec suite.**
|
||||
- [x] **4 — Round-trip property tests.**
|
||||
- [x] **4.1 — Editor-normal and the node accessors.**
|
||||
- [x] **4.2 — The ADF property.**
|
||||
- [x] **4.3 — The markdown property.**
|
||||
- [x] **4.4 — The real payloads.**
|
||||
- [x] **4b — The block walk's retry (`0.2.0`).**
|
||||
- [x] **4c — The scanning rule's remaining sites (`0.2.0`).**
|
||||
- [x] **4d — What the gate says while it runs (`0.2.0`).**
|
||||
- [x] **5 — Ship `0.1.0`.**
|
||||
- [ ] **5e — The publish token's deadline.** `0.1.0` published only once the npm
|
||||
token carried **Bypass 2FA**: the account requiring no 2FA on writes was not enough, and npm
|
||||
answered `EOTP` until the token itself bypassed. npm retires bypass-2FA tokens for direct
|
||||
publishing around January 2027, leaving them `npm stage publish`, which a maintainer
|
||||
approves with 2FA; its replacement — trusted publishing over OIDC — supports GitHub-hosted
|
||||
Actions, GitLab.com's shared runners and CircleCI's cloud, self-hosted runners planned
|
||||
without a date. So the release path has an expiry date and no drop-in successor yet. Revisit:
|
||||
whether npm has added Gitea or self-hosted OIDC, and otherwise whether the
|
||||
release moves to the staged publish — which fits badly with publish-on-merge,
|
||||
and is the trade to weigh rather than discover on a red release run.
|
||||
**Settled** (the maintainer, 2026-09-13): last of the known work, clear of `0.2.0`, placed
|
||||
there knowing the cutoff may land before `0.2.0` ships.
|
||||
- [ ] **5f — Publish the bundle size (`0.2.0`).** Measure the shipped artifact and put the number in the
|
||||
README, kept honest by the release pipeline rather than by a human re-reading it. The
|
||||
quantity is what a consumer downloads and loads: the tarball `npm pack` produces, its
|
||||
unpacked `dist`, and the built JavaScript minified + gzipped — the figure the competitors
|
||||
advertise (marklassian's "12kb") and the only apple-to-apple one, since ours ships tsc's
|
||||
unminified output and no minifier yet (decide here whether to minify for the build or report
|
||||
the unminified gzip). A publish/pipeline leg measures it and fails when the README figure
|
||||
drifts, so the number can't rot; the figure lands in README §The package beside the
|
||||
"no runtime dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked
|
||||
221.5 kB, JS gzipped 45.6 kB.
|
||||
- [ ] **5g — Reweight the README for the reader (`0.2.0`).** It opens with the pre-launch rationale —
|
||||
Atlassian's REST APIs, `pf-editor-service/convert` being decommissioned, a link to
|
||||
JRACLOUD-77436 — where a shipped package should answer what it is, what it does and for whom
|
||||
first, then the shortest runnable example.
|
||||
**Settled** (the maintainer, 2026-09-13): the background goes entirely, no endpoint, ticket or
|
||||
"why" note left. The top follows the package-README order: an npm version badge and the Gitea
|
||||
Actions badge, a tagline that is also `package.json`'s `description`, a feature list and a
|
||||
one-line table of contents, then install and the shortest runnable example; a table of
|
||||
everything exported sits near the bottom. The HTML directions were to stay an aside until a
|
||||
later release shipped them; 7 now ships in this one and reads ahead of this item, so the
|
||||
README documents HTML as it documents markdown, the tagline and `description` naming both
|
||||
(the maintainer, 2026-09-13, revised 2026-09-18).
|
||||
- [x] **5a — Rename to `@larvit/adf-codec`.**
|
||||
- [x] **5b — The consumer's error surface.**
|
||||
- [x] **5b1 — The error's source position.**
|
||||
- [x] **5b2 — The error messages.**
|
||||
- [x] **5b3 — The code list and the flavour's gaps.**
|
||||
- [x] **5b4 — The README's consumer surface.**
|
||||
- [x] **5c — The build and the release pipeline.**
|
||||
- [x] **5d — The browser leg.**
|
||||
- [ ] **6 — The HTML dialect spec (`0.2.0`).** Element-by-element mapping, the `data-*` fidelity
|
||||
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts —
|
||||
the set `markdownToAdf` shares (`spec/flavour.md` §Raw HTML in input; 29).
|
||||
**Settled** (the maintainer, 2026-09-20), the four answers that shape the set:
|
||||
- A container ADF has no node for unwraps to its children, its own attributes dropped, so
|
||||
`<div align="center">text</div>` keeps `text` and loses the box and the alignment ADF
|
||||
cannot hold.
|
||||
- `<details><summary>Title</summary>…</details>` is an `expand`, the summary its `title`;
|
||||
one inside another is a `nestedExpand`, as 10 already spells for the lossy pair. An empty
|
||||
`<details>` is still refused — `expand` requires content, so there is nothing to build.
|
||||
- A comment stays an error result. Neither schema holds a comment node: across 84 and 98
|
||||
definitions the only "comment" in either file is `annotationType: "inlineComment"` on the
|
||||
`annotation` mark, which carries an `id` and no text, the words living behind an Atlassian
|
||||
API. `placeholder` is the editor's own visible hint, and `extension` demands an
|
||||
`extensionKey` naming a vendor app. Nothing can hold the words, so nothing accepts them.
|
||||
- `<script>` and `<style>` drop whole, their text with them. Neither holds anything a reader
|
||||
of the document ever saw, so nothing is lost; unwrapping them would put `alert(1)` on the
|
||||
page as prose. A `style` attribute is a separate question — `textColor` and
|
||||
`backgroundColor` are the marks it could reach — and is not read at `0.2.0`, the work
|
||||
outweighing what it buys.
|
||||
So the set sorts every element three ways, and that is what AGENTS.md §3 gains in place of
|
||||
"error result naming the element": a container around document content unwraps, content ADF
|
||||
cannot hold is an error result naming it, and what is not document content at all drops
|
||||
whole. A comment sorts into the second rather than the third because a person wrote those
|
||||
words on purpose. 10's "Rejected in the survey" line names raw HTML and comments and does not
|
||||
contradict this: it rejects them as spellings the lossy pair writes and reads back, where
|
||||
`plainMarkdownToAdf` composes on `markdownToAdf` and so inherits whatever this set accepts.
|
||||
- [ ] **7 — HTML, the third format (`0.2.0`).** `adfToHtml`, `htmlToAdf`, the composed
|
||||
`markdownToHtml` / `htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from
|
||||
here (§10). The README's tagline and `package.json`'s `description` regain HTML (5g).
|
||||
- [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists.
|
||||
- [ ] **9 — The online sandbox.** A web page with two textboxes converting back and forth between ADF and markdown, powered by the library's browser build.
|
||||
- [ ] **10 — Lossy conversion (`0.2.0`).** Markdown other tools render readably, to and from ADF,
|
||||
keeping the content while dropping what markdown cannot hold — format, design and the richer
|
||||
nodes.
|
||||
**Settled** (the maintainer, 2026-09-14): two exports composed around the lossless pair, so §1's
|
||||
four conversions stay four. `adfToPlainMarkdown(doc)` reduces the document ADF→ADF and hands it
|
||||
to `adfToMarkdown`; `plainMarkdownToAdf(markdown)` hands the markdown to `markdownToAdf` and
|
||||
lifts the result ADF→ADF. Both carry markdown conventions, so the reduction sits in
|
||||
`src/markdown/emit/`, the lift in `src/markdown/parse/` and what both read in `src/markdown/`
|
||||
(§11). The markdown is the flavour without directives — CommonMark, the pipe table and `~~` —
|
||||
plus the conventions below, chosen for readability from a survey of GitHub, GitLab, Gitea,
|
||||
Obsidian, Pandoc, MkDocs, Docusaurus, Typora, Joplin, Logseq, Bear, Notion, Azure DevOps and
|
||||
Discord, GitHub's renderer confirming each shape. Writing refuses only what the document guard
|
||||
refuses (`not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`)
|
||||
and degrades every other shape; reading refuses what `markdownToAdf` refuses. A lifted node
|
||||
carries no `localId`. The lift also reads other tools' spellings — type words in any case,
|
||||
Obsidian's aliases, `[X]` — since it reads their output and never writes those spellings.
|
||||
- A `panel` is an alert: the marker alone on the quote's first line, a blank `>`, then the body
|
||||
(`> [!WARNING]`), in GitHub's five words by colour — info `NOTE`, note `IMPORTANT`, tip and
|
||||
success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE`. The lift reads those words
|
||||
back (`NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error) and
|
||||
Obsidian's by meaning (hint tip; success, check and done success; attention warning; danger,
|
||||
failure, fail, missing and bug error; any other word info). Text after a marker in its
|
||||
paragraph is the panel's first body paragraph.
|
||||
- An `expand` or `nestedExpand` is Obsidian's folded callout, `> [!NOTE]- Title`, a blank `>`,
|
||||
then the body. The lift reads a fold sign (`-` or `+`) as an expand whatever the word, the
|
||||
rest of the marker's paragraph as its title, and an expand inside an expand as a
|
||||
`nestedExpand`.
|
||||
- A `taskList` is a bullet list whose items lead with `[x]` or `[ ]` (`- [x] Write the spec`).
|
||||
The lift reads a list whose every item is so marked back as a `taskList` — a `blockTaskItem`
|
||||
where an item holds more than one block, a nested task list moved beside its item — and
|
||||
leaves mixed and ordered lists plain. A `decisionList` is a plain bullet list.
|
||||
- `backgroundColor` is `==text==`, and the lift gives `==text==` the Atlassian editor's default
|
||||
highlight colour.
|
||||
- `layoutSection`/`layoutColumn`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`
|
||||
and `extensionFrame` unwrap to their body blocks in order; the CommonMark blocks keep their
|
||||
spelling, attributes dropped.
|
||||
- `mention` and `status` become their text, the mention's `@` kept; `emoji` its text or else its
|
||||
`shortName`; `date` its ISO date in UTC (`2026-09-13`); `inlineCard`, `blockCard` and
|
||||
`embedCard` a link to their `url`, dropped when they carry only `data`; a `mediaSingle`
|
||||
holding an external image stays ``; `media`, `mediaGroup` and `mediaInline` their
|
||||
`alt` text or nothing; `caption` its text as a paragraph; `extension`, `inlineExtension` and
|
||||
`syncBlock` their `text` attribute or nothing; `placeholder` nothing; a node no row names, or
|
||||
one standing where no spelling holds it, its blocks or its text.
|
||||
- A table stays a pipe table: the first row becomes the header, a cell's blocks join on one line
|
||||
with spaces, and spans and the cells they cover drop.
|
||||
- `code`, `em`, `link`, `strike` and `strong` stay and every other mark drops, keeping its text —
|
||||
`subsup` too, since `~2~` is a strike on GitHub; a link no CommonMark escape writes becomes its
|
||||
text, and a mark run CommonMark's flanking or matching cannot spell drops its mark.
|
||||
- A newline in text becomes a hard break and edge whitespace is trimmed; carriage returns and
|
||||
null characters are removed; a paragraph line opening with a code span whose backticks would
|
||||
read as a fence loses the code mark; an empty paragraph drops, and adjacent lists of one type
|
||||
merge.
|
||||
- Rejected in the survey: `~sub~` and `^sup^`, underline and colour spellings, raw HTML
|
||||
(`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family, footnotes, definition
|
||||
lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states past `[x]`/`[ ]`,
|
||||
and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes.
|
||||
- [ ] **10a — The reduction.** `adfToPlainMarkdown`'s ADF→ADF reduction, tests first, a test per
|
||||
row above.
|
||||
- [ ] **10b — The lift.** `plainMarkdownToAdf`'s ADF→ADF lift, tests first, a test per row it reads,
|
||||
other tools' spellings included; the editor's default highlight colour looked up and cited.
|
||||
- [ ] **10c — The exports.** `adfToPlainMarkdown` and `plainMarkdownToAdf` exported with their README
|
||||
sections, and two properties over 4.2's generators: writing refuses only the guard's codes,
|
||||
and markdown `adfToPlainMarkdown` wrote reads back through `plainMarkdownToAdf` and writes
|
||||
again byte for byte. AGENTS.md §1 records the pair as composed around the lossless one.
|
||||
- [x] **11 — Atlassian's ADF schema as the tables' truth.**
|
||||
- [x] **11a — The vendored schema.**
|
||||
- [x] **11b — The gate.**
|
||||
- [x] **12 — The `!adf:` re-spelling.**
|
||||
- [x] **12a — The spec and the decision.**
|
||||
- [x] **12b — The inline form.**
|
||||
- [x] **12c — The block form.**
|
||||
- [x] **12d — The README, `MIGRATION.md` and the sweep.**
|
||||
- [x] **13 — The schema's gap attributes (`0.2.0`).**
|
||||
- [x] **13a — `rule` and `layoutSection`.**
|
||||
- [x] **13b — The directive link.**
|
||||
- [x] **14 — The CommonMark subset's directory (`0.2.0`).**
|
||||
- [x] **15 — The href-less directive link (`0.2.0`).**
|
||||
- [x] **16 — The link wrapping a link (`0.2.0`).**
|
||||
- [x] **17 — A machine-enforced size ratchet (`0.2.0`).**
|
||||
- [x] **18 — The subtree the directive spelling asks about (`0.2.0`).**
|
||||
- [x] **19 — A home for what both formats read (`0.2.0`).**
|
||||
- [x] **28 — `emitLine`'s retry loop cannot spin (`0.2.0`).**
|
||||
- [x] **29 — The README reads raw HTML as refused for good (`0.2.0`).**
|
||||
- [x] **30 — AGENTS.md says each thing once (`0.2.0`).**
|
||||
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 40 | 0.2.0 | decision | **Make `markdownToAdf(adfToMarkdown(doc))` deep-equal `doc` for every document `adfToMarkdown` takes.** | 6 | 7 | 8 | 9 | 1 | 26.2 |
|
||||
| 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 3 | 25.8 |
|
||||
| 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
|
||||
| 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 3 | 24.7 |
|
||||
| 43 | 0.2.0 | | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 3, 4 | 22.3 |
|
||||
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.** | 4 | 5 | 5 | 8 | 3, 4 | 17.2 |
|
||||
| 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 3, 4 | 12.4 |
|
||||
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 3, 4, 6 | 10.4 |
|
||||
| 38 | 0.3.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 4 | 7 | 1 | 19.5 |
|
||||
| 47 | 0.3.0 | | **Open the README with what the package is, what it does and for whom.** | 1 | 4 | 7 | 9 | 9 | 14.0 |
|
||||
| 34 | 0.3.0 | | **Read emphasis flanking by the whole character beside an astral symbol.** | 2 | 3 | 3 | 6 | 3, 4 | 12.6 |
|
||||
| 42 | 0.3.0 | | **Trim a text leaf's trailing blanks in linear time.** | 1 | 2 | 5 | 9 | 8 | 12.5 |
|
||||
| 48 | 0.3.0 | | **Keep the release path publishing past npm's bypass-2FA token retirement.** | 4 | 4 | 8 | 3 | 9 | 11.7 |
|
||||
| 31 | 0.3.0 | | **Make the branch-coverage figure repeat across runs of an unchanged tree.** | 2 | 3 | 3 | 4 | 1 | 11.2 |
|
||||
| 33 | 0.3.0 | | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 8 | 10.7 |
|
||||
| 46 | 0.3.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 7, 9 | 10.3 |
|
||||
| 9 | 0.3.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 6 | 8 | 9 | 10.3 |
|
||||
| 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 9 | 10.6 |
|
||||
|
||||
## The ADF inventory to cover
|
||||
## Details
|
||||
|
||||
From Atlassian's [structure
|
||||
reference](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/) — not the
|
||||
whole schema: real payloads also carry `taskList`/`taskItem`, `decisionList`/`decisionItem`,
|
||||
`layoutSection`/`layoutColumn`, `blockCard`/`embedCard`, `extension`/`bodiedExtension`/`inlineExtension`
|
||||
and `placeholder`, none documented there. The documented set is the floor: the floor gets designed
|
||||
syntax, the rest rides the opaque carry (§3) until it does too.
|
||||
### 40. Make `markdownToAdf(adfToMarkdown(doc))` deep-equal `doc` for every document `adfToMarkdown` takes.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Top-level block | `blockquote` `bodiedSyncBlock` `bulletList` `codeBlock` `expand` `heading` `mediaGroup` `mediaSingle` `multiBodiedExtension` `orderedList` `panel` `paragraph` `rule` `syncBlock` `table` |
|
||||
| Child block | `blockTaskItem` `extensionFrame` `listItem` `media` `nestedExpand` `tableCell` `tableHeader` `tableRow` |
|
||||
| Inline | `date` `emoji` `hardBreak` `inlineCard` `mediaInline` `mention` `status` `text` |
|
||||
| Marks | `border` `code` `em` `link` `strike` `strong` `subsup` `textColor` `underline` |
|
||||
Today it holds for editor-normal documents only: two adjacent text nodes with the same marks merge,
|
||||
an empty `attrs`, `marks` or `content` drops, and `-0` reads back `0` — shapes pipelines and bots
|
||||
build. Spell each so it reads back as written; CommonMark's spelling stays wherever the document
|
||||
holds none of these shapes. The spellings are part of the chunk. `docs/decisions.md` §Equality is
|
||||
editor-normal, `spec/flavour.md` and `corpus/README.md` follow, and the tests drop `toEditorNormal`.
|
||||
|
||||
Plain markdown covers `blockquote`, `bulletList`, `codeBlock`, `heading`, `orderedList`,
|
||||
`paragraph`, `rule`, `listItem`, `hardBreak`, `text`, and the `code`, `em`, `link` and `strong`
|
||||
marks; `strike` is the flavour's `~~` carve-out. Everything else is what the flavour is for.
|
||||
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.
|
||||
|
||||
Lands after item 6. The CommonMark spec suite also runs against `markdownToHtml`. The README
|
||||
documents HTML as it documents markdown, and its tagline and `package.json`'s `description` regain
|
||||
HTML.
|
||||
|
||||
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
|
||||
|
||||
Goal 1 has every call return a result; the boolean guard is the one export that does not, and it
|
||||
cannot say which branch refused, where `not-an-adf-document`'s message already does. Breaking:
|
||||
`MIGRATION.md` shows the guard's replacement.
|
||||
|
||||
### 6. Specify the HTML dialect.
|
||||
|
||||
Element-by-element mapping, the `data-*` fidelity scheme, the opaque-carry form, and the documented
|
||||
foreign-element set `htmlToAdf` accepts — the set `markdownToAdf` shares (`spec/flavour.md` §Raw
|
||||
HTML in input). The set sorts per `docs/decisions.md` §Foreign HTML sorts three ways.
|
||||
|
||||
### 43. Give each markdown input its own reader, strict to its own standard.
|
||||
|
||||
Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a
|
||||
directive, a pipe table or a `~~` pair becomes a flavour node where CommonMark reads plain text. A
|
||||
caller names the markdown it hands in: CommonMark, read as its spec says, or the lossless flavour,
|
||||
read as `spec/flavour.md` says. Breaking: `MIGRATION.md` says which call a caller takes.
|
||||
|
||||
### 49. Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.
|
||||
|
||||
Lands after item 43. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads
|
||||
two (spec examples 301 and 302), and so must the CommonMark reader. `spec/flavour.md` merges them in
|
||||
the lossless flavour on purpose and parts two adjacent lists with `!adf:listBreak`. The chunk
|
||||
settles by Goals 3 and 4 whether the flavour follows, and asks where the Goals do not decide. That
|
||||
answer also settles what `adfToMarkdown` and `adfToPlainMarkdown` write for two adjacent lists, and
|
||||
whether `!adf:listBreak` still reads. Breaking, so it ships beside item 43: `MIGRATION.md`'s
|
||||
Readings table gains its row, and its Spellings table one if `!adf:listBreak` retires. Examples 301
|
||||
and 302 lose their `pending` exceptions, and the spelling leaves the README's "Four CommonMark
|
||||
spellings" bullet, which counts one fewer.
|
||||
|
||||
### 51. Match a reference label to its definition under Unicode case folding.
|
||||
|
||||
`link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec
|
||||
example 540); lowercasing and then uppercasing folds it. Breaking, so it ships beside item 43:
|
||||
`MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go, and its spelling leaves
|
||||
the README's "Four CommonMark spellings" bullet, which counts one fewer.
|
||||
|
||||
### 50. Read `[](/url)` and `[]()` as CommonMark's empty link.
|
||||
|
||||
Both stay literal text today (spec examples 484 and 487). ADF holds no empty text node to carry a
|
||||
link mark, so the chunk settles what the empty link builds by Goals 3 and 6, and asks where they do
|
||||
not decide; whatever it builds, both still parse, since a bot relies on plain CommonMark being valid
|
||||
input. Breaking, so it ships beside item 43: `MIGRATION.md`'s Readings table gains its row. Its
|
||||
`pending` exceptions go, and its spelling leaves the README's "Four CommonMark spellings" bullet,
|
||||
which counts one fewer.
|
||||
|
||||
### 38. Spell a lone surrogate in a text node so it survives a UTF-8 encode.
|
||||
|
||||
`adfToMarkdown` emits it verbatim, so markdown stored as UTF-8 reads back U+FFFD; attribute values
|
||||
already escape it.
|
||||
|
||||
### 47. Open the README with what the package is, what it does and for whom.
|
||||
|
||||
It opens with the pre-launch rationale — Atlassian's REST APIs, `pf-editor-service/convert` being
|
||||
decommissioned, a link to JRACLOUD-77436. The background goes entirely, no endpoint, ticket or "why"
|
||||
note left. The badges are npm's version and the Gitea Actions status. The README names the lossy
|
||||
pair, `adfToPlainMarkdown` and `plainMarkdownToAdf`, and the flavours it writes and reads — GitHub
|
||||
Flavored Markdown's alerts and task lists, Obsidian Flavored Markdown's callouts — so a search for
|
||||
any of these names finds the package.
|
||||
|
||||
### 34. Read emphasis flanking by the whole character beside an astral symbol.
|
||||
|
||||
Check whether `line-escaping.ts`'s `charAt` and the parser's flanking read one UTF-16 unit beside an
|
||||
astral symbol — a lone surrogate is neither punctuation nor symbol, where CommonMark reads `😀` as
|
||||
punctuation — and, where they do, read the code point, with a fixture per direction.
|
||||
|
||||
### 42. Trim a text leaf's trailing blanks in linear time.
|
||||
|
||||
`plain-inline.ts`'s `leafEdges` finds the trail with an unanchored `/[ \t]*$/`, quadratic in a run
|
||||
of blanks inside one leaf: a paragraph of `a`, 80 000 spaces, `b` takes 6.5 s in
|
||||
`adfToPlainMarkdown`. Scan backward, as the expand title's trim does.
|
||||
|
||||
### 48. Keep the release path publishing past npm's bypass-2FA token retirement.
|
||||
|
||||
Lands after 2027-01-01, or after a release run fails on the token, whichever comes first: the
|
||||
maintainer chose on 2026-10-02 to wait and see whether the retirement bites. It holds back no
|
||||
release: when the rest of its release is done, it moves to the next. `0.1.0` published only once the
|
||||
npm token carried **Bypass 2FA**: the account requiring no 2FA on writes was not enough, and npm
|
||||
answered `EOTP` until the token itself bypassed. npm retires bypass-2FA tokens for direct publishing
|
||||
around January 2027, leaving them `npm stage publish`, which a maintainer approves with 2FA; its
|
||||
replacement — trusted publishing over OIDC — supports GitHub-hosted Actions, GitLab.com's shared
|
||||
runners and CircleCI's cloud, self-hosted runners planned without a date. Revisit: whether npm has
|
||||
added Gitea or self-hosted OIDC, and otherwise whether the release moves to the staged publish —
|
||||
which fits badly with publish-on-merge, and is the maintainer's trade to weigh. The Goals and G
|
||||
cells are provisional: no README goal covers the release path.
|
||||
|
||||
### 31. Make the branch-coverage figure repeat across runs of an unchanged tree.
|
||||
|
||||
Three Node test legs over one unchanged tree reported `emit/inline-line.ts` at 95.83%, 96.23% and
|
||||
96.23%, and the total at 98.80%, 98.84% and 98.84% (2026-09-21). `--experimental-test-coverage`
|
||||
counts branches off V8's own coverage, which the runner's parallel files and V8's optimization make
|
||||
run-dependent, so the number the floor is read against is not the code's alone. The floor of 98
|
||||
holds today on 0.8 points of slack and `docs/decisions.md` §The coverage floors says it only ever
|
||||
moves upward, so the first raise to the measured figure reddens a run that changed nothing. Make the
|
||||
measurement repeatable, or state the number the floor may be raised to and why it is not the
|
||||
measured one.
|
||||
|
||||
### 33. Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.
|
||||
|
||||
`adfToMarkdown` spends 23 s on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking
|
||||
cannot spell re-emits the whole line before riding the carry, quadratic in the runs, and the plain
|
||||
reduction's `spellableLine` drops one mark per re-emit the same way. Make both linear.
|
||||
|
||||
### 46. Publish the bundle size in the README, failing the release pipeline when it drifts.
|
||||
|
||||
Lands after item 7, which changes it. The quantity is what a consumer downloads and loads: the
|
||||
tarball `npm pack` produces, its unpacked `dist`, and the built JavaScript minified + gzipped — the
|
||||
figure the competitors advertise (marklassian's "12kb") and the only apples-to-apples one, since
|
||||
ours ships tsc's unminified output and no minifier yet (decide here whether to minify for the build
|
||||
or report the unminified gzip). The figure lands in README §The package beside the "no runtime
|
||||
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6
|
||||
kB.
|
||||
|
||||
### 8. Ship a CLI.
|
||||
|
||||
The Goals and G cells are provisional: no README goal or persona covers a CLI yet. The chunk
|
||||
proposes both (the maintainer, 2026-10-02), and they land in the README's `## Goals` and `##
|
||||
Audience` with the CLI.
|
||||
|
||||
+1
-1
@@ -27,6 +27,6 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"exclude": ["src/**/*.test.ts", "src/property-harness.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/conformance/property-harness.ts"],
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user