Compare commits
216 Commits
cf9b12b973
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 31d3f53a58 | |||
| 80a45b69da | |||
| 8287a483db | |||
| 7b38eba29e | |||
| ee9a7e7562 | |||
| 317262b240 | |||
| eccfd12d2d | |||
| 7bab478729 | |||
| 00c89dd63b | |||
| fd3452b124 | |||
| d56e5b4f57 | |||
| 36604b1e2b | |||
| 1791542b3c | |||
| f7d2eb6517 | |||
| 79005f07be | |||
| 7f67998c3b | |||
| b39525cad7 | |||
| d0cb63fcf2 | |||
| 1d3a020926 | |||
| 4579f32fcd | |||
| 6df17a7069 | |||
| 4349df9017 | |||
| d68f311a14 | |||
| 608ef266ed | |||
| a2c620da27 | |||
| e4eecebeab | |||
| 230d590fb4 | |||
| 7846502a1a | |||
| 79d9b4db53 | |||
| 912c5039aa | |||
| fb78063f12 | |||
| 5cffe82dd5 | |||
| d9f2d325ca | |||
| 419bbcb4f2 | |||
| 4d7e8c944c | |||
| ff9bb7fab2 | |||
| 5a7279059d | |||
| 0ac5dccf11 | |||
| 6fc0d6bba9 | |||
| 423dcbbff5 | |||
| b9760d0af2 | |||
| d7ba8e6aae | |||
| bba2fa409e | |||
| c3bc2eb572 | |||
| 2700184e56 | |||
| d9c3e4b349 | |||
| 2d02165702 | |||
| a565d49c24 | |||
| ade3997cfd | |||
| 31576bb824 | |||
| 723c0e31ef | |||
| a5238e4fe5 | |||
| 9f0f520afd | |||
| 45f1bee94b | |||
| 20a191e7e9 | |||
| 3da45a9da7 | |||
| 0bc19238b4 | |||
| 1458b09449 | |||
| eab28ae8ff | |||
| afb53ca594 | |||
| 70e2639982 | |||
| fdbf43398c | |||
| 0c507f981f | |||
| 1d0ae695da | |||
| f180943edf | |||
| 9ab7286e6d | |||
| bfc4b2ce00 | |||
| 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",
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
"categories": { "correctness": "off" },
|
"categories": { "correctness": "off" },
|
||||||
"ignorePatterns": ["src/**/*.test.ts", "src/property-harness.ts"],
|
"ignorePatterns": ["src/**/*.test.ts", "src/conformance/property-harness.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"eslint/max-lines-per-function": ["error", { "IIFEs": true, "max": 52, "skipBlankLines": false, "skipComments": false }]
|
"eslint/max-lines-per-function": ["error", { "IIFEs": true, "max": 52, "skipBlankLines": false, "skipComments": false }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,246 +1,90 @@
|
|||||||
# Working in this repo
|
# Working in this repo
|
||||||
|
|
||||||
Decisions a reader would otherwise relitigate, and the rules for every collaborator, human or
|
The rules for every collaborator, human or agent, and an index of the decisions a reader would
|
||||||
agent. Using the library: `README.md`. What is still to build: `todo.md`.
|
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
|
In `docs/decisions.md`:
|
||||||
through ADF: four conversions exist to keep correct — never write a fifth. No fourth format, ever;
|
|
||||||
each one doubles the directions.
|
|
||||||
|
|
||||||
## 2. The round-trip is the product
|
- Plain markdown is a flavour of the grammar
|
||||||
|
- The round-trip is the product
|
||||||
|
- Markdown in is a canonical fixpoint
|
||||||
|
- Equality is deep
|
||||||
|
- `!adf:textBreak{}` parts text CommonMark would join
|
||||||
|
- An empty key spells `empty`
|
||||||
|
- `-0` is spelled `-0`
|
||||||
|
- Empty markdown is a document of no blocks
|
||||||
|
- Unknown nodes ride the carry
|
||||||
|
- The carry fence names the node type
|
||||||
|
- A code block is a fence per text node
|
||||||
|
- 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
|
||||||
|
- The project ships under the comprehension floor until items 59, 60, 61 and 62 land
|
||||||
|
- 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
|
## 1. Nothing about any consumer
|
||||||
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
|
|
||||||
|
|
||||||
No Jira client, no HTTP, no REST shapes, no issue keys, no actual consumer named anywhere. Design
|
No Jira client, no HTTP, no REST shapes, no issue keys, no actual consumer named anywhere. Design
|
||||||
against the README's personas.
|
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
|
- The bump commit renames `CHANGELOG.md`'s `## Unreleased` to the version.
|
||||||
differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR.
|
- Exact versions: `save-exact=true` in `.npmrc`.
|
||||||
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.
|
|
||||||
- Renovate watches devDependencies, Docker pins and action tags; automerges everything on green CI.
|
- 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
|
- 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
|
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.
|
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.
|
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,
|
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.
|
containers are torn down after a run. A test reaches only for what Node's, Deno's and Bun's `node:`
|
||||||
|
shims all carry.
|
||||||
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.
|
|
||||||
|
|
||||||
Every leg announces its name and, where a container is in play, the image, before it runs and its
|
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
|
elapsed time after, `publish.sh` alongside `ci.sh`, so a long run reads as progress rather than as
|
||||||
@@ -248,211 +92,97 @@ 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
|
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`
|
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
|
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
|
`PROPERTY_RUNS=<runs>` raises the property runs and randomizes the seed for local digging. The
|
||||||
functions, and a branch floor that only ever moves upward. It sits below 100 because the guards
|
generators and run parameters properties share live in `src/conformance/property-harness.ts`,
|
||||||
`noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index
|
outside the build and coverage.
|
||||||
compared against `undefined` — have a half no valid document reaches.
|
|
||||||
|
|
||||||
The size ratchet is the other such number, `.oxlintrc.json`'s single rule over the files
|
Each `- ` bullet in `spec/flavour.md`'s `## Block nodes`, `## Inline nodes` and `## Marks` declares
|
||||||
`tsconfig.build.json` builds: a per-function line ceiling, set at that set's worst and moving only
|
the nodes named before its first em dash, with the attributes following `Attributes: ` — a
|
||||||
downward. It covers the built files alone, since one ceiling over the tests too would have to be
|
parenthesized value set reading `string`; fenced examples are skipped. Keep prose out of a bullet.
|
||||||
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.
|
|
||||||
|
|
||||||
The corpus, all checked in: hand-built fixtures per node and combination; real ADF Atlassian's
|
## 4. Code rules
|
||||||
editor wrote; the CommonMark spec suite against `markdownToAdf` and `markdownToHtml`.
|
|
||||||
|
|
||||||
Beside the corpus, properties run over documents generated from the node tables and over generated
|
- Two-space indent, English everywhere. Alphabetical order wherever order carries no meaning,
|
||||||
markdown, on a fixed seed in the gate; `PROPERTY_RUNS=<runs>` raises the runs and randomizes the
|
keyed on the name a line introduces: an import sorts on its first binding, type imports ahead of
|
||||||
seed for local digging, and a counterexample found becomes a round-trip fixture. The generators and
|
value imports, so moving or renaming a module reorders nothing.
|
||||||
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.
|
|
||||||
- No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the
|
- 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
|
fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
|
||||||
unrepresentable.
|
unrepresentable.
|
||||||
- `src/adf/` holds ADF's own knowledge, imports no format, and is where a construct both formats
|
- Failures are values: everything returns `Result<T>`, nothing throws. `try/catch` only wrapped
|
||||||
read lives: the question is answered in ADF's vocabulary — a node type, an attribute kind, a
|
tightly around a call that genuinely throws, converted to a result on the spot. A reader with no
|
||||||
content model — and no delimiter, element name or escape reaches it. A helper that cannot answer
|
path to name returns `Read<T>`, and the walk attaches the path where it knows it.
|
||||||
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).
|
|
||||||
- Reuse before adding; the smallest sufficient diff is the benchmark; no speculative generality —
|
- Reuse before adding; the smallest sufficient diff is the benchmark; no speculative generality —
|
||||||
a second consumer, or it goes.
|
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.
|
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
|
- 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.
|
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
|
- 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.
|
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,
|
- Published text — npm README, error messages, API docs — never references internal systems,
|
||||||
tickets or repos.
|
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.
|
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
|
A session works one chunk, starting from the first item in `todo.md` not waiting on an unmet "Lands
|
||||||
validation or exported validator — a refusal that keeps the round-trip is not schema validation,
|
after", and stops when that chunk merges, whatever it was asked to finish: a release is a chain of
|
||||||
so the one a spelled node carrying the same mark type twice earns stays, and input nesting a
|
sessions, so an instruction to work until a release is done names the chain, not the session. An
|
||||||
spelling inside its own kind (`*(*a*)*`) names that mark once, no shipped CSS (§4), no
|
open PR is a chunk already in flight, and finishing it is the session.
|
||||||
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.
|
|
||||||
Per chunk:
|
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
|
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
|
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
|
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.
|
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 —
|
3. Merge the PR (standing authorization, this repo only, granted by the maintainer through the
|
||||||
the maintainer, 2026-09-13), check the box in `todo.md` and move the item's text to
|
`0.2.0` release), report, stop.
|
||||||
`todo-history.md`, leaving its title behind, report, stop.
|
|
||||||
|
|
||||||
Reserved for the maintainer whatever any rule here says: changing `version` in `package.json` (a
|
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
|
### Ask, don't guess
|
||||||
|
|
||||||
Any choice where what the maintainer would pick is not near-certain gets asked, and the answer
|
Any choice where what the maintainer would pick is not near-certain gets asked. The confidence bar
|
||||||
lands as a decision in this file. The confidence bar is very high — asking too often is the
|
is very high — asking too often is the accepted cost, guessing wrong is not.
|
||||||
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
|
An ask is a gap in `docs/decisions.md`, and its answer is the entry that closes it, landing there —
|
||||||
alone. Before asking, name the class the question belongs to and the earlier `(the maintainer, …)`
|
never the instance alone; an answer that is a goal lands in the README, one that is a working rule
|
||||||
entries of that class; where a rule already decides it, apply it without asking, and where the rule
|
here. Before asking, name the class the question belongs to and the entries of that class; where
|
||||||
reads two ways on this input, that reading is the ask. Never ask "A or B?": state the gap, the
|
one already decides it, apply it without asking, and where it reads two ways on this input, that
|
||||||
earlier asks of its class, the nearest text here, a candidate rule in this file's voice and section,
|
reading is the ask. Never ask "A or B?": state the gap, the earlier entries of its class, the
|
||||||
and the instance it yields, and ask for the rule. The maintainer answers the rule, the rule lands
|
nearest text, a candidate entry in that file's voice, and the instance it yields. An entry that
|
||||||
here, and the instance follows from it in the chunk. A rule that keeps collecting instances is
|
keeps collecting instances is wrong: rewrite it.
|
||||||
wrong: rewrite it rather than append to 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 question 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
|
A writer panel settles every new or changed markdown or HTML spelling: a reader panel whose readers
|
||||||
in a release, weighed against every item on that release by the personas and §1–§3 — an item it
|
are the people who read and write that format (README `## Audience`). A panel of the developer
|
||||||
outweighs moves later. A weighing no rule decides is asked as a gap.
|
personas settles a question about what an app relies on.
|
||||||
- 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.
|
### Stated numbers
|
||||||
- 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
|
A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks, naming
|
||||||
maintainer's approval taken before any code.
|
the number it can reach. A number the code needs and no entry states is a gap.
|
||||||
|
|
||||||
### The continuous loop
|
### The continuous loop
|
||||||
|
|
||||||
@@ -461,3 +191,14 @@ and `todo.md` and trusting them over anything remembered from earlier iterations
|
|||||||
is a thin driver: each chunk's work runs in a fresh-context subagent holding this file as its
|
is a thin driver: each chunk's work runs in a fresh-context subagent holding this file as its
|
||||||
charter, and the driver only relays maintainer questions, runs the review flow, merges, and cleans
|
charter, and the driver only relays maintainer questions, runs the review flow, merges, and cleans
|
||||||
up. The loop stops when only maintainer-reserved acts remain.
|
up. The loop stops when only maintainer-reserved acts remain.
|
||||||
|
|
||||||
|
## 8. Scoring run
|
||||||
|
|
||||||
|
The comprehension panel's fill-ins:
|
||||||
|
|
||||||
|
- Language: TypeScript.
|
||||||
|
- Kind: a pure-function document converter with hand-written parsers and emitters.
|
||||||
|
- Domain: Atlassian Document Format and CommonMark parsing.
|
||||||
|
- Domain docs: the CommonMark spec, ADF's JSON schema and `spec/flavour.md`.
|
||||||
|
- 3am question: a viewer/editor app reports that a document it saved comes back with two text
|
||||||
|
nodes merged and a mark gone after `markdownToAdf(adfToMarkdown(doc))`.
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- **Breaking:** directives, the inline opaque carry among them (now `!adf:carry{json="…"}`), 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. Convert stored markdown per `MIGRATION.md`.
|
||||||
|
- **Breaking:** the block carry is a code fence whose info string `adf:<type>` names the node's
|
||||||
|
type, its body the node's JSON without `type`, and a code fence whose info string opens `adf:` is
|
||||||
|
claimed, and `adf` is an ordinary code block language. Convert stored markdown per `MIGRATION.md`.
|
||||||
|
- **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` read markdown holding no block as a
|
||||||
|
document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}`
|
||||||
|
spells a document holding no `content` key.
|
||||||
|
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it: two adjacent text
|
||||||
|
nodes CommonMark would read back as one are parted by `!adf:textBreak{}`, an empty `attrs`,
|
||||||
|
`content` or `marks` is spelled `{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is
|
||||||
|
spelled `-0`, and a `codeBlock` of several text nodes is a fence per node. A `codeBlock` holding
|
||||||
|
other than plain text nodes rides the block carry, where it was refused.
|
||||||
|
- **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.
|
||||||
+13
-7
@@ -5,8 +5,9 @@
|
|||||||
Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling without an error,
|
Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling without an error,
|
||||||
turning each directive into text and each carried node into an `adf` code block. Before `0.2.0`
|
turning each directive into text and each carried node into an `adf` code block. Before `0.2.0`
|
||||||
reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the
|
reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the
|
||||||
recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by
|
recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by the
|
||||||
the tables below. Stored ADF needs no change.
|
tables below. Stored ADF needs one change: give a document holding no `content` key `content: []`.
|
||||||
|
`0.1.0` built that shape from empty markdown, meaning the empty document.
|
||||||
|
|
||||||
### Convert markdown
|
### Convert markdown
|
||||||
|
|
||||||
@@ -22,15 +23,17 @@ import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1'
|
|||||||
|
|
||||||
function migrateMarkdown(stored: string) {
|
function migrateMarkdown(stored: string) {
|
||||||
const parsed = markdownToAdf010(stored)
|
const parsed = markdownToAdf010(stored)
|
||||||
return parsed.ok ? adfToMarkdown(parsed.value) : parsed
|
// 0.1.0 dropped an empty content array, so a document with no content key meant an empty one.
|
||||||
|
return parsed.ok ? adfToMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Convert each document once: a second pass can return ok while turning the directives into text.
|
- Convert each document once: a second pass can return ok while turning the directives into text.
|
||||||
Stop `0.1.0` writing first, and record which documents are converted.
|
Stop `0.1.0` writing first, and record which documents are converted.
|
||||||
- A refusal carrying `position` is `0.1.0`'s parse, which refused that markdown before too. One
|
- A refusal carrying `position` is `0.1.0`'s parse, which refused that markdown before too. One
|
||||||
without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF rather than keeping
|
without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF, with
|
||||||
the unconverted markdown.
|
`content: parsed.value.content ?? []` as the recipe gives it, rather than keeping the unconverted
|
||||||
|
markdown.
|
||||||
|
|
||||||
### Spellings
|
### Spellings
|
||||||
|
|
||||||
@@ -40,12 +43,12 @@ function migrateMarkdown(stored: string) {
|
|||||||
| `::media {id=a type=file}` | `!adf:media {id=a type=file}` |
|
| `::media {id=a type=file}` | `!adf:media {id=a type=file}` |
|
||||||
| `::taskItem TODO {localId=i}`: an empty `caption`, `decisionItem`, `paragraph` or `taskItem`, or an empty `heading` carrying `localId` | `!adf:taskItem TODO {localId=i}` then `!adf:/taskItem` |
|
| `::taskItem TODO {localId=i}`: an empty `caption`, `decisionItem`, `paragraph` or `taskItem`, or an empty `heading` carrying `localId` | `!adf:taskItem TODO {localId=i}` then `!adf:/taskItem` |
|
||||||
| `:mention[@Mikael]{id=5b10a2}` | `!adf:mention[@Mikael]{id=5b10a2}` |
|
| `:mention[@Mikael]{id=5b10a2}` | `!adf:mention[@Mikael]{id=5b10a2}` |
|
||||||
| the `adf` code fence and `:adf{json="…"}` | the `carry` code fence and `!adf:carry{json="…"}` |
|
| the `adf` code fence and `:adf{json="…"}` | the `adf:<type>` code fence, its JSON without `type`, and `!adf:carry{json="…"}` |
|
||||||
| `\:` keeps a directive literal | `\!adf:` keeps a directive literal |
|
| `\:` keeps a directive literal | `\!adf:` keeps a directive literal |
|
||||||
| `:adf{json="…"}` carrying a link for its `collection`, `id` or `occurrenceKey` | `!adf:link[text]{attrs}` |
|
| `:adf{json="…"}` carrying a link for its `collection`, `id` or `occurrenceKey` | `!adf:link[text]{attrs}` |
|
||||||
|
|
||||||
A colon run and `:name[` are plain text now, and `adf` an ordinary code block language; text
|
A colon run and `:name[` are plain text now, and `adf` an ordinary code block language; text
|
||||||
holding an unescaped `!adf:` and a `carry` fence are claimed instead.
|
holding an unescaped `!adf:` and a code fence whose info string opens `adf:` are claimed instead.
|
||||||
|
|
||||||
### Readings
|
### Readings
|
||||||
|
|
||||||
@@ -54,6 +57,8 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
|
|||||||
| Input | `0.1.0` | `0.2.0` |
|
| Input | `0.1.0` | `0.2.0` |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them |
|
| a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them |
|
||||||
|
| markdown holding no block (`markdownToAdf("")`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former |
|
||||||
|
| a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | the block carry, refusing a body that is not one node's canonical JSON; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block |
|
||||||
|
|
||||||
### Error codes
|
### Error codes
|
||||||
|
|
||||||
@@ -63,6 +68,7 @@ it named converts.
|
|||||||
| Input | `0.1.0` | `0.2.0` |
|
| Input | `0.1.0` | `0.2.0` |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` |
|
| a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` |
|
||||||
|
| a `codeBlock` holding other than plain text nodes, on emit | `unsupported-node-shape` | rides the block carry |
|
||||||
| a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` |
|
| a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` |
|
||||||
| a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` |
|
| a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` |
|
||||||
| an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` |
|
| an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` |
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ an HTML dialect.
|
|||||||
|
|
||||||
**Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at
|
**Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at
|
||||||
`0.2.0`.**
|
`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).
|
[`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).
|
Upgrading from `0.1.0`: [convert your markdown first](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/MIGRATION.md).
|
||||||
|
|
||||||
@@ -21,39 +24,37 @@ represent.
|
|||||||
|
|
||||||
## Goals
|
## 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
|
1. **Lossless, and every call returns a result, never a throw.**
|
||||||
know included; one that has no spelling is refused and says where, never silently reduced.
|
2. **ADF is the hub.**
|
||||||
Every goal below gives way to this one.
|
3. **Each format reads and writes as its standard says.**
|
||||||
2. **Three formats, ADF the hub.** ADF, one markdown flavour, one HTML dialect, markdown↔HTML
|
4. **Our markdown is CommonMark, extended only where CommonMark has no spelling.**
|
||||||
composing through ADF — four conversions to keep correct, never a fifth, and never a fourth
|
5. **No surprises: output reads and edits the way its audience expects.**
|
||||||
format.
|
6. **Lossy conversion drops form, never content.**
|
||||||
3. **Plain CommonMark is input.** Markdown written for something else converts — the exceptions
|
7. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.**
|
||||||
below are the whole of them — and every spelling the flavour claims on top of CommonMark is
|
8. **Fast, and linear in the document's size.**
|
||||||
escapable, so the flavour is opt-in.
|
9. **Easy to find, and clear at a glance what it does.**
|
||||||
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.
|
|
||||||
|
|
||||||
## Audience
|
## Audience
|
||||||
|
|
||||||
Application developers embedding the library, addressed as personas rather than named consumers
|
Application developers embedding the library, in four personas. All four rely on the guarantees
|
||||||
(AGENTS.md §7). All four rely on the guarantees below and on `code` being a closed list; none may
|
below and on an error's `code` being a closed list; none may rely on an error message's wording,
|
||||||
rely on an error message's wording, which is free text.
|
which is free text.
|
||||||
|
|
||||||
- **Viewer/editor app** — shows a document, lets a human edit, posts it back. Relies on the
|
- **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
|
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.
|
refusal arriving before the save rather than after.
|
||||||
- **Bot posting content** — turns generated markdown into ADF. Relies on plain CommonMark being
|
- **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
|
- **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.
|
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.
|
- **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.
|
||||||
|
|
||||||
|
Behind those apps, the people who read and write the markdown, and later the HTML: product
|
||||||
|
managers, engineers and support agents working in Atlassian products through a plugin or another
|
||||||
|
UI. They know markdown and not ADF, and rely on every spelling saying what it means to them.
|
||||||
|
|
||||||
## The shape
|
## The shape
|
||||||
|
|
||||||
@@ -73,25 +74,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
|
```ts
|
||||||
adfToMarkdown(doc: AdfDocument): Result<string>
|
adfToMarkdown(doc: AdfDocument): Result<string>
|
||||||
markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
|
markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
|
||||||
isAdfDocument(v: unknown): v is AdfDocument
|
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
|
adfToHtml(doc: AdfDocument): Result<string> // 0.2.0
|
||||||
htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.2.0
|
htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.2.0
|
||||||
markdownToHtml(markdown: string): Result<string> // 0.2.0, via ADF
|
markdownToHtml(markdown: string): Result<string> // 0.2.0
|
||||||
htmlToMarkdown(html: string): Result<string> // 0.2.0, via ADF
|
htmlToMarkdown(html: string): Result<string> // 0.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
|
`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
|
## The errors
|
||||||
|
|
||||||
An ADF node type this version does not know is not an error: it is carried opaquely and restores
|
Serves Goal 1. An ADF node type this version does not know is not an error: the lossless pair
|
||||||
unchanged (AGENTS.md §3).
|
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`,
|
`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
|
stable across minors and safe to `switch` on exhaustively with no `default`; `message` is free text
|
||||||
@@ -111,14 +171,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
|
or before the refusal — currently the start of the line the enclosing block begins on; a later
|
||||||
minor may narrow that, never widen it.
|
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 |
|
| 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-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 an opaque carry | write the spelling the message names, or keep it literal: escape the prefix — `\!adf:`, block and inline alike — or, for a code fence whose info string opens `adf:`, drop the info string and wrap the fence in `!adf:codeBlock {language="adf:…"}` |
|
||||||
| `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 |
|
| `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 |
|
| `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 |
|
| `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`:
|
Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`:
|
||||||
@@ -138,20 +198,28 @@ 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-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 |
|
| `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-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, or a reserved directive stands out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document | fix what the message names; `spec/flavour.md` lists every type's attributes and body |
|
||||||
|
|
||||||
## The guarantees
|
## The guarantees
|
||||||
|
|
||||||
- `markdownToAdf(adfToMarkdown(doc))` equals `doc` — unknown node types included, carried opaquely
|
Serves Goals 1, 3 and 4.
|
||||||
(AGENTS.md §3).
|
|
||||||
|
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as JSON, for a document of plain objects
|
||||||
|
as `JSON.parse` builds them — every key and value as `doc` holds it, adjacent text nodes, an
|
||||||
|
empty `attrs`, `content` or `marks` and `-0` included, and unknown node types carried opaquely
|
||||||
|
([`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`
|
- 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
|
names, with four carve-outs — literal text matching directive, pipe-table or strikethrough syntax,
|
||||||
syntax is claimed (escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as
|
and a code fence whose info string opens `adf:`, are claimed (each can be kept literal —
|
||||||
its own title-less paragraph; mid-text and titled images are error results, save an image inside
|
`spec/flavour.md`) — and one gap: a CommonMark image fits only as its own title-less paragraph;
|
||||||
another's description, which flattens into the alt text. Converting back yields the library's
|
mid-text and titled images are error results, save an image inside another's description, which
|
||||||
canonical spelling, which round-trips byte-identically — where it converts back at all: a parse
|
flattens into the alt text. Converting back yields the library's canonical spelling, which
|
||||||
succeeding is no promise of that, so keep the source until the way back succeeds.
|
round-trips byte-identically — where it converts back at all: a parse succeeding is no promise of
|
||||||
``` ` `` ` ``` reads cleanly and then refuses.
|
that, so keep the source until the way back succeeds. ``` ` `` ` ``` reads cleanly and then
|
||||||
|
refuses.
|
||||||
- Four CommonMark spellings parse without an error and build a document the reference
|
- Four CommonMark spellings parse without an error and build a document the reference
|
||||||
implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's
|
implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's
|
||||||
empty link, a list continuing past a marker change stays one list against CommonMark's two, a
|
empty link, a list continuing past a marker change stays one list against CommonMark's two, a
|
||||||
@@ -169,17 +237,20 @@ emit refuses:
|
|||||||
error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in
|
error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in
|
||||||
input.
|
input.
|
||||||
- Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist
|
- Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist
|
||||||
is the `taskList` directive.
|
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.
|
- A document nested deeper than 500 levels is an error result, not a stack overflow, and no input
|
||||||
- The emitted formats are semver surface (AGENTS.md §8).
|
makes a call loop forever.
|
||||||
- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides
|
- 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))` deep-equals `doc`; fidelity HTML cannot express rides
|
||||||
`data-*` attributes. Foreign HTML maps a documented element set, which markdown's raw HTML reads
|
`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
|
through as well, and a construct outside it is an error; well-formed HTML only — no tag-soup
|
||||||
recovery.
|
recovery.
|
||||||
|
|
||||||
## The package
|
## The package
|
||||||
|
|
||||||
ESM only, no runtime dependencies, public npmjs. Built JavaScript with `.d.ts` beside it.
|
Serves Goal 7. ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts`
|
||||||
Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs under Node,
|
beside it. Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs
|
||||||
Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
|
under Node, Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
|
||||||
Contract: `AGENTS.md` §5–6.
|
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.
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
try {
|
try {
|
||||||
const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js')
|
const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js')
|
||||||
|
// WebDriver's JSON reads -0 back as 0, so a document crosses as JSON text with -0 tagged; run.js revives it.
|
||||||
|
const spelled = (result) => (result.ok ? { ok: true, value: JSON.stringify(result.value, (_, value) => (Object.is(value, -0) ? '\u0000-0' : value)) } : result)
|
||||||
window.convertCorpus = (corpus) => ({
|
window.convertCorpus = (corpus) => ({
|
||||||
errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
|
errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
|
||||||
normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
|
normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: spelled(markdownToAdf(markdown)) })),
|
||||||
realPayloads: corpus.realPayloads.map(({ json, name }) => {
|
realPayloads: corpus.realPayloads.map(({ json, name }) => {
|
||||||
const adf = JSON.parse(json)
|
const adf = JSON.parse(json)
|
||||||
const emitted = adfToMarkdown(adf)
|
const emitted = adfToMarkdown(adf)
|
||||||
return { emitted, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? markdownToAdf(emitted.value) : undefined }
|
return { emitted, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(markdownToAdf(emitted.value)) : undefined }
|
||||||
}),
|
}),
|
||||||
roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
|
roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
|
||||||
const adf = JSON.parse(json)
|
const adf = JSON.parse(json)
|
||||||
return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: markdownToAdf(markdown) }
|
return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: spelled(markdownToAdf(markdown)) }
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict'
|
|||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
import { extname, join } from 'node:path'
|
import { extname, join } from 'node:path'
|
||||||
import { readFileSync, readdirSync } from 'node:fs'
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
import { toEditorNormal } from '../dist/adf/editor-normal.js'
|
|
||||||
|
|
||||||
const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' }
|
const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' }
|
||||||
const driver = 'http://127.0.0.1:4444'
|
const driver = 'http://127.0.0.1:4444'
|
||||||
@@ -50,6 +49,11 @@ function fixtureNames(kind, extension) {
|
|||||||
.sort()
|
.sort()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// convert-corpus.js tags -0 so it survives WebDriver's JSON.
|
||||||
|
function revived(text) {
|
||||||
|
return JSON.parse(text, (_, value) => (value === '\u0000-0' ? -0 : value))
|
||||||
|
}
|
||||||
|
|
||||||
function refusal(result) {
|
function refusal(result) {
|
||||||
return result.ok ? '' : `${result.error.code}: ${result.error.message}`
|
return result.ok ? '' : `${result.error.code}: ${result.error.message}`
|
||||||
}
|
}
|
||||||
@@ -107,7 +111,7 @@ for (const [index, { json, markdown, name }] of corpus.roundTrip.entries()) {
|
|||||||
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
|
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
|
||||||
assert.equal(result.emitted.value, markdown)
|
assert.equal(result.emitted.value, markdown)
|
||||||
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
|
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
|
||||||
assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(json))
|
assert.deepEqual(revived(result.parsed.value), JSON.parse(json))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +119,7 @@ for (const [index, { name }] of corpus.normalization.entries()) {
|
|||||||
const result = results.normalization[index]
|
const result = results.normalization[index]
|
||||||
checking(name, result, () => {
|
checking(name, result, () => {
|
||||||
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
|
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
|
||||||
assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(fixture(name, '.json')))
|
assert.deepEqual(revived(result.parsed.value), JSON.parse(fixture(name, '.json')))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +129,7 @@ for (const [index, { json, name }] of corpus.realPayloads.entries()) {
|
|||||||
assert.ok(result.isDocument, `${name}.json is no ADF document`)
|
assert.ok(result.isDocument, `${name}.json is no ADF document`)
|
||||||
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
|
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
|
||||||
assert.ok(result.parsed.ok, `it did not parse back — ${refusal(result.parsed)}`)
|
assert.ok(result.parsed.ok, `it did not parse back — ${refusal(result.parsed)}`)
|
||||||
assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(json))
|
assert.deepEqual(revived(result.parsed.value), JSON.parse(json))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -1,10 +1,10 @@
|
|||||||
# The corpus
|
# 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
|
- `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
|
document, byte for byte, and that `markdownToAdf` must read back to it (`docs/decisions.md` §The
|
||||||
by what the fixture exercises.
|
round-trip is the product). Grouped by what the fixture exercises.
|
||||||
- `normalization/` — `<name>.md` + `<name>.json`: markdown input, and the document
|
- `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
|
`markdownToAdf` must build from it, which must in turn emit and read back to itself. The
|
||||||
markdown is not canonical.
|
markdown is not canonical.
|
||||||
@@ -16,11 +16,11 @@ 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`
|
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
|
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
|
`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
|
model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap).
|
||||||
milestone may close).
|
|
||||||
|
|
||||||
JSON is editor-normal (AGENTS.md §2), two-space indent, keys sorted. `spec.json` is the vendored,
|
JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's
|
||||||
upstream machine-readable suite, byte-exact from
|
(`docs/decisions.md` §Equality is deep). `spec.json` is the vendored, upstream machine-readable
|
||||||
[spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John
|
suite, byte-exact from [spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json)
|
||||||
MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not
|
(CommonMark 0.31.2, © John MacFarlane,
|
||||||
re-serialized by the corpus gate.
|
[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 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
```adf:
|
||||||
|
{
|
||||||
|
"type": "blockCard"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
```adf:\\
|
||||||
|
{}
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
```adf:blockCard
|
||||||
|
{
|
||||||
|
"type": "blockCard"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
```carry
|
```adf:blockCard
|
||||||
{"type":
|
{"attrs":
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
!adf:codeBlock {attrs=empty}
|
||||||
|
```js
|
||||||
|
a
|
||||||
|
```
|
||||||
|
```js
|
||||||
|
b
|
||||||
|
```
|
||||||
|
!adf:/codeBlock
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
!adf:codeBlock
|
||||||
|
```
|
||||||
|
a
|
||||||
|
```
|
||||||
|
```
|
||||||
|
```
|
||||||
|
!adf:/codeBlock
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
!adf:codeBlock
|
||||||
|
```js
|
||||||
|
a
|
||||||
|
```
|
||||||
|
```ts
|
||||||
|
b
|
||||||
|
```
|
||||||
|
!adf:/codeBlock
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
!adf:status[!adf:carry{json="{\"marks\":[],\"text\":\"x\",\"type\":\"text\"}"}]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
!adf:doc
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
!adf:doc {content=none}
|
||||||
|
|
||||||
|
Text.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
!adf:panel info {attrs=empty}
|
||||||
|
Text.
|
||||||
|
!adf:/panel
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
!adf:paragraph {content=empty}
|
||||||
|
Text.
|
||||||
|
!adf:/paragraph
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
!adf:paragraph {attrs=none}
|
||||||
|
Text.
|
||||||
|
!adf:/paragraph
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
**!adf:date{marks=empty}**
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
a!adf:textBreak{x=y}b
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
a!adf:textBreak[x]b
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
**a**!adf:textBreak{}b
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
!adf:textBreak{}Hello
|
||||||
@@ -14,7 +14,19 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"language": "carry"
|
"language": "adf:blockCard"
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "{\n \"attrs\": {}\n}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "codeBlock"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"language": "adf:"
|
||||||
},
|
},
|
||||||
"content": [
|
"content": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
!adf:codeBlock {language=carry}
|
```carry
|
||||||
```
|
|
||||||
{
|
{
|
||||||
"type": "blockCard"
|
"type": "blockCard"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
!adf:codeBlock {language="adf:blockCard"}
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"attrs": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
!adf:/codeBlock
|
!adf:/codeBlock
|
||||||
|
|
||||||
!adf:codeBlock {language=carry}
|
!adf:codeBlock {language="adf:"}
|
||||||
````
|
````
|
||||||
```
|
```
|
||||||
````
|
````
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"language": "js"
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "const a = 1\n",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "const b = 2",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "codeBlock"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "a",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "```",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "\n",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "codeBlock"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"language": "has`tick",
|
||||||
|
"wrap": true
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "x",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": " y ",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "codeBlock"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
!adf:codeBlock
|
||||||
|
```js
|
||||||
|
const a = 1
|
||||||
|
|
||||||
|
```
|
||||||
|
```js
|
||||||
|
const b = 2
|
||||||
|
```
|
||||||
|
!adf:/codeBlock
|
||||||
|
|
||||||
|
!adf:codeBlock
|
||||||
|
```
|
||||||
|
a
|
||||||
|
```
|
||||||
|
````
|
||||||
|
```
|
||||||
|
````
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
!adf:/codeBlock
|
||||||
|
|
||||||
|
!adf:codeBlock {language="has\u0060tick" wrap=true}
|
||||||
|
```
|
||||||
|
x
|
||||||
|
```
|
||||||
|
```
|
||||||
|
y
|
||||||
|
```
|
||||||
|
!adf:/codeBlock
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
!adf:doc {content=none}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "Plain words.",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [],
|
||||||
|
"type": "rule"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"level": 2
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "Title",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"marks": [],
|
||||||
|
"type": "heading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"panelType": "info"
|
||||||
|
},
|
||||||
|
"content": [],
|
||||||
|
"type": "panel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"attrs": {},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "Item",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "listItem"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "Next",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "listItem"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "bulletList"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [],
|
||||||
|
"type": "codeBlock"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"language": "js"
|
||||||
|
},
|
||||||
|
"marks": [],
|
||||||
|
"type": "codeBlock"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"language": "js"
|
||||||
|
},
|
||||||
|
"content": [],
|
||||||
|
"type": "codeBlock"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
!adf:paragraph {content=empty}
|
||||||
|
!adf:/paragraph
|
||||||
|
|
||||||
|
!adf:paragraph {attrs=empty}
|
||||||
|
Plain words.
|
||||||
|
!adf:/paragraph
|
||||||
|
|
||||||
|
!adf:rule {content=empty}
|
||||||
|
|
||||||
|
!adf:heading {level=2 marks=empty}
|
||||||
|
Title
|
||||||
|
!adf:/heading
|
||||||
|
|
||||||
|
!adf:panel info {content=empty}
|
||||||
|
!adf:/panel
|
||||||
|
|
||||||
|
!adf:bulletList
|
||||||
|
!adf:listItem {attrs=empty}
|
||||||
|
Item
|
||||||
|
!adf:/listItem
|
||||||
|
!adf:listItem
|
||||||
|
Next
|
||||||
|
!adf:/listItem
|
||||||
|
!adf:/bulletList
|
||||||
|
|
||||||
|
!adf:codeBlock {content=empty}
|
||||||
|
!adf:/codeBlock
|
||||||
|
|
||||||
|
!adf:codeBlock {marks=empty}
|
||||||
|
```js
|
||||||
|
```
|
||||||
|
!adf:/codeBlock
|
||||||
|
|
||||||
|
!adf:codeBlock {content=empty language=js}
|
||||||
|
!adf:/codeBlock
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
```carry
|
```adf:panel
|
||||||
{
|
{
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"rounded": true
|
"rounded": true
|
||||||
@@ -13,12 +13,11 @@
|
|||||||
],
|
],
|
||||||
"type": "paragraph"
|
"type": "paragraph"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"type": "panel"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```carry
|
```adf:panel
|
||||||
{
|
{
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"panelType": "extra info"
|
"panelType": "extra info"
|
||||||
@@ -33,8 +32,7 @@
|
|||||||
],
|
],
|
||||||
"type": "paragraph"
|
"type": "paragraph"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"type": "panel"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"order": -0
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "First",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "listItem"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "orderedList"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"level": -0
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "Title",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "heading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"extensionKey": "toc",
|
||||||
|
"extensionType": "com.atlassian.confluence.macro.core",
|
||||||
|
"parameters": {
|
||||||
|
"maxLevel": -0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "extension"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"data": [
|
||||||
|
-0
|
||||||
|
],
|
||||||
|
"url": "https://example.com/"
|
||||||
|
},
|
||||||
|
"type": "inlineCard"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"color": "#000000",
|
||||||
|
"size": -0
|
||||||
|
},
|
||||||
|
"type": "border"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "x",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"x": -0
|
||||||
|
},
|
||||||
|
"type": "blockCard"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
!adf:orderedList {order=-0}
|
||||||
|
!adf:listItem
|
||||||
|
First
|
||||||
|
!adf:/listItem
|
||||||
|
!adf:/orderedList
|
||||||
|
|
||||||
|
!adf:heading {level=-0}
|
||||||
|
Title
|
||||||
|
!adf:/heading
|
||||||
|
|
||||||
|
!adf:extension {extensionKey=toc extensionType="com.atlassian.confluence.macro.core" parameters="{\"maxLevel\":-0}"}
|
||||||
|
|
||||||
|
!adf:inlineCard{data="[-0]" url="https://example.com/"}!adf:border[x]{color="#000000" size=-0}
|
||||||
|
|
||||||
|
```adf:blockCard
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"x": -0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"content": [],
|
||||||
"type": "doc",
|
"type": "doc",
|
||||||
"version": 1
|
"version": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"attrs": {},
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": " ",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [],
|
||||||
|
"type": "date"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "a",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [],
|
||||||
|
"type": "hardBreak"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "b",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"id": "x",
|
||||||
|
"text": "@M"
|
||||||
|
},
|
||||||
|
"content": [],
|
||||||
|
"type": "mention"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "bare",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [],
|
||||||
|
"text": "marked",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {},
|
||||||
|
"type": "em"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "x",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": " and ",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"shortName": ":a:"
|
||||||
|
},
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {},
|
||||||
|
"type": "strong"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "emoji"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
!adf:date{attrs=empty} !adf:date{content=empty}
|
||||||
|
|
||||||
|
a!adf:hardBreak{marks=empty}b
|
||||||
|
|
||||||
|
!adf:mention[@M]{content=empty id=x}
|
||||||
|
|
||||||
|
bare!adf:carry{json="{\"marks\":[],\"text\":\"marked\",\"type\":\"text\"}"}
|
||||||
|
|
||||||
|
!adf:carry{json="{\"marks\":[{\"attrs\":{},\"type\":\"em\"}],\"text\":\"x\",\"type\":\"text\"}"} and !adf:carry{json="{\"attrs\":{\"shortName\":\":a:\"},\"marks\":[{\"attrs\":{},\"type\":\"strong\"}],\"type\":\"emoji\"}"}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "Hello, ",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "world",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "strong"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "Hello, ",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "strong"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "world",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "a",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "b",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "https://example.com/"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "a",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "https://example.com/"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "b",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "Line\n",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "next",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "a",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "b",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {},
|
||||||
|
"type": "underline"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "a",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "underline"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "b",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Hello, !adf:textBreak{}world
|
||||||
|
|
||||||
|
**Hello, !adf:textBreak{}world**
|
||||||
|
|
||||||
|
`a`!adf:textBreak{}`b`
|
||||||
|
|
||||||
|
[a!adf:textBreak{}b](https://example.com/)
|
||||||
|
|
||||||
|
Line!adf:text{text="\n"}!adf:textBreak{}next
|
||||||
|
|
||||||
|
!adf:underline[a!adf:textBreak{}b]
|
||||||
|
|
||||||
|
!adf:underline[a]{attrs=empty}!adf:underline[b]
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
> ```carry
|
> ```adf:blockCard
|
||||||
> {
|
> {
|
||||||
> "attrs": {
|
> "attrs": {
|
||||||
> "url": "https://example.com/quoted"
|
> "url": "https://example.com/quoted"
|
||||||
> },
|
> }
|
||||||
> "type": "blockCard"
|
|
||||||
> }
|
> }
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
- ```carry
|
- ```adf:blockCard
|
||||||
{
|
{
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"url": "https://example.com/listed"
|
"url": "https://example.com/listed"
|
||||||
},
|
}
|
||||||
"type": "blockCard"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"language": "js"
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "strong"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "const a = 1",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "codeBlock"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "a",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "hardBreak"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "codeBlock"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
```adf:codeBlock
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"language": "js"
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "strong"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "const a = 1",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```adf:codeBlock
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "a",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "hardBreak"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,23 +1,21 @@
|
|||||||
```carry
|
```adf:blockCard
|
||||||
{
|
{
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"url": "https://example.com/roadmap"
|
"url": "https://example.com/roadmap"
|
||||||
},
|
}
|
||||||
"type": "blockCard"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
!adf:panel info
|
!adf:panel info
|
||||||
The card below has no spelling yet.
|
The card below has no spelling yet.
|
||||||
|
|
||||||
```carry
|
```adf:embedCard
|
||||||
{
|
{
|
||||||
"attrs": {
|
"attrs": {
|
||||||
"layout": "wide",
|
"layout": "wide",
|
||||||
"url": "https://example.com/board",
|
"url": "https://example.com/board",
|
||||||
"width": 100
|
"width": 100
|
||||||
},
|
}
|
||||||
"type": "embedCard"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
!adf:/panel
|
!adf:/panel
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"url": "https://example.com/"
|
||||||
|
},
|
||||||
|
"type": "has`tick"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": " padded"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "two words"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
```adf:
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"url": "https://example.com/"
|
||||||
|
},
|
||||||
|
"type": "has`tick"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```adf:
|
||||||
|
{
|
||||||
|
"type": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```adf:
|
||||||
|
{
|
||||||
|
"type": " padded"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```adf:two words
|
||||||
|
{}
|
||||||
|
```
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
# 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 deep-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 deep
|
||||||
|
|
||||||
|
2026-08-24, deep 2026-10-03, the maintainer. Goal 1. Valid while a pipeline or a bot can build a
|
||||||
|
shape the editor would not.
|
||||||
|
|
||||||
|
"Equals" is deep equality over the document's JSON values: `assert.deepStrictEqual` on plain objects
|
||||||
|
as `JSON.parse` builds them, since ADF is JSON. Every key and value in `doc` counts, including two
|
||||||
|
adjacent text nodes, an empty `attrs`, `content` or `marks`, and `-0`. Neither side is normalized.
|
||||||
|
CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader builds
|
||||||
|
what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction reads and
|
||||||
|
writes editor-normal ADF — adjacent text nodes of identical marks and no attributes merged, `-0` as
|
||||||
|
`0`, and an empty `attrs`, `content` or `marks` the absent key, except the doc's `content`, which
|
||||||
|
ADF's schema requires — so two documents the editor holds equal write the same plain markdown.
|
||||||
|
|
||||||
|
## `!adf:textBreak{}` parts text CommonMark would join
|
||||||
|
|
||||||
|
2026-10-03, the maintainer. Goal 1. Valid while CommonMark reads adjacent text as one run.
|
||||||
|
|
||||||
|
Two adjacent text nodes CommonMark would read back as one are parted by the reserved inline leaf
|
||||||
|
`!adf:textBreak{}`, mirroring `!adf:listBreak`: a leaf building no node keeps both nodes and asks
|
||||||
|
nothing of the text around it. A code span holds no directive, so the spans close and reopen
|
||||||
|
around the leaf. The grammar: `spec/flavour.md` §Inline nodes, **Adjacent text nodes**.
|
||||||
|
|
||||||
|
## An empty key spells `empty`
|
||||||
|
|
||||||
|
2026-10-03, a writer panel and the maintainer. Goals 1 and 5. Valid while no attribute value
|
||||||
|
spells an empty object or array.
|
||||||
|
|
||||||
|
An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the
|
||||||
|
bare value `empty`, so a container opener and closer with nothing between them stays the node
|
||||||
|
holding no `content` key. A writer panel chose the spelling, 5 of 7. The grammar: `spec/flavour.md`
|
||||||
|
§Directives, **Attributes**, and §Marks.
|
||||||
|
|
||||||
|
## `-0` is spelled `-0`
|
||||||
|
|
||||||
|
2026-10-03, the maintainer. Goal 1. Valid while JSON's own serialization writes `-0` as `0`.
|
||||||
|
|
||||||
|
`-0` is spelled `-0` wherever the flavour writes a number or a JSON value, since JSON's grammar
|
||||||
|
reads it back as `-0`. The grammar: `spec/flavour.md` §Block nodes and §The CommonMark blocks.
|
||||||
|
|
||||||
|
## Empty markdown is a document of no blocks
|
||||||
|
|
||||||
|
2026-10-03, a writer panel and the maintainer. Goals 1 and 5. Valid while ADF's schema requires
|
||||||
|
`content` on `doc`.
|
||||||
|
|
||||||
|
Markdown holding no block reads as `{ content: [], type: 'doc', version: 1 }`, the document
|
||||||
|
`spec/adf-schema/full.json` requires. A document holding no `content` key is
|
||||||
|
`!adf:doc {content=none}` as its only block, and a named error anywhere else. The writer panel split
|
||||||
|
4 for `none` and 3 for `absent`, and the maintainer chose `none`; all seven rejected a bare
|
||||||
|
`!adf:doc`.
|
||||||
|
|
||||||
|
## Unknown nodes ride the carry
|
||||||
|
|
||||||
|
2026-08-23, extended to misplaced known nodes 2026-08-26 and to code block children 2026-10-03, 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`. A
|
||||||
|
`codeBlock` holding a child no fence holds — anything but a text node carrying no marks, `attrs` or
|
||||||
|
`content` — rides the carry whole.
|
||||||
|
|
||||||
|
## The carry fence names the node type
|
||||||
|
|
||||||
|
2026-10-03, the maintainer. Goals 1 and 5. Valid while a code fence's info string reads back
|
||||||
|
verbatim.
|
||||||
|
|
||||||
|
The block carry is a code fence whose info string `adf:<type>` names the node's type, its body the
|
||||||
|
node's JSON without `type`: ```` ```adf:blockCard ````. A type no info string carries back — by the
|
||||||
|
rule a code language follows — leaves the info string `adf:` and keeps `type` in the body. Every
|
||||||
|
info string opening `adf:` is reserved, so a `codeBlock` whose language opens so takes the
|
||||||
|
`language` attribute, and `carry` is an ordinary language. A body holding `type` under a named type,
|
||||||
|
or a fence whose info string is `adf:` alone while its body's `type` could be spelled in the info
|
||||||
|
string, is `unsupported-node-shape`. The reservation claims a fence CommonMark reads as code until
|
||||||
|
`todo.md` item 43 gives CommonMark its own reader.
|
||||||
|
|
||||||
|
## A code block is a fence per text node
|
||||||
|
|
||||||
|
2026-10-03, the maintainer. Goal 1. Valid while ADF holds a code block's text in more than one
|
||||||
|
node.
|
||||||
|
|
||||||
|
A `codeBlock` holding several text nodes is the `!adf:codeBlock` container holding one fence per
|
||||||
|
node, so each node keeps its own text. The fences carry one info string, since ADF holds one
|
||||||
|
language, and none is empty beside another, since a text node holds text. The grammar:
|
||||||
|
`spec/flavour.md` §The CommonMark blocks, the `codeBlock` bullet.
|
||||||
|
|
||||||
|
## 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, and so is a code fence whose info string opens
|
||||||
|
`adf:` (§The carry fence names the node type) — plus one image gap. `todo.md` item 43 ends the
|
||||||
|
claims, giving CommonMark its own reader, and `todo.md` item 65 ends the 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 names 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,
|
||||||
|
`!adf:textBreak{}` anything but two adjacent text nodes CommonMark would read back as one,
|
||||||
|
`!adf:doc` standing beside another block (2026-09-01, the text break and `doc` 2026-10-03).
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## The project ships under the comprehension floor until items 59, 60, 61 and 62 land
|
||||||
|
|
||||||
|
2026-10-03, the maintainer. KISS, a technical principle, and its comprehension floor of 7. Valid
|
||||||
|
while `todo.md` items 59, 60, 61 and 62 are open.
|
||||||
|
|
||||||
|
A four-seat comprehension panel scored the project under the floor of 7. Its round-5 scores
|
||||||
|
(2026-10-03) are the baseline: a later panel may not score lower on any dimension.
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Junior | 6 | 5 | 6 | 5 | 5.5 |
|
||||||
|
| Mid | 6.5 | 5 | 6 | 4.5 | 5.5 |
|
||||||
|
| Senior | 6.5 | 5.5 | 6.5 | 5.5 | 6 |
|
||||||
|
| Architect | 6.5 | 5.5 | 6 | 6 | 6 |
|
||||||
|
|
||||||
|
Three panels on near-identical code scored overall means of 5.88, 5.75 and 5.75, and a seat moves
|
||||||
|
±0.5 between runs.
|
||||||
|
|
||||||
|
## 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, `plain/` 2026-10-03, 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.
|
||||||
|
|
||||||
|
A flavour's own code, both directions included, sits in its own directory: `markdown/plain/`;
|
||||||
|
`todo.md` item 62 moves what still sits in `parse/` and `emit/`. Otherwise 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 value 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 document: AdfDocument = { content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
|
||||||
|
|
||||||
const emitted: Result<string> = adfToMarkdown(document)
|
const emitted: Result<string> = adfToMarkdown(document)
|
||||||
const parsed: Result<AdfDocument, ParseError> = markdownToAdf('x\n')
|
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 guarded: boolean = isAdfDocument(document)
|
||||||
const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code
|
const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code
|
||||||
const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line
|
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": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"size-ratchet": "oxlint --deny-warnings -c .oxlintrc.json src",
|
"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"
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ fi
|
|||||||
published=$(leg "ask npmjs for $name@$version ($node_image)" published_version "$name" "$version")
|
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")
|
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
|
if [ -z "$published" ]; then
|
||||||
: "${NPM_TOKEN:?the publish needs NPM_TOKEN}"
|
: "${NPM_TOKEN:?the publish needs NPM_TOKEN}"
|
||||||
leg "install ($node_image)" in_image "$node_image" npm ci
|
leg "install ($node_image)" in_image "$node_image" npm ci
|
||||||
|
|||||||
+116
-82
@@ -1,12 +1,14 @@
|
|||||||
# The markdown flavour
|
# The markdown flavour
|
||||||
|
|
||||||
The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain
|
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
|
CommonMark is a subset apart from raw HTML (below), with four carve-outs: literal text that matches
|
||||||
matches directive syntax below or reads as a pipe table is claimed by the flavour, and a matched
|
directive syntax below or reads as a pipe table is claimed by the flavour, a matched `~~` pair
|
||||||
`~~` pair spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal) — and one gap: a
|
spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal), and a code fence whose info
|
||||||
CommonMark image fits only as its own
|
string opens `adf:` is the opaque carry (drop the info string and wrap the fence in
|
||||||
title-less paragraph — mid-text and titled images are named errors. The emitted form is contract
|
`!adf:codeBlock {language="adf:…"}` to keep it code) — and one gap: a CommonMark image fits only as
|
||||||
(AGENTS.md §8). Per-node syntaxes build on this grammar in the sections below.
|
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
|
## Canonical form
|
||||||
|
|
||||||
@@ -44,7 +46,8 @@ normalizes to it through the round-trip.
|
|||||||
CommonMark admits no spelling — the end of a block, inside an ATX heading — or where the node
|
CommonMark admits no spelling — the end of a block, inside an ATX heading — or where the node
|
||||||
carries an attribute, it is the inline directive.
|
carries an attribute, it is the inline directive.
|
||||||
- An empty paragraph — real payloads carry them — is an `!adf:paragraph` … `!adf:/paragraph` pair
|
- An empty paragraph — real payloads carry them — is an `!adf:paragraph` … `!adf:/paragraph` pair
|
||||||
holding nothing.
|
holding nothing, and one whose `content` is an empty array the pair
|
||||||
|
`!adf:paragraph {content=empty}` … `!adf:/paragraph` (Attributes).
|
||||||
- Links `[text](url)`; `<…>` around a destination containing spaces, `<>` an empty one beside a
|
- Links `[text](url)`; `<…>` around a destination containing spaces, `<>` an empty one beside a
|
||||||
title; title in double quotes. A backslash escapes a parenthesis the destination leaves
|
title; title in double quotes. A backslash escapes a parenthesis the destination leaves
|
||||||
unbalanced, and a quote inside the title; a balanced pair stays bare. `<url>` autolink form only
|
unbalanced, and a quote inside the title; a balanced pair stays bare. `<url>` autolink form only
|
||||||
@@ -67,22 +70,26 @@ normalizes to it through the round-trip.
|
|||||||
matching below, which is what lets the emitter decide its own pairings.
|
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 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
|
blocks; inside a directive container a pair holding a directive block takes none. No trailing
|
||||||
whitespace outside a code
|
whitespace outside a code block's content, single trailing newline. A document whose `content` is
|
||||||
block's
|
an empty array is the empty string, and markdown holding no block reads back to it; a document
|
||||||
content, single trailing newline; a document with no blocks is the empty string.
|
holding no `content` key is the leaf `!adf:doc {content=none}` as its only block, which is a
|
||||||
|
named error anywhere else or spelled any other way.
|
||||||
|
|
||||||
## Directives
|
## Directives
|
||||||
|
|
||||||
One grammar for everything CommonMark lacks, namespaced: every directive opens with the literal
|
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
|
`!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
|
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
|
below parses as a directive regardless of whether the name is known, and an unknown name is an error
|
||||||
error result naming it at the opener, whatever follows it — so output an old emitter escaped stays
|
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
|
escaped, and erroring input gaining meaning later is MINOR, never a reparse (`docs/decisions.md`
|
||||||
to one position, and a name the other one spells — a mark or an inline node written as a block
|
§The formats are API). Each name belongs to one position, and a name the other one spells — a mark
|
||||||
directive, a block node written inline — is a different error, naming the spelling it takes. Two
|
or an inline node written as a block directive, a block node written inline — is a different error,
|
||||||
reserved names read back to no node: `carry` for the opaque carry, as both directive name and fence
|
naming the spelling it takes. Four reserved names read back to no node: `carry` for the inline
|
||||||
info string, and `listBreak` for the leaf that parts two adjacent lists (Canonical form).
|
opaque carry, `listBreak` for the leaf that parts two adjacent lists and `doc` for a document
|
||||||
|
holding no `content` key (Canonical form), and `textBreak` for the leaf that parts two text nodes
|
||||||
|
(Inline nodes). Every fence info string opening `adf:` is reserved for the block carry (The opaque
|
||||||
|
carry).
|
||||||
|
|
||||||
**Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name`
|
**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
|
closes a container, and a name picks by what follows it in turn — a space or the line's end a block
|
||||||
@@ -123,7 +130,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
|
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
|
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
|
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}`,
|
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
|
and one parts each attribute pair, with no padding inside the braces. Trailing whitespace on a
|
||||||
@@ -143,6 +150,13 @@ ends the name (`!adf:hardBreak{}`). Input reads that spelling alone: keys out of
|
|||||||
quoted where bare carries it, an escape longer than it need be, an empty `{attrs}` on a block line
|
quoted where bare carries it, an escape longer than it need be, an empty `{attrs}` on a block line
|
||||||
or after a `[content]`, and a number or `json` value outside its canonical JSON spelling are each a
|
or after a `[content]`, and a number or `json` value outside its canonical JSON spelling are each a
|
||||||
named error naming the spelling to write instead.
|
named error naming the spelling to write instead.
|
||||||
|
`attrs`, `content` and `marks` are reserved keys on every directive — block, inline node and mark —
|
||||||
|
whose bare value `empty` spells the node's or mark's key holding an empty object or array:
|
||||||
|
`!adf:underline[a]{attrs=empty}`, `!adf:date{content=empty}`, `!adf:hardBreak{marks=empty}`. A
|
||||||
|
container spelling `content=empty` closes with no body; `attrs=empty` stands beside no other
|
||||||
|
attribute, argument or content slot; and an inline node spelling `marks=empty` stands inside no
|
||||||
|
mark spelling. Any other value of a reserved key is a named error, except on a block's `marks`
|
||||||
|
(Block nodes).
|
||||||
|
|
||||||
**Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as
|
**Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as
|
||||||
directive syntax — every literal `!adf:`, `]` inside content, a bracket a link's destination and
|
directive syntax — every literal `!adf:`, `]` inside content, a bracket a link's destination and
|
||||||
@@ -154,51 +168,58 @@ 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
|
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,
|
`!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
|
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
|
A node no section spells where it stands (`docs/decisions.md` §Unknown nodes ride the carry) — an
|
||||||
to the other position — rides as its raw JSON and restores to a deep-equal node. A carry may hold
|
unknown type, or a known one whose spelling belongs to the other position — rides as its raw JSON
|
||||||
a node the emitter spells natively: it restores unreinterpreted, and the next emit spells it
|
and restores to a deep-equal node. A carry may hold a node the emitter spells natively: it restores
|
||||||
canonically (AGENTS.md §2). Block and inline positions canonicalize differently, each fitting
|
unreinterpreted, and the next emit spells it canonically (`docs/decisions.md` §The round-trip is the
|
||||||
where it sits:
|
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 —
|
- **Block position**: a fenced code block with info string `adf:` and the node's type, body = the
|
||||||
two-space indent, object keys sorted.
|
node's JSON without its `type` — two-space indent, object keys sorted: ```` ```adf:blockCard ````.
|
||||||
|
A type no info string carries back, by the rule a `codeBlock`'s language follows, leaves the info
|
||||||
|
string `adf:` and keeps `type` in the body. A body holding `type` under a named type, or a fence
|
||||||
|
whose info string is `adf:` alone while its body's `type` could be spelled in the info string, is
|
||||||
|
a named error.
|
||||||
- **Inline position**: `!adf:carry{json="…"}` — compact serialization (keys sorted, no whitespace),
|
- **Inline position**: `!adf:carry{json="…"}` — compact serialization (keys sorted, no whitespace),
|
||||||
JSON-string-escaped into the attribute.
|
JSON-string-escaped into the attribute.
|
||||||
|
|
||||||
The info string `carry` is reserved: a genuine `codeBlock` whose `language` is exactly `carry` takes
|
Every info string opening `adf:` is reserved: a genuine `codeBlock` whose `language` opens so takes
|
||||||
the attribute the section below keeps for a language no info string holds, so the reservation
|
the attribute the section below keeps for a language no info string holds, so the reservation
|
||||||
stays absolute.
|
stays absolute. In block-directive position `!adf:carry` is a named error — the carry's block form
|
||||||
In block-directive position `!adf:carry` is a named error — the carry's block form is the fence.
|
is the fence.
|
||||||
|
|
||||||
## Raw HTML in input
|
## Raw HTML in input
|
||||||
|
|
||||||
CommonMark input may contain raw HTML. `markdownToAdf` routes each construct through the foreign
|
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
|
HTML element mapping (`docs/decisions.md` §Foreign HTML sorts three ways; specified with the HTML
|
||||||
has no raw-HTML node, so a construct without a mapping, comments and processing instructions
|
dialect, `todo.md` item 6) — ADF has no raw-HTML node, so a construct without a mapping, comments
|
||||||
included, is an error result naming it. The flavour never emits raw HTML.
|
and processing instructions included, is an error result naming it. The flavour never emits raw
|
||||||
|
HTML.
|
||||||
|
|
||||||
## Block nodes
|
## Block nodes
|
||||||
|
|
||||||
The directive name is always the ADF node type. A container's body is the node's `content`; a
|
The directive name is always the ADF node type. A container's body is the node's `content`; a leaf
|
||||||
leaf has none. Every directive parses in any position — `markdownToAdf` builds exactly what is
|
has none. Every directive parses in any position — `markdownToAdf` builds exactly what is written;
|
||||||
written; validity against ADF's content models stays the author's business (AGENTS.md §14). It
|
validity against ADF's content models stays the author's business (`docs/decisions.md` §No schema
|
||||||
parses only in the form the emitter picks, though: a directive spelling a node the emitter would
|
validation). It parses only in the form the emitter picks, though: a directive spelling a node the
|
||||||
have written as CommonMark is a named error.
|
emitter would have written as CommonMark is a named error.
|
||||||
|
|
||||||
Each section lists attributes as `name (type)`. A parenthesized value set documents what real
|
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
|
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
|
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
|
(`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted),
|
||||||
sorted), quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty;
|
quoted, `-0` spelled `-0`. `markdownToAdf` builds an `attrs`, `content` or `marks` key only where the
|
||||||
editor-normal ADF reads an empty attrs object, marks array or content array as the absent key
|
markdown spells one, an empty one through its reserved key (Attributes), so a document reads back
|
||||||
(AGENTS.md §2) — the grammar's empty-`{attrs}` omission already collapses the two spellings.
|
deep-equal (`docs/decisions.md` §Equality is deep). A node CommonMark spells takes the directive
|
||||||
|
form to hold an empty key.
|
||||||
|
|
||||||
Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a
|
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\"}]"}`.
|
`json` value, `marks=empty` where it is empty:
|
||||||
|
`!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`.
|
||||||
A section saying its body is inline takes at most one paragraph, whose inline content becomes
|
A section saying its body is inline takes at most one paragraph, whose inline content becomes
|
||||||
the node's `content`; any other body is a named error, and an empty pair is a node holding none.
|
the node's `content`; any other body is a named error, and an empty pair is a node holding none.
|
||||||
|
|
||||||
@@ -214,20 +235,23 @@ cannot — `localId` (string) on any of them, marks, and the values below — ta
|
|||||||
form.
|
form.
|
||||||
|
|
||||||
- `blockquote`, `bulletList`, `listItem` — containers, block body. Attributes: `localId` (string).
|
- `blockquote`, `bulletList`, `listItem` — containers, block body. Attributes: `localId` (string).
|
||||||
- `codeBlock` — container, body one fenced code block whose info string is the language and whose
|
- `codeBlock` — container, body one fenced code block per text node, each fence's info string the
|
||||||
content is the node's. Attributes: `hideLineNumbers` (boolean), `language` (string), `localId`
|
language and its content the node's text; a node holding no `content` key is one empty fence.
|
||||||
(string), `uniqueId` (string), `wrap` (boolean). A language no info string carries back — empty,
|
Attributes: `hideLineNumbers` (boolean), `language` (string), `localId` (string), `uniqueId`
|
||||||
the reserved `carry`, or holding a backtick, a backslash, a control character, edge whitespace or
|
(string), `wrap` (boolean). A language no info string carries back — empty, opening the reserved
|
||||||
an entity reference — rides the `language` attribute instead and the fence carries no info
|
`adf:`, or holding a backtick, a backslash, a control character, edge whitespace or an entity
|
||||||
string; writing it in the slot that rule leaves empty, or in both, is a named error. The body is
|
reference — rides the `language` attribute instead and the fences carry no info string, and so
|
||||||
one ordinary code block, and a fence's info string decodes escapes and entity references as any
|
does a language beside `content=empty`, which has no fence; writing it in the slot that rule
|
||||||
other does.
|
leaves empty, or in both, is a named error, and so are fences whose info strings differ and an
|
||||||
|
empty fence beside another. Each fence is an ordinary code block, and its info string decodes
|
||||||
|
escapes and entity references as any other does. A node holding a child no fence holds — any but
|
||||||
|
a text node carrying no marks, `attrs` or `content` — rides the block carry.
|
||||||
- `heading` — container, inline body. Attributes: `level` (number), `localId` (string). `level` is
|
- `heading` — container, inline body. Attributes: `level` (number), `localId` (string). `level` is
|
||||||
the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
|
the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
|
||||||
CommonMark spelling.
|
CommonMark spelling.
|
||||||
- `orderedList` — container of `listItem`, block body. Attributes: `localId` (string), `order`
|
- `orderedList` — container of `listItem`, block body. Attributes: `localId` (string), `order`
|
||||||
(number). `order` is the first marker, so a list carrying none, one that is no whole number
|
(number). `order` is the first marker, so a list carrying none, one that is `-0` or no whole
|
||||||
from 0, or one whose markers would run past 999999999, has no CommonMark spelling.
|
number from 0, or one whose markers would run past 999999999, has no CommonMark spelling.
|
||||||
- `paragraph` — container, inline body. Attributes: `localId` (string).
|
- `paragraph` — container, inline body. Attributes: `localId` (string).
|
||||||
- `rule` — leaf. Attributes: `color` (string, `#rrggbb`), `localId` (string), `style` (`dashed`
|
- `rule` — leaf. Attributes: `color` (string, `#rrggbb`), `localId` (string), `style` (`dashed`
|
||||||
`dotted` `fade` `sketch` `solid`), `weight` (number, 1–3).
|
`dotted` `fade` `sketch` `solid`), `weight` (number, 1–3).
|
||||||
@@ -300,10 +324,10 @@ other text, or one carrying a title, is a named error: `mediaInline` carries a m
|
|||||||
### Tables
|
### Tables
|
||||||
|
|
||||||
One header row plus plain inline cells is a pipe table; anything richer is the directive form
|
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
|
(`docs/decisions.md` §Tables). Precisely: a table emits as a pipe table exactly when the `table`,
|
||||||
and every cell carry no attrs and no marks, the first row is all `tableHeader` and the rest all
|
every row and every cell carry no attrs and no marks, the first row is all `tableHeader` and the
|
||||||
`tableCell`, every row has the header's cell count, and every cell holds exactly one attr-less,
|
rest all `tableCell`, every row has the header's cell count, and every cell holds exactly one
|
||||||
mark-less paragraph — an empty cell holds one empty paragraph — with no `|` anywhere the
|
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
|
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.
|
takes the directive form instead. A pipe table parses back to exactly that shape.
|
||||||
|
|
||||||
@@ -423,10 +447,9 @@ Right.
|
|||||||
Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of
|
Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of
|
||||||
the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot as
|
the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot as
|
||||||
plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
|
plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
|
||||||
parsing to anything but one text node carrying neither marks, attributes nor content — adjacent text
|
parsing to anything but one text node carrying neither marks, attributes nor content is a named
|
||||||
nodes with identical marks and no attributes merged first — is a named error, and so is a `text` key
|
error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot.
|
||||||
in `{attrs}`. An enclosing mark spelling does not reach into the slot. The rest take no content,
|
The rest take no content, `!adf:text` included; content on a node that takes none is a named error.
|
||||||
`!adf:text` included; content on a node that takes none is a named error.
|
|
||||||
|
|
||||||
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
|
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
|
||||||
- `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text`
|
- `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text`
|
||||||
@@ -449,19 +472,30 @@ 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
|
**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,
|
CommonMark strips or refuses one — a block's inline content edges, either side of a line break, an
|
||||||
an em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled
|
em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled `!adf:text{text="…"}`,
|
||||||
`!adf:text{text="…"}`, the reserved key carrying the node's text, escaped by the attribute grammar
|
the reserved key carrying the node's text, escaped by the attribute grammar and never literal: pipe
|
||||||
and never literal: pipe cells trim and pad. The emitter wraps the whitespace run alone and leaves
|
cells trim and pad. The emitter wraps the whitespace run alone and leaves the rest plain text, which
|
||||||
the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks and no
|
the spelled run joins on reading. Input reads that spelling alone: the value is one run of spaces
|
||||||
attributes (AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and
|
and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries
|
||||||
tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries plainly —
|
plainly — is a named error.
|
||||||
is a named error.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
!adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines.
|
!adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Adjacent text nodes.** CommonMark reads two adjacent text nodes back as one where neither is
|
||||||
|
carried, neither holds `attrs` or an empty key, and their marks are identical, attributes included.
|
||||||
|
The reserved leaf `!adf:textBreak{}` parts such a pair, inside every mark spelling the two share; a
|
||||||
|
code span holds no directive, so it closes and reopens. It builds no node and reads only between
|
||||||
|
two such nodes: elsewhere, or with `[content]` or `{attrs}`, it is a named error (`docs/decisions.md`
|
||||||
|
§`!adf:textBreak{}` parts text CommonMark would join). A text node holding `attrs` or an empty key
|
||||||
|
rides the inline carry.
|
||||||
|
|
||||||
|
```
|
||||||
|
Hello, !adf:textBreak{}world — **Hello, !adf:textBreak{}world** — `a`!adf:textBreak{}`b`
|
||||||
|
```
|
||||||
|
|
||||||
## Marks
|
## Marks
|
||||||
|
|
||||||
An inline node's marks ride the spelling wrapped around them, never the block sections' reserved
|
An inline node's marks ride the spelling wrapped around them, never the block sections' reserved
|
||||||
@@ -488,21 +522,21 @@ 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,
|
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
|
outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the
|
||||||
reverse.
|
reverse. `adfToMarkdown` nests in the order the array holds rather than sorting it —
|
||||||
`adfToMarkdown` nests in the order the array holds rather than sorting it — §2's equality
|
`docs/decisions.md` §Equality is deep restores the array, not a set — and opens each spelling once
|
||||||
restores the array, not a set — and opens each spelling once over the longest run of adjacent
|
over the longest run of adjacent inline nodes carrying an identical mark, attributes included, at
|
||||||
inline nodes carrying an identical mark, attributes included, at that depth. A run breaks at every
|
that depth: `attrs: {}` differs from no `attrs`, and a directive spells it `{attrs=empty}`. A run
|
||||||
node the emitter carries, so no emitted carry sits inside a mark spelling.
|
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
|
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
|
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs and
|
||||||
and the mark lacks, an order putting a code span outside another mark, `code` over anything but a
|
the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside
|
||||||
text node or over text holding a newline, or a spelling CommonMark's flanking rules cannot open or
|
another mark, `code` over anything but a text node or over text holding a newline, or a spelling
|
||||||
close where the run sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the
|
CommonMark's flanking rules cannot open or close where the run sits (`un**-real**istic`), or one
|
||||||
intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the
|
CommonMark's matching pairs elsewhere — the intra-word `*` runs together with a neighbouring `**`,
|
||||||
merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a
|
and the multiple-of-3 rule can leave the merged run's pairing to another delimiter — rides the
|
||||||
mark spelling is a named error in input: the carry restores its node exactly, marks included
|
inline carry whole. An opaque carry inside a mark spelling is a named error in input: the carry
|
||||||
(AGENTS.md §3).
|
restores its node exactly, marks included (`docs/decisions.md` §Unknown nodes ride the carry).
|
||||||
|
|
||||||
```
|
```
|
||||||
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
|
!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'
|
import type { AttributeVocabulary } from './attribute-vocabulary.ts'
|
||||||
|
|
||||||
export type BlockDirective = {
|
export type BlockNodeModel = {
|
||||||
attributes: AttributeVocabulary
|
attributes: AttributeVocabulary
|
||||||
contentModel: 'block' | 'code' | 'inline' | 'none'
|
contentModel: 'block' | 'code' | 'inline' | 'none'
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ const mediaAttributes: AttributeVocabulary = {
|
|||||||
|
|
||||||
const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' }
|
const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' }
|
||||||
|
|
||||||
export const blockDirectives = {
|
export const blockNodes = {
|
||||||
blockTaskItem: { attributes: localIdAttributes, contentModel: 'block' },
|
blockTaskItem: { attributes: localIdAttributes, contentModel: 'block' },
|
||||||
blockquote: { attributes: localIdAttributes, contentModel: 'block' },
|
blockquote: { attributes: localIdAttributes, contentModel: 'block' },
|
||||||
bodiedExtension: { attributes: extensionAttributes, contentModel: 'block' },
|
bodiedExtension: { attributes: extensionAttributes, contentModel: 'block' },
|
||||||
@@ -80,14 +80,14 @@ export const blockDirectives = {
|
|||||||
tableRow: { attributes: localIdAttributes, contentModel: 'block' },
|
tableRow: { attributes: localIdAttributes, contentModel: 'block' },
|
||||||
taskItem: { attributes: localIdAttributes, contentModel: 'inline' },
|
taskItem: { attributes: localIdAttributes, contentModel: 'inline' },
|
||||||
taskList: { attributes: localIdAttributes, contentModel: 'block' },
|
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 {
|
export function blockNodeModel(type: string): BlockNodeModel | undefined {
|
||||||
return isBlockType(type) ? blockDirectives[type] : undefined
|
return isBlockType(type) ? blockNodes[type] : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBlockType(type: string): type is BlockType {
|
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', () => {
|
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 =>
|
const deeper = (key: string, type: string): string => `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
|
||||||
`the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
|
|
||||||
assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted')
|
assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted')
|
||||||
assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph'))
|
assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph'))
|
||||||
assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth')
|
assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth')
|
||||||
assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), true)
|
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 })
|
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)), 'accepted')
|
||||||
assert.equal(fault(marked(largestNesting - 2)), deeper('a', 'link', largestNesting - 3))
|
assert.equal(fault(marked(largestNesting + 1)), deeper('a', 'link'))
|
||||||
assert.equal(isAdfDocument(marked(largestNesting - 2)), true)
|
assert.equal(isAdfDocument(marked(largestNesting + 1)), true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('accepts the JSON values an attribute may hold', () => {
|
test('accepts the JSON values an attribute may hold', () => {
|
||||||
|
|||||||
+55
-10
@@ -1,6 +1,7 @@
|
|||||||
import type { ConvertFault } from '../result.ts'
|
import type { ConvertFault } from '../result.ts'
|
||||||
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
|
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
|
||||||
import { largestNesting } from '../nesting.ts'
|
import { largestNesting } from '../nesting.ts'
|
||||||
|
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||||
|
|
||||||
export type AdfAttributes = { [key: string]: JsonValue }
|
export type AdfAttributes = { [key: string]: JsonValue }
|
||||||
|
|
||||||
@@ -17,15 +18,14 @@ export type AdfNode = {
|
|||||||
type: string
|
type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EmptyKey = 'attrs' | 'content' | 'marks'
|
||||||
|
|
||||||
export type AdfDocument = {
|
export type AdfDocument = {
|
||||||
content?: AdfNode[]
|
content?: AdfNode[]
|
||||||
type: 'doc'
|
type: 'doc'
|
||||||
version: number
|
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 documentKeys = ['content', 'type', 'version']
|
||||||
const markKeys = ['attrs', 'type']
|
const markKeys = ['attrs', 'type']
|
||||||
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
|
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
|
||||||
@@ -47,15 +47,53 @@ export function adfDocumentFault(value: unknown): ConvertFault | undefined {
|
|||||||
return nestingFault(content)
|
return nestingFault(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attributeNestingMessage(key: string, type: string, levels: number = largestNesting): string {
|
export function attributeNestingMessage(key: string, type: string): string {
|
||||||
return `the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
|
return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
|
export function holdsOnlyAttributes(node: AdfNode, attributes: readonly string[]): boolean {
|
||||||
if (nodeMarks(node).length > 0 || node.text !== undefined) return false
|
if (nodeMarks(node).length > 0 || node.text !== undefined || emptyKeys(node).length > 0) return false
|
||||||
return holdsOnly(nodeAttrs(node), attributes)
|
return holdsOnly(nodeAttrs(node), attributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function emptyKeys(held: { attrs?: AdfAttributes; content?: AdfNode[]; marks?: AdfMark[] }): EmptyKey[] {
|
||||||
|
const keys: EmptyKey[] = []
|
||||||
|
if (held.attrs !== undefined && Object.keys(held.attrs).length === 0) keys.push('attrs')
|
||||||
|
if (held.content?.length === 0) keys.push('content')
|
||||||
|
if (held.marks?.length === 0) keys.push('marks')
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// A format spells this node as text; anything more rides a carry, save content, which is refused.
|
||||||
|
export function isBareText(node: AdfNode): boolean {
|
||||||
|
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUnmarkedBareText(node: AdfNode): boolean {
|
||||||
|
return isBareText(node) && node.marks === undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identicalMark(left: AdfMark, right: AdfMark): boolean {
|
||||||
|
return marksKey([left]) === marksKey([right])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identicalMarks(left: readonly AdfMark[], right: readonly AdfMark[]): boolean {
|
||||||
|
return marksKey(left) === marksKey(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] {
|
||||||
|
const merged: AdfNode[] = []
|
||||||
|
for (const node of nodes) {
|
||||||
|
const previous = merged[merged.length - 1]
|
||||||
|
if (previous !== undefined && joins(previous, node)) {
|
||||||
|
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged.push(node)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
|
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
|
||||||
export function isAdfDocument(value: unknown): value is AdfDocument {
|
export function isAdfDocument(value: unknown): value is AdfDocument {
|
||||||
const fault = adfDocumentFault(value)
|
const fault = adfDocumentFault(value)
|
||||||
@@ -102,6 +140,13 @@ function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function marksKey(marks: readonly AdfMark[]): string {
|
||||||
|
return serializeCanonicalJson(
|
||||||
|
marks.map((mark) => (mark.attrs === undefined ? [mark.type] : [mark.type, mark.attrs])),
|
||||||
|
'compact',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
|
function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
|
||||||
const pending: AdfNode[] = [...nodes]
|
const pending: AdfNode[] = [...nodes]
|
||||||
while (pending.length > 0) {
|
while (pending.length > 0) {
|
||||||
@@ -116,15 +161,15 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
|
|||||||
|
|
||||||
function marksFault(marks: readonly AdfMark[]): ConvertFault | undefined {
|
function marksFault(marks: readonly AdfMark[]): ConvertFault | undefined {
|
||||||
for (const mark of marks) {
|
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
|
if (fault !== undefined) return fault
|
||||||
}
|
}
|
||||||
return undefined
|
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)) {
|
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
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { AttributeVocabulary } from './attribute-vocabulary.ts'
|
import type { AttributeVocabulary } from './attribute-vocabulary.ts'
|
||||||
|
|
||||||
export type InlineDirective = {
|
export type InlineNodeModel = {
|
||||||
attributes: AttributeVocabulary
|
attributes: AttributeVocabulary
|
||||||
textAttribute?: string
|
textAttribute?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
|
export const inlineNodes: Readonly<Record<string, InlineNodeModel>> = {
|
||||||
date: { attributes: { localId: 'string', timestamp: 'string' } },
|
date: { attributes: { localId: 'string', timestamp: 'string' } },
|
||||||
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, textAttribute: 'text' },
|
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, textAttribute: 'text' },
|
||||||
hardBreak: { attributes: { localId: 'string', text: 'string' } },
|
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' },
|
status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, textAttribute: 'text' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inlineDirective(type: string): InlineDirective | undefined {
|
export function inlineNodeModel(type: string): InlineNodeModel | undefined {
|
||||||
return Object.hasOwn(inlineDirectives, type) ? inlineDirectives[type] : undefined
|
return Object.hasOwn(inlineNodes, type) ? inlineNodes[type] : undefined
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling)
|
|||||||
const { depth, value: held } = next
|
const { depth, value: held } = next
|
||||||
if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth)
|
if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth)
|
||||||
else if (held !== null && typeof held === 'object') schedule(pending, '{', objectMembers(held, indent), '}', indent, depth)
|
else if (held !== null && typeof held === 'object') schedule(pending, '{', objectMembers(held, indent), '}', indent, depth)
|
||||||
else text.push(JSON.stringify(held))
|
else text.push(Object.is(held, -0) ? '-0' : JSON.stringify(held))
|
||||||
}
|
}
|
||||||
return text.join('')
|
return text.join('')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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/plain/adf-to-plain-markdown.ts'
|
||||||
|
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
||||||
|
import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||||
|
import { toEditorNormal } from '../markdown/plain/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(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),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a generated document writes the plain markdown its editor-normal form writes', { timeout: propertyTimeout }, () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(adfDocument, (document) => {
|
||||||
|
assert.deepEqual(adfToPlainMarkdown(document), adfToPlainMarkdown(toEditorNormal(document)))
|
||||||
|
}),
|
||||||
|
propertyRuns(gateRuns),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -5,11 +5,11 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts'
|
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||||
import { blockArgument } from './markdown/block-directive-arguments.ts'
|
import { blockArgument } from '../markdown/block-directive.ts'
|
||||||
import { blockDirectives } from './adf/block-directives.ts'
|
import { blockNodes } from '../adf/block-nodes.ts'
|
||||||
import { inlineDirectives } from './adf/inline-directives.ts'
|
import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||||
import { markAttributes } from './adf/mark-attributes.ts'
|
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||||
|
|
||||||
type Held = Map<string, Set<AttributeKind>>
|
type Held = Map<string, Set<AttributeKind>>
|
||||||
type Properties = Map<string, SchemaObject[]>
|
type Properties = Map<string, SchemaObject[]>
|
||||||
@@ -21,7 +21,7 @@ const definitionReference = '#/definitions/'
|
|||||||
const gaps: string[] = []
|
const gaps: string[] = []
|
||||||
const grammarOwn = ['doc', 'text']
|
const grammarOwn = ['doc', 'text']
|
||||||
const readKeywords = ['$ref', 'additionalProperties', 'allOf', 'anyOf', 'enum', 'items', 'maxItems', 'maximum', 'minItems', 'minLength', 'minimum', 'pattern', 'properties', 'required', 'type']
|
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']
|
const schemaFiles = ['full.json', 'stage-0.json']
|
||||||
|
|
||||||
test('the ADF JSON Schemas are @atlaskit/adf-schema 57.4.9, vendored byte-exact', () => {
|
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[] {
|
function spelled(): Spelled[] {
|
||||||
return [
|
return [
|
||||||
...Object.entries(blockDirectives).map(([type, entry]) => spelledType(type, entry.attributes, blockArgument(type))),
|
...Object.entries(blockNodes).map(([type, model]) => spelledType(type, model.attributes, blockArgument(type))),
|
||||||
...Object.entries(inlineDirectives).map(([type, entry]) => spelledType(type, entry.attributes)),
|
...Object.entries(inlineNodes).map(([type, model]) => spelledType(type, model.attributes)),
|
||||||
...Object.entries(markAttributes).map(([type, attributes]) => spelledType(type, 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 { readFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import type { AdfDocument, AdfNode } from './adf/document.ts'
|
import type { AdfDocument, AdfNode } from '../adf/document.ts'
|
||||||
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||||
import { markdownToAdf } from './markdown/parse/markdown-to-adf.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
|
const checks = ['count', 'fixpoint', 'text'] as const
|
||||||
|
|
||||||
type Check = (typeof checks)[number]
|
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`)
|
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 countKeys = ['a', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'li', 'ol', 'pre', 'strong', 'ul']
|
||||||
const nodeElement: Record<string, string> = {
|
const nodeElement: Record<string, string> = {
|
||||||
blockquote: 'blockquote',
|
blockquote: 'blockquote',
|
||||||
@@ -4,14 +4,13 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import { readFileSync, readdirSync } from 'node:fs'
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||||
import { isAdfDocument } from './adf/document.ts'
|
import { isAdfDocument } from '../adf/document.ts'
|
||||||
import { isJsonValue } from './json-value.ts'
|
import { isJsonValue } from '../json-value.ts'
|
||||||
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||||
import { serializeCanonicalJson } from './canonical-json.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 errorsRoot = join(corpusRoot, 'errors')
|
||||||
const normalizationRoot = join(corpusRoot, 'normalization')
|
const normalizationRoot = join(corpusRoot, 'normalization')
|
||||||
const realPayloadsRoot = join(corpusRoot, 'real-payloads')
|
const realPayloadsRoot = join(corpusRoot, 'real-payloads')
|
||||||
@@ -97,7 +96,7 @@ for (const directory of roundTripDirectories) {
|
|||||||
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
||||||
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
|
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
|
||||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
||||||
assert.deepEqual(toEditorNormal(result.value), expected)
|
assert.deepEqual(result.value, expected)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,12 +124,12 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
|
|||||||
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
||||||
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
|
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
|
||||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
||||||
assert.deepEqual(toEditorNormal(result.value), expected)
|
assert.deepEqual(result.value, expected)
|
||||||
const emitted = adfToMarkdown(result.value)
|
const emitted = adfToMarkdown(result.value)
|
||||||
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
||||||
const again = markdownToAdf(emitted.value)
|
const again = markdownToAdf(emitted.value)
|
||||||
assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`)
|
assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`)
|
||||||
assert.deepEqual(toEditorNormal(again.value), expected)
|
assert.deepEqual(again.value, expected)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +145,7 @@ for (const name of names(realPayloadsRoot, '.json')) {
|
|||||||
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
||||||
const parsed = markdownToAdf(emitted.value)
|
const parsed = markdownToAdf(emitted.value)
|
||||||
assert.ok(parsed.ok, parsed.ok ? '' : `${parsed.error.code}: ${parsed.error.message}`)
|
assert.ok(parsed.ok, parsed.ok ? '' : `${parsed.error.code}: ${parsed.error.message}`)
|
||||||
assert.deepEqual(toEditorNormal(parsed.value), payload)
|
assert.deepEqual(parsed.value, payload)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4,15 +4,15 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts'
|
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||||
import { blockDirectives } from './adf/block-directives.ts'
|
import { blockNodes } from '../adf/block-nodes.ts'
|
||||||
import { inlineDirectives } from './adf/inline-directives.ts'
|
import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||||
import { markAttributes } from './adf/mark-attributes.ts'
|
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||||
import { textDirectiveName } from './markdown/text-directive.ts'
|
import { textDirectiveName } from '../markdown/text-directive.ts'
|
||||||
|
|
||||||
type Declared = { attributes: AttributeVocabulary }
|
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 introducer = 'Attributes: '
|
||||||
const codeFence = /^`{3,}/
|
const codeFence = /^`{3,}/
|
||||||
const directiveName = /`([a-z][A-Za-z0-9]*)`/g
|
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', () => {
|
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', () => {
|
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.
|
// 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', () => {
|
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)
|
assert.equal(new Set(names).size, names.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2,16 +2,16 @@ import assert from 'node:assert/strict'
|
|||||||
import fc from 'fast-check'
|
import fc from 'fast-check'
|
||||||
import test from 'node:test'
|
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 { Arbitrary, DepthIdentifier } from 'fast-check'
|
||||||
import type { AttributeVocabulary } from './adf/attribute-vocabulary.ts'
|
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||||
import type { JsonValue } from './json-value.ts'
|
import type { JsonValue } from '../json-value.ts'
|
||||||
import type { Result } from './result.ts'
|
import type { Result } from '../result.ts'
|
||||||
import { adfDocument, attributes, jsonKey, jsonValue, markdownPieces, propertyRuns, propertyTimeout, textOf } from './property-harness.ts'
|
import { adfDocument, attributes, jsonKey, jsonValue, markdownPieces, propertyRuns, propertyTimeout, textOf } from './property-harness.ts'
|
||||||
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||||
import { blockArgument } from './markdown/block-directive-arguments.ts'
|
import { blockArgument, listBreakName, marksAttribute } from '../markdown/block-directive.ts'
|
||||||
import { blockDirectives } from './adf/block-directives.ts'
|
import { blockNodes } from '../adf/block-nodes.ts'
|
||||||
import { carryName } from './markdown/opaque-carry.ts'
|
import { carryName } from '../markdown/opaque-carry.ts'
|
||||||
import {
|
import {
|
||||||
directivePrefix,
|
directivePrefix,
|
||||||
spellAttributes,
|
spellAttributes,
|
||||||
@@ -22,19 +22,16 @@ import {
|
|||||||
spellJsonAttribute,
|
spellJsonAttribute,
|
||||||
spellStringAttribute,
|
spellStringAttribute,
|
||||||
spellVocabulary,
|
spellVocabulary,
|
||||||
} from './markdown/directive-syntax.ts'
|
} from '../markdown/directive-syntax.ts'
|
||||||
import { fencedCodeBlock } from './markdown/commonmark/backtick-runs.ts'
|
import { fencedCodeBlock } from '../markdown/commonmark/backtick-runs.ts'
|
||||||
import { inlineDirectives } from './adf/inline-directives.ts'
|
import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||||
import { listBreakName } from './markdown/list-break.ts'
|
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||||
import { markAttributes } from './adf/mark-attributes.ts'
|
import { markSpelling } from '../markdown/mark-spellings.ts'
|
||||||
import { markSpelling } from './markdown/mark-spellings.ts'
|
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||||
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
import { nodeContent, nodeMarks } from '../adf/document.ts'
|
||||||
import { marksAttribute } from './markdown/block-directive-marks.ts'
|
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||||
import { nodeContent, nodeMarks } from './adf/document.ts'
|
import { textDirectiveName } from '../markdown/text-directive.ts'
|
||||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
import { vocabularyPairs } from '../adf/attribute-vocabulary.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 }
|
type Choice = { arbitrary: Arbitrary<string>; hostile?: true; weight: number }
|
||||||
|
|
||||||
@@ -50,11 +47,11 @@ const fixpointFloor = 600
|
|||||||
const gateRuns = 1000
|
const gateRuns = 1000
|
||||||
const markdownMarkTypes = new Set(Object.keys(markAttributes).filter((type) => markSpelling(type)?.kind !== 'directive'))
|
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 = [
|
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.
|
// 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> {
|
function choose(hostile: boolean, choices: readonly Choice[], depth?: { depthIdentifier: DepthIdentifier; maxDepth: number }): Arbitrary<string> {
|
||||||
@@ -233,9 +230,9 @@ function inlineMarkdown(hostile: boolean): InlineMarkdown {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
arbitrary: fc.oneof(
|
arbitrary: fc.oneof(
|
||||||
...Object.entries(inlineDirectives).map(([name, directive]) =>
|
...Object.entries(inlineNodes).map(([name, model]) =>
|
||||||
fc
|
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}`)),
|
.map(([slot, attrs]) => (slot === null ? spellInlineLeafDirective(name, attrs) : `${spellInlineDirectiveOpener(name)}${slot}]${attrs}`)),
|
||||||
),
|
),
|
||||||
...Object.entries(markAttributes)
|
...Object.entries(markAttributes)
|
||||||
@@ -328,15 +325,15 @@ function blockMarkdown(hostile: boolean, { inlines, oneLine }: InlineMarkdown, {
|
|||||||
const blockDepth = fc.createDepthIdentifier()
|
const blockDepth = fc.createDepthIdentifier()
|
||||||
const { blocks } = fc.letrec<{ block: string; blocks: string }>((tie) => {
|
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 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 =
|
const argument =
|
||||||
blockArgument(name) === undefined
|
blockArgument(name) === undefined
|
||||||
? fc.constant(undefined)
|
? fc.constant(undefined)
|
||||||
: fc.oneof({ arbitrary: fc.constantFrom('DONE', 'TODO', 'custom', 'info', 'warning'), weight: 3 }, { arbitrary: bareToken, weight: 1 })
|
: 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)
|
const attrs = hostile ? fc.oneof({ arbitrary: tableAttributes(model.attributes), weight: 4 }, { arbitrary: hostileAttributes, weight: 1 }) : tableAttributes(model.attributes)
|
||||||
if (directive.contentModel === 'none') return fc.tuple(argument, attrs).map(([held, spelled]) => spellDirectiveOpener(name, held, spelled))
|
if (model.contentModel === 'none') return fc.tuple(argument, attrs).map(([held, spelled]) => spellDirectiveOpener(name, held, spelled))
|
||||||
return fc
|
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)))
|
.map(([held, spelled, body, closer]) => container(spellDirectiveOpener(name, held, spelled), body, closer ?? spellDirectiveCloser(name)))
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
@@ -435,7 +432,7 @@ test('generated markdown refuses, or what it parses to refuses to emit, or its s
|
|||||||
if (holdsDirectiveShape(parsed.value)) directiveShaped += 1
|
if (holdsDirectiveShape(parsed.value)) directiveShaped += 1
|
||||||
const read = markdownToAdf(emitted.value)
|
const read = markdownToAdf(emitted.value)
|
||||||
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
|
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
|
||||||
assert.deepEqual(toEditorNormal(read.value), toEditorNormal(parsed.value), `reading ${JSON.stringify(emitted.value)}`)
|
assert.deepEqual(read.value, parsed.value, `reading ${JSON.stringify(emitted.value)}`)
|
||||||
const respelled = adfToMarkdown(read.value)
|
const respelled = adfToMarkdown(read.value)
|
||||||
assert.ok(respelled.ok, respelled.ok ? '' : `${respelled.error.code}: ${respelled.error.message} — spelling ${JSON.stringify(emitted.value)} again`)
|
assert.ok(respelled.ok, respelled.ok ? '' : `${respelled.error.code}: ${respelled.error.message} — spelling ${JSON.stringify(emitted.value)} again`)
|
||||||
assert.equal(respelled.value, emitted.value)
|
assert.equal(respelled.value, emitted.value)
|
||||||
@@ -2,16 +2,17 @@ import assert from 'node:assert/strict'
|
|||||||
import { env } from 'node:process'
|
import { env } from 'node:process'
|
||||||
import fc from 'fast-check'
|
import fc from 'fast-check'
|
||||||
|
|
||||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
|
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode, EmptyKey } from '../adf/document.ts'
|
||||||
import type { Arbitrary } from 'fast-check'
|
import type { Arbitrary } from 'fast-check'
|
||||||
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts'
|
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||||
import type { JsonValue } from './json-value.ts'
|
import type { JsonValue } from '../json-value.ts'
|
||||||
import { blockArgument } from './markdown/block-directive-arguments.ts'
|
import { blockArgument } from '../markdown/block-directive.ts'
|
||||||
import { blockDirectives } from './adf/block-directives.ts'
|
import { blockNodes } from '../adf/block-nodes.ts'
|
||||||
import { directivePrefix } from './markdown/directive-syntax.ts'
|
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
||||||
import { inlineDirectives } from './adf/inline-directives.ts'
|
import { emptyKeys, mergeAdjacentText } from '../adf/document.ts'
|
||||||
import { markAttributes } from './adf/mark-attributes.ts'
|
import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||||
import { toEditorNormal } from './adf/editor-normal.ts'
|
import { joinsWhenRead } from '../markdown/adjacent-text.ts'
|
||||||
|
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||||
|
|
||||||
type Positions = { block: AdfNode; inline: AdfNode }
|
type Positions = { block: AdfNode; inline: AdfNode }
|
||||||
|
|
||||||
@@ -23,9 +24,9 @@ export const propertyTimeout = 600000
|
|||||||
const depthIdentifier = fc.createDepthIdentifier()
|
const depthIdentifier = fc.createDepthIdentifier()
|
||||||
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
|
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
|
||||||
const flatCommonMarkShapeWeight = 4
|
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 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> {
|
export function textOf(minLength: number): Arbitrary<string> {
|
||||||
return fc.oneof(
|
return fc.oneof(
|
||||||
@@ -36,7 +37,11 @@ export function textOf(minLength: number): Arbitrary<string> {
|
|||||||
|
|
||||||
const text = textOf(1)
|
const text = textOf(1)
|
||||||
const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type))
|
const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type))
|
||||||
const numberValue = fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 })
|
const numberValue = fc.oneof(
|
||||||
|
{ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 6 },
|
||||||
|
{ arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 2 },
|
||||||
|
{ arbitrary: fc.constant(-0), weight: 1 },
|
||||||
|
)
|
||||||
|
|
||||||
// V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash (https://issues.chromium.org/issues/521080746); Bun is unaffected.
|
// V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash (https://issues.chromium.org/issues/521080746); Bun is unaffected.
|
||||||
const keyPiece = fc
|
const keyPiece = fc
|
||||||
@@ -73,18 +78,35 @@ function heldAttributes(held: Readonly<Record<string, JsonValue | undefined>>):
|
|||||||
return attrs
|
return attrs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An empty attrs, content or marks key, and adjacent text CommonMark would read back as one, stay occasional: each takes a spelling outside CommonMark.
|
||||||
|
// The copy gives fast-check's null-prototype records the prototype a parsed node has.
|
||||||
|
function occasionallyEmpty<T extends AdfMark | AdfNode>(arbitrary: Arbitrary<T>): Arbitrary<T> {
|
||||||
|
return fc.tuple(arbitrary, fc.nat({ max: 9 })).map(([held, roll]) => (roll === 0 ? { ...held } : withoutEmptyKeys(held)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutEmptyKeys<T extends AdfMark | AdfNode>(held: T): T {
|
||||||
|
const kept: Partial<Record<EmptyKey, unknown>> & T = { ...held }
|
||||||
|
for (const key of emptyKeys(held)) delete kept[key]
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
function occasionallyApart(arbitrary: Arbitrary<AdfNode[]>): Arbitrary<AdfNode[]> {
|
||||||
|
return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes, joinsWhenRead)))
|
||||||
|
}
|
||||||
|
|
||||||
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
|
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
|
||||||
const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))]
|
const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))]
|
||||||
return { content: rows.map((content): AdfNode => ({ content, type: 'tableRow' })), type: 'table' }
|
return { content: rows.map((content): AdfNode => ({ content, type: 'tableRow' })), type: 'table' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const mark: Arbitrary<AdfMark> = fc.oneof(
|
const mark: Arbitrary<AdfMark> = occasionallyEmpty(fc.oneof(
|
||||||
{ arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 },
|
{ 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 },
|
{ 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 })
|
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
|
||||||
|
|
||||||
const textNode = fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' }))
|
const textNode = occasionallyEmpty(fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' })))
|
||||||
|
|
||||||
const backtickRunNode = fc
|
const backtickRunNode = fc
|
||||||
.record({ marks: fc.oneof(fc.constant<AdfMark[]>([]), fc.constant<AdfMark[]>([{ type: 'code' }]), marks), text: fc.string({ maxLength: 6, minLength: 1, unit: fc.constantFrom('`', '``', ' ', 'a') }) })
|
.record({ marks: fc.oneof(fc.constant<AdfMark[]>([]), fc.constant<AdfMark[]>([{ type: 'code' }]), marks), text: fc.string({ maxLength: 6, minLength: 1, unit: fc.constantFrom('`', '``', ' ', 'a') }) })
|
||||||
@@ -94,8 +116,8 @@ const autolinkTextNode = fc
|
|||||||
.record({ href: fc.tuple(fc.constantFrom('ab:', 'http://'), textOf(0)).map(([scheme, rest]) => `${scheme}${rest}`), marks })
|
.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' }))
|
.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]) =>
|
const inlineArbitraries = Object.entries(inlineNodes).map(([type, model]) =>
|
||||||
fc.record({ attrs: attributes(directive.attributes), marks }).map((held): AdfNode => ({ ...held, type })),
|
occasionallyEmpty(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 }[] {
|
function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): { arbitrary: Arbitrary<AdfNode>; weight: number }[] {
|
||||||
@@ -104,42 +126,51 @@ function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): {
|
|||||||
|
|
||||||
const positions = fc.letrec<Positions>((tie) => {
|
const positions = fc.letrec<Positions>((tie) => {
|
||||||
const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 })
|
const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 })
|
||||||
const inlineContent = fc.array(tie('inline'), { depthIdentifier, maxLength: 4 })
|
const inlineContent = occasionallyApart(fc.array(tie('inline'), { depthIdentifier, maxLength: 4 }))
|
||||||
const contentByModel = {
|
const contentByModel = {
|
||||||
block: blockContent,
|
block: blockContent,
|
||||||
code: fc.array(text.map((held): AdfNode => ({ text: held, type: 'text' })), { maxLength: 2 }),
|
code: occasionallyApart(fc.array(text.map((held): AdfNode => ({ text: held, type: 'text' })), { maxLength: 2 })),
|
||||||
inline: inlineContent,
|
inline: inlineContent,
|
||||||
none: fc.constant<AdfNode[]>([]),
|
none: fc.constant<AdfNode[]>([]),
|
||||||
}
|
}
|
||||||
const blockMarks = fc.oneof({ arbitrary: fc.constant<AdfMark[]>([]), weight: 4 }, { arbitrary: marks, weight: 1 })
|
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 argument = blockArgument(type)
|
||||||
const vocabulary: AttributeVocabulary = argument === undefined ? directive.attributes : { ...directive.attributes, [argument]: 'string' }
|
const vocabulary: AttributeVocabulary = argument === undefined ? model.attributes : { ...model.attributes, [argument]: 'string' }
|
||||||
const node = fc.record({ attrs: attributes(vocabulary), content: contentByModel[directive.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type }))
|
const node = occasionallyEmpty(fc.record({ attrs: attributes(vocabulary), content: contentByModel[model.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type })))
|
||||||
return { leaf: directive.contentModel === 'code' || directive.contentModel === 'none', node }
|
return { leaf: model.contentModel === 'code' || model.contentModel === 'none', node }
|
||||||
})
|
})
|
||||||
const unknownNode = fc
|
const unknownNode = occasionallyEmpty(
|
||||||
.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType })
|
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 leafBlocks = blockArbitraries.filter((entry) => entry.leaf).map((entry) => entry.node)
|
||||||
const containerBlocks = blockNodes.filter((entry) => !entry.leaf).map((entry) => entry.node)
|
const containerBlocks = blockArbitraries.filter((entry) => !entry.leaf).map((entry) => entry.node)
|
||||||
const misplacedWeight = 7
|
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 paragraph = occasionallyEmpty(
|
||||||
|
fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: occasionallyApart(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 }))
|
const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type }))
|
||||||
const listItems = fc.array(
|
const listItems = fc.array(
|
||||||
blockContent.map((content): AdfNode => ({ content, type: 'listItem' })),
|
occasionallyEmpty(blockContent.map((content): AdfNode => ({ content, type: 'listItem' }))),
|
||||||
{ depthIdentifier, maxLength: 3, minLength: 1 },
|
{ depthIdentifier, maxLength: 3, minLength: 1 },
|
||||||
)
|
)
|
||||||
const flatCommonMarkShapes = [
|
const flatCommonMarkShapes = [
|
||||||
fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })),
|
occasionallyEmpty(fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' }))),
|
||||||
paragraph,
|
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),
|
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[]>) =>
|
||||||
|
occasionallyEmpty(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 = [
|
const nestingCommonMarkShapes = [
|
||||||
blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })),
|
occasionallyEmpty(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' })),
|
listItems.map((content): AdfNode => ({ content, type: 'bulletList' })),
|
||||||
fc
|
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 }) })
|
.record({
|
||||||
|
content: listItems,
|
||||||
|
order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 8 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 2 }, { arbitrary: fc.constant(-0), weight: 1 }),
|
||||||
|
})
|
||||||
.map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })),
|
.map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })),
|
||||||
]
|
]
|
||||||
const flatBlocks = [...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)]
|
const flatBlocks = [...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)]
|
||||||
@@ -148,7 +179,7 @@ const positions = fc.letrec<Positions>((tie) => {
|
|||||||
{ depthIdentifier, depthSize: 'small', maxDepth: 4 },
|
{ depthIdentifier, depthSize: 'small', maxDepth: 4 },
|
||||||
{ arbitrary: fc.oneof(...flatBlocks), weight: flatBlocks.reduce((sum, entry) => sum + entry.weight, 0) },
|
{ arbitrary: fc.oneof(...flatBlocks), weight: flatBlocks.reduce((sum, entry) => sum + entry.weight, 0) },
|
||||||
{ arbitrary: fc.oneof(...containerBlocks), weight: containerBlocks.length * 2 },
|
{ 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 },
|
{ arbitrary: fc.oneof(...nestingCommonMarkShapes), weight: nestingCommonMarkShapes.length * nestingCommonMarkShapeWeight },
|
||||||
),
|
),
|
||||||
inline: fc.oneof(
|
inline: fc.oneof(
|
||||||
@@ -156,13 +187,17 @@ const positions = fc.letrec<Positions>((tie) => {
|
|||||||
{ arbitrary: textNode, weight: 12 },
|
{ arbitrary: textNode, weight: 12 },
|
||||||
{ arbitrary: autolinkTextNode, weight: 2 },
|
{ arbitrary: autolinkTextNode, weight: 2 },
|
||||||
{ arbitrary: backtickRunNode, weight: 3 },
|
{ arbitrary: backtickRunNode, weight: 3 },
|
||||||
{ arbitrary: fc.oneof(...inlineNodes), weight: 7 },
|
{ arbitrary: fc.oneof(...inlineArbitraries), weight: 7 },
|
||||||
{ arbitrary: fc.oneof(...blockNodes.map((entry) => entry.node), unknownNode), weight: 2 },
|
{ arbitrary: fc.oneof(...blockArbitraries.map((entry) => entry.node), unknownNode), weight: 2 },
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const adfDocument = fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => toEditorNormal({ content, type: 'doc', version: 1 }))
|
export const adfDocument = fc.oneof(
|
||||||
|
{ arbitrary: fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => ({ content, type: 'doc', version: 1 })), weight: 30 },
|
||||||
|
{ arbitrary: fc.constant<AdfDocument>({ content: [], type: 'doc', version: 1 }), weight: 1 },
|
||||||
|
{ arbitrary: fc.constant<AdfDocument>({ type: 'doc', version: 1 }), weight: 1 },
|
||||||
|
)
|
||||||
|
|
||||||
export function propertyRuns(gateRuns: number): { gate: boolean; numRuns: number; seed?: number } {
|
export function propertyRuns(gateRuns: number): { gate: boolean; numRuns: number; seed?: number } {
|
||||||
const deepRuns = env[deepRunsVariable]
|
const deepRuns = env[deepRunsVariable]
|
||||||
+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 { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
|
||||||
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
|
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
|
||||||
export type { JsonValue } from './json-value.ts'
|
export type { JsonValue } from './json-value.ts'
|
||||||
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||||
|
export { adfToPlainMarkdown } from './markdown/plain/adf-to-plain-markdown.ts'
|
||||||
export { isAdfDocument } from './adf/document.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'
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { AdfNode } from '../adf/document.ts'
|
||||||
|
import { identicalMarks, isBareText, nodeMarks } from '../adf/document.ts'
|
||||||
|
import { spellInlineLeafDirective } from './directive-syntax.ts'
|
||||||
|
|
||||||
|
export const textBreakName = 'textBreak'
|
||||||
|
|
||||||
|
export const textBreakSpelling = spellInlineLeafDirective(textBreakName, '')
|
||||||
|
|
||||||
|
// The lossless reader's rule: CommonMark reads the pair back as one text run (spec/flavour.md, Inline nodes).
|
||||||
|
export function joinsWhenRead(previous: AdfNode, node: AdfNode): boolean {
|
||||||
|
return isBareText(previous) && isBareText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node))
|
||||||
|
}
|
||||||
@@ -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,23 +0,0 @@
|
|||||||
import type { AdfMark } from '../adf/document.ts'
|
|
||||||
import type { JsonValue } from '../json-value.ts'
|
|
||||||
import { isAdfMark, nodeAttrs } from '../adf/document.ts'
|
|
||||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
|
||||||
|
|
||||||
export const marksAttribute = 'marks'
|
|
||||||
|
|
||||||
export function markValues(marks: readonly AdfMark[]): JsonValue {
|
|
||||||
return marks.map((mark) => {
|
|
||||||
const attrs = nodeAttrs(mark)
|
|
||||||
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readMarkValues(value: JsonValue): AdfMark[] | undefined {
|
|
||||||
if (!Array.isArray(value) || value.length === 0) return undefined
|
|
||||||
const marks: AdfMark[] = []
|
|
||||||
for (const item of value) {
|
|
||||||
if (!isAdfMark(item)) return undefined
|
|
||||||
marks.push(item)
|
|
||||||
}
|
|
||||||
return serializeCanonicalJson(markValues(marks), 'compact') === serializeCanonicalJson(value, 'compact') ? marks : undefined
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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 } from '../adf/document.ts'
|
||||||
|
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||||
|
import { spellAttributes, spellDirectiveOpener } from './directive-syntax.ts'
|
||||||
|
|
||||||
|
const argumentByType = new Map(
|
||||||
|
Object.entries({
|
||||||
|
blockTaskItem: 'state',
|
||||||
|
panel: 'panelType',
|
||||||
|
taskItem: 'state',
|
||||||
|
} satisfies Partial<Record<BlockType, string>>),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const documentName = 'doc'
|
||||||
|
|
||||||
|
// spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell.
|
||||||
|
export const documentAttribute = { key: 'content', value: 'none' }
|
||||||
|
|
||||||
|
export const documentSpelling = spellDirectiveOpener(documentName, undefined, spellAttributes([[documentAttribute.key, documentAttribute.value]]))
|
||||||
|
|
||||||
|
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 || name === documentName) 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) => (mark.attrs === undefined ? { type: mark.type } : { attrs: mark.attrs, type: mark.type }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readMarkValues(value: JsonValue): AdfMark[] | undefined {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) return undefined
|
||||||
|
const marks: AdfMark[] = []
|
||||||
|
for (const item of value) {
|
||||||
|
if (!isAdfMark(item)) return undefined
|
||||||
|
marks.push(item)
|
||||||
|
}
|
||||||
|
return serializeCanonicalJson(markValues(marks), 'compact') === serializeCanonicalJson(value, 'compact') ? marks : undefined
|
||||||
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import type { JsonValue } from '../json-value.ts'
|
import type { JsonValue } from '../json-value.ts'
|
||||||
import { carryName } from './opaque-carry.ts'
|
import { carryFenceType } from './opaque-carry.ts'
|
||||||
import { holdsControlCharacter } from './commonmark/grammar.ts'
|
import { infoStringCarries } from './commonmark/grammar.ts'
|
||||||
import { holdsEntityReference } from './commonmark/entity-references.ts'
|
|
||||||
|
|
||||||
export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' }
|
export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' }
|
||||||
|
|
||||||
// spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides.
|
// spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides.
|
||||||
export function languageSlot(language: JsonValue | undefined): LanguageSlot {
|
export function languageSlot(language: JsonValue | undefined): LanguageSlot {
|
||||||
if (language === undefined) return { kind: 'none' }
|
if (language === undefined) return { kind: 'none' }
|
||||||
if (typeof language !== 'string' || language === '' || language === carryName) return { kind: 'attribute' }
|
if (typeof language !== 'string' || carryFenceType(language) !== undefined || !infoStringCarries(language)) return { kind: 'attribute' }
|
||||||
if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return { kind: 'attribute' }
|
|
||||||
return { info: language, kind: 'fence' }
|
return { info: language, kind: 'fence' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function isWordCharacter(character: string): boolean {
|
|||||||
return character !== '' && !isWhitespace(character) && !isPunctuation(character)
|
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>[] {
|
export function matchEmphasis<Run extends DelimiterRun>(runs: readonly Run[]): EmphasisPairing<Run>[] {
|
||||||
const pairings: EmphasisPairing<Run>[] = []
|
const pairings: EmphasisPairing<Run>[] = []
|
||||||
const bottoms = new Map<string, Candidate<Run> | undefined>()
|
const bottoms = new Map<string, Candidate<Run> | undefined>()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readEntityReference, replacementCharacter } from './entity-references.ts'
|
import { holdsEntityReference, readEntityReference, replacementCharacter } from './entity-references.ts'
|
||||||
|
|
||||||
export type LinePosition = 'first' | 'later'
|
export type LinePosition = 'first' | 'later'
|
||||||
|
|
||||||
@@ -133,6 +133,11 @@ export function holdsControlCharacter(text: string): boolean {
|
|||||||
return controlCharacter.test(text)
|
return controlCharacter.test(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What a backtick fence's info string reads back verbatim: escapes and entity references decode, and the edges trim.
|
||||||
|
export function infoStringCarries(text: string): boolean {
|
||||||
|
return text !== '' && !/[`\\]/.test(text) && !holdsControlCharacter(text) && text === text.trim() && !holdsEntityReference(text)
|
||||||
|
}
|
||||||
|
|
||||||
export function holdsNullCharacter(text: string): boolean {
|
export function holdsNullCharacter(text: string): boolean {
|
||||||
return nullCharacter.test(text)
|
return nullCharacter.test(text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export function spellStringAttribute(text: string): string {
|
|||||||
export function spellAttributeValue(value: VocabularyValue): string {
|
export function spellAttributeValue(value: VocabularyValue): string {
|
||||||
if (value.kind === 'boolean') return `${value.value}`
|
if (value.kind === 'boolean') return `${value.value}`
|
||||||
if (value.kind === 'json') return spellJsonAttribute(value.value)
|
if (value.kind === 'json') return spellJsonAttribute(value.value)
|
||||||
if (value.kind === 'number') return spellStringAttribute(JSON.stringify(value.value))
|
if (value.kind === 'number') return spellStringAttribute(serializeCanonicalJson(value.value, 'compact'))
|
||||||
return spellStringAttribute(value.value)
|
return spellStringAttribute(value.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,13 @@ import type { JsonValue } from '../../json-value.ts'
|
|||||||
import type { Result } from '../../result.ts'
|
import type { Result } from '../../result.ts'
|
||||||
import { adfToMarkdown, markdownToAdf } from '../../index.ts'
|
import { adfToMarkdown, markdownToAdf } from '../../index.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { toEditorNormal } from '../../adf/editor-normal.ts'
|
|
||||||
|
|
||||||
function document(...content: AdfNode[]): AdfDocument {
|
function document(...content: AdfNode[]): AdfDocument {
|
||||||
return { content, type: 'doc', version: 1 }
|
return { content, type: 'doc', version: 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
function paragraph(...content: AdfNode[]): AdfNode {
|
function paragraph(...content: AdfNode[]): AdfNode {
|
||||||
return { content, type: 'paragraph' }
|
return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function code(result: Result<string>): string {
|
function code(result: Result<string>): string {
|
||||||
@@ -168,21 +167,24 @@ test('parts two adjacent lists of the same kind, the marker spelling being what
|
|||||||
const nested: AdfNode = { content: [{ content: [list, list], type: 'listItem' }], type: 'bulletList' }
|
const nested: AdfNode = { content: [{ content: [list, list], type: 'listItem' }], type: 'bulletList' }
|
||||||
assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n !adf:listBreak\n\n - x\n')
|
assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n !adf:listBreak\n\n - x\n')
|
||||||
const carried: AdfNode = { ...list, attrs: { unknown: 'x' } }
|
const carried: AdfNode = { ...list, attrs: { unknown: 'x' } }
|
||||||
assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```carry\n'))
|
assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```adf:bulletList\n'))
|
||||||
assert.ok(markdown(adfToMarkdown(document(carried, list))).endsWith('```\n\n- x\n'))
|
assert.ok(markdown(adfToMarkdown(document(carried, list))).endsWith('```\n\n- x\n'))
|
||||||
assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```carry\n'))
|
assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```adf:bulletList\n'))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('carries a node type no section spells', () => {
|
test('carries a node type no section spells, the fence naming the type wherever an info string carries it', () => {
|
||||||
assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```carry\n{\n "type": "blockCard"\n}\n```\n')
|
assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```adf:blockCard\n{}\n```\n')
|
||||||
assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```carry\n{\n "type": "toString"\n}\n```\n')
|
assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```adf:toString\n{}\n```\n')
|
||||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), '!adf:carry{json="{\\"type\\":\\"blockCard\\"}"}\n')
|
assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), '!adf:carry{json="{\\"type\\":\\"blockCard\\"}"}\n')
|
||||||
assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```carry\n{\n "text": "x",\n "type": "text"\n}\n```\n')
|
assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```adf:text\n{\n "text": "x"\n}\n```\n')
|
||||||
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```carry\n{\n "type": "hardBreak"\n}\n```\n')
|
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```adf:hardBreak\n{}\n```\n')
|
||||||
|
assert.equal(markdown(adfToMarkdown(document({ type: 'a&b' }))), '```adf:\n{\n "type": "a&b"\n}\n```\n')
|
||||||
|
assert.equal(markdown(adfToMarkdown(document({ type: 'a\\b' }))), '```adf:\n{\n "type": "a\\\\b"\n}\n```\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('spells the code block whose language is the reserved info string', () => {
|
test('spells the code block whose language opens with the reserved info string prefix', () => {
|
||||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '!adf:codeBlock {language=carry}\n```\n```\n!adf:/codeBlock\n')
|
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf:x' }, type: 'codeBlock' }))), '!adf:codeBlock {language="adf:x"}\n```\n```\n!adf:/codeBlock\n')
|
||||||
|
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '```carry\n```\n')
|
||||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), '```adf\n```\n')
|
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), '```adf\n```\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -208,21 +210,17 @@ test('refuses a carried node nested deeper than the levels its position leaves',
|
|||||||
assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth')
|
assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a node whose content model the canonical form cannot emit', () => {
|
test('carries a code block holding a node no fence holds', () => {
|
||||||
assert.equal(
|
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), '```adf:codeBlock\n{\n "content": [\n {\n "type": "paragraph"\n }\n ]\n}\n```\n')
|
||||||
markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))),
|
for (const child of [{ attrs: {}, text: 'x', type: 'text' }, { content: [], text: 'x', type: 'text' }, { marks: [], text: 'x', type: 'text' }, { content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }]) {
|
||||||
'unsupported-node-shape: a codeBlock holds plain text nodes only: this paragraph node is not one',
|
assert.ok(markdown(adfToMarkdown(document({ content: [child], type: 'codeBlock' }))).startsWith('```adf:codeBlock\n'), JSON.stringify(child))
|
||||||
)
|
}
|
||||||
assert.equal(
|
|
||||||
markdown(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))),
|
|
||||||
'unsupported-node-shape: a codeBlock holds plain text nodes only: this text node is not one',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('spells a list its own content shape cannot hold as a directive', () => {
|
test('spells a list its own content shape cannot hold as a directive', () => {
|
||||||
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), '!adf:bulletList\n!adf:paragraph\n!adf:/paragraph\n!adf:/bulletList\n')
|
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), '!adf:bulletList\n!adf:paragraph\n!adf:/paragraph\n!adf:/bulletList\n')
|
||||||
assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), '!adf:bulletList\n!adf:/bulletList\n')
|
assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), '!adf:bulletList\n!adf:/bulletList\n')
|
||||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {order=2}\n!adf:/orderedList\n')
|
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {content=empty order=2}\n!adf:/orderedList\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('spells an ordered list no marker fits as a directive', () => {
|
test('spells an ordered list no marker fits as a directive', () => {
|
||||||
@@ -334,7 +332,7 @@ test('refuses a node carrying one mark type twice', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('parts a nested list the tight spelling would swallow from the block above it', () => {
|
test('parts a nested list the tight spelling would swallow from the block above it', () => {
|
||||||
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' })
|
const item = (...content: AdfNode[]): AdfNode => (content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' })
|
||||||
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
|
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
|
||||||
const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' })
|
const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' })
|
||||||
const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' }
|
const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' }
|
||||||
@@ -345,7 +343,7 @@ test('parts a nested list the tight spelling would swallow from the block above
|
|||||||
const list: AdfNode = { content: [item(text('b'))], type: 'bulletList' }
|
const list: AdfNode = { content: [item(text('b'))], type: 'bulletList' }
|
||||||
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [text('p')], type: 'panel' }
|
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [text('p')], type: 'panel' }
|
||||||
assert.equal(markdown(adfToMarkdown(outer(panel, list))), '- !adf:panel info\n p\n !adf:/panel\n\n - b\n')
|
assert.equal(markdown(adfToMarkdown(outer(panel, list))), '- !adf:panel info\n p\n !adf:/panel\n\n - b\n')
|
||||||
assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```carry\n'))
|
assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```adf:bulletList\n'))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses marks and attributes nested deeper than the emitter carries', () => {
|
test('refuses marks and attributes nested deeper than the emitter carries', () => {
|
||||||
@@ -353,8 +351,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')
|
assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth')
|
||||||
let attrs: AdfMark['attrs'] = { depth: 'x' }
|
let attrs: AdfMark['attrs'] = { depth: 'x' }
|
||||||
for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs }
|
for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs }
|
||||||
const deeper = (key: string, type: string, levels: number = largestNesting): string =>
|
const deeper = (key: string, type: string): string =>
|
||||||
`unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
|
`unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
|
||||||
const nested = (levels: number): JsonValue => {
|
const nested = (levels: number): JsonValue => {
|
||||||
let value: JsonValue = 1
|
let value: JsonValue = 1
|
||||||
for (let level = 0; level < levels; level += 1) value = [value]
|
for (let level = 0; level < levels; level += 1) value = [value]
|
||||||
@@ -372,14 +370,21 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
|
|||||||
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
|
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
|
||||||
const read = markdownToAdf(spelled.value)
|
const read = markdownToAdf(spelled.value)
|
||||||
assert.ok(read.ok, read.ok ? '' : read.error.message)
|
assert.ok(read.ok, read.ok ? '' : read.error.message)
|
||||||
assert.deepEqual(toEditorNormal(read.value), document(node))
|
assert.deepEqual(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.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper('data', 'inlineCard'))
|
||||||
assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), [])
|
assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), [])
|
||||||
roundTrips(paragraph(card(largestNesting)))
|
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))
|
roundTrips(marked(largestNesting - 3))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -435,7 +440,7 @@ test('escapes a hyphen underline a hard break would expose', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('spells a list item whose marker completes a thematic break as a directive', () => {
|
test('spells a list item whose marker completes a thematic break as a directive', () => {
|
||||||
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' })
|
const item = (...content: AdfNode[]): AdfNode => (content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' })
|
||||||
assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')
|
assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')
|
||||||
const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
|
const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
|
||||||
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n')
|
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n')
|
||||||
@@ -462,10 +467,7 @@ test('refuses the characters CommonMark rewrites', () => {
|
|||||||
)
|
)
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\u0000b', type: 'text' })))), 'unspellable-character')
|
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\u0000b', type: 'text' })))), 'unspellable-character')
|
||||||
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))), 'unspellable-character')
|
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))), 'unspellable-character')
|
||||||
assert.equal(
|
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape: a text node holds text: this one has none')
|
||||||
markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))),
|
|
||||||
'unsupported-node-shape: a codeBlock holds plain text nodes only: this text node is not one',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a text node the spelling would empty out', () => {
|
test('refuses a text node the spelling would empty out', () => {
|
||||||
@@ -494,11 +496,11 @@ test('pads a code span whose edges CommonMark would strip', () => {
|
|||||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: ' \t ', type: 'text' })))), '` \t `\n')
|
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: ' \t ', type: 'text' })))), '` \t `\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('spells one code span over a run of code-marked nodes', () => {
|
test('spells a code span per code-marked node, parted by the text break', () => {
|
||||||
const code_ = { type: 'code' }
|
const code_ = { type: 'code' }
|
||||||
assert.equal(
|
assert.equal(
|
||||||
markdown(adfToMarkdown(document(paragraph({ marks: [code_], text: 'a', type: 'text' }, { marks: [code_], text: 'b', type: 'text' })))),
|
markdown(adfToMarkdown(document(paragraph({ marks: [code_], text: 'a', type: 'text' }, { marks: [code_], text: 'b', type: 'text' })))),
|
||||||
'`ab`\n',
|
'`a`!adf:textBreak{}`b`\n',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -538,7 +540,7 @@ test('emits an empty list item without trailing whitespace', () => {
|
|||||||
test('spells a block directive as its node type, arg and attributes', () => {
|
test('spells a block directive as its node type, arg and attributes', () => {
|
||||||
const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' })
|
const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' })
|
||||||
assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), '!adf:panel warning\nx\n!adf:/panel\n')
|
assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), '!adf:panel warning\nx\n!adf:/panel\n')
|
||||||
assert.equal(markdown(adfToMarkdown(panel({}))), '!adf:panel\nx\n!adf:/panel\n')
|
assert.equal(markdown(adfToMarkdown(panel({}))), '!adf:panel {attrs=empty}\nx\n!adf:/panel\n')
|
||||||
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), '!adf:caption\nx\n!adf:/caption\n')
|
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), '!adf:caption\nx\n!adf:/caption\n')
|
||||||
assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '!adf:caption\n!adf:/caption\n')
|
assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '!adf:caption\n!adf:/caption\n')
|
||||||
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '!adf:syncBlock {localId=a}\n')
|
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '!adf:syncBlock {localId=a}\n')
|
||||||
@@ -546,20 +548,20 @@ test('spells a block directive as its node type, arg and attributes', () => {
|
|||||||
|
|
||||||
test('carries a directive attribute no section spells', () => {
|
test('carries a directive attribute no section spells', () => {
|
||||||
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
|
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
|
||||||
assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```carry\n{\n "attrs": {\n "rounded": true\n },\n "type": "panel"\n}\n```\n')
|
assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "rounded": true\n }\n}\n```\n')
|
||||||
assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```carry\n{\n "attrs": {\n "toString": "x"\n },\n "type": "panel"\n}\n```\n')
|
assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "toString": "x"\n }\n}\n```\n')
|
||||||
assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```carry\n{\n "attrs": {\n "localId": 4\n },\n "type": "panel"\n}\n```\n')
|
assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "localId": 4\n }\n}\n```\n')
|
||||||
assert.equal(
|
assert.equal(
|
||||||
carried({ attrs: { width: '50' }, type: 'layoutColumn' }),
|
carried({ attrs: { width: '50' }, type: 'layoutColumn' }),
|
||||||
'```carry\n{\n "attrs": {\n "width": "50"\n },\n "type": "layoutColumn"\n}\n```\n',
|
'```adf:layoutColumn\n{\n "attrs": {\n "width": "50"\n }\n}\n```\n',
|
||||||
)
|
)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }),
|
carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }),
|
||||||
'```carry\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n },\n "type": "table"\n}\n```\n',
|
'```adf:table\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n }\n}\n```\n',
|
||||||
)
|
)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }),
|
carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }),
|
||||||
'!adf:mediaGroup\n```carry\n{\n "attrs": {\n "alt": 4\n },\n "type": "media"\n}\n```\n!adf:/mediaGroup\n',
|
'!adf:mediaGroup\n```adf:media\n{\n "attrs": {\n "alt": 4\n }\n}\n```\n!adf:/mediaGroup\n',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -567,15 +569,16 @@ test('carries an arg slot value no bare token spells', () => {
|
|||||||
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
|
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
|
||||||
assert.equal(
|
assert.equal(
|
||||||
carried({ attrs: { panelType: 'extra info' }, type: 'panel' }),
|
carried({ attrs: { panelType: 'extra info' }, type: 'panel' }),
|
||||||
'```carry\n{\n "attrs": {\n "panelType": "extra info"\n },\n "type": "panel"\n}\n```\n',
|
'```adf:panel\n{\n "attrs": {\n "panelType": "extra info"\n }\n}\n```\n',
|
||||||
)
|
)
|
||||||
assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```carry\n{\n "attrs": {\n "state": 2\n },\n "type": "taskItem"\n}\n```\n')
|
assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```adf:taskItem\n{\n "attrs": {\n "state": 2\n }\n}\n```\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('carries a block node mark in the reserved attribute', () => {
|
test('carries a block node mark in the reserved attribute', () => {
|
||||||
const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' })
|
const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' })
|
||||||
assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
|
assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
|
||||||
assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
|
assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"attrs\\":{},\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
|
||||||
|
assert.equal(markdown(adfToMarkdown(section())), '!adf:layoutSection {marks=empty}\n!adf:/layoutSection\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses the content a directive body has no room for', () => {
|
test('refuses the content a directive body has no room for', () => {
|
||||||
@@ -597,7 +600,7 @@ test('separates blocks in a container body by a blank line only where a directiv
|
|||||||
test('spells the image form for exactly the centered external media shape', () => {
|
test('spells the image form for exactly the centered external media shape', () => {
|
||||||
const url = 'https://example.com/moon.png'
|
const url = 'https://example.com/moon.png'
|
||||||
const single = (attrs: AdfAttributes, ...content: AdfNode[]): AdfDocument =>
|
const single = (attrs: AdfAttributes, ...content: AdfNode[]): AdfDocument =>
|
||||||
document({ attrs: { layout: 'center' }, content: [{ attrs, content, type: 'media' }], type: 'mediaSingle' })
|
document({ attrs: { layout: 'center' }, content: [content.length === 0 ? { attrs, type: 'media' } : { attrs, content, type: 'media' }], type: 'mediaSingle' })
|
||||||
assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `\n`)
|
assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `\n`)
|
||||||
assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `\n`)
|
assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `\n`)
|
||||||
assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`)
|
assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`)
|
||||||
@@ -694,7 +697,8 @@ test('spells the directive marks around the longest run they cover', () => {
|
|||||||
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
|
||||||
const underline: AdfMark = { type: 'underline' }
|
const underline: AdfMark = { type: 'underline' }
|
||||||
assert.equal(emitted(marked('x', underline)), '!adf:underline[x]\n')
|
assert.equal(emitted(marked('x', underline)), '!adf:underline[x]\n')
|
||||||
assert.equal(emitted(marked('a', underline), marked('b', underline)), '!adf:underline[ab]\n')
|
assert.equal(emitted(marked('a', underline), marked('b', underline)), '!adf:underline[a!adf:textBreak{}b]\n')
|
||||||
|
assert.equal(emitted(marked('a', { attrs: {}, type: 'underline' }), marked('b', underline)), '!adf:underline[a]{attrs=empty}!adf:underline[b]\n')
|
||||||
assert.equal(emitted(marked('x', { attrs: { type: 'sub' }, type: 'subsup' })), '!adf:subsup[x]{type=sub}\n')
|
assert.equal(emitted(marked('x', { attrs: { type: 'sub' }, type: 'subsup' })), '!adf:subsup[x]{type=sub}\n')
|
||||||
assert.equal(emitted(marked('x', { attrs: { color: '#ae2e24' }, type: 'textColor' })), '!adf:textColor[x]{color="#ae2e24"}\n')
|
assert.equal(emitted(marked('x', { attrs: { color: '#ae2e24' }, type: 'textColor' })), '!adf:textColor[x]{color="#ae2e24"}\n')
|
||||||
assert.equal(emitted(marked('x', { attrs: { color: '#091e42', size: 2 }, type: 'border' })), '!adf:border[x]{color="#091e42" size=2}\n')
|
assert.equal(emitted(marked('x', { attrs: { color: '#091e42', size: 2 }, type: 'border' })), '!adf:border[x]{color="#091e42" size=2}\n')
|
||||||
@@ -739,5 +743,5 @@ test('carries whitespace CommonMark strips in the reserved text directive', () =
|
|||||||
|
|
||||||
test("joins a mark run's segments as a walk rather than as one call's arguments", () => {
|
test("joins a mark run's segments as a walk rather than as one call's arguments", () => {
|
||||||
const run = Array.from({ length: 200000 }, (): AdfNode => ({ marks: [{ type: 'strong' }], text: 'a', type: 'text' }))
|
const run = Array.from({ length: 200000 }, (): AdfNode => ({ marks: [{ type: 'strong' }], text: 'a', type: 'text' }))
|
||||||
assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${'a'.repeat(200000)}**\n`)
|
assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${Array.from({ length: 200000 }, () => 'a').join('!adf:textBreak{}')}**\n`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
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 { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
import type { Flavour } from '../plain/conventions.ts'
|
||||||
import { blockDirective, blockDirectives } from '../../adf/block-directives.ts'
|
import { adfDocumentFault, holdsOnlyAttributes, isUnmarkedBareText, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||||
import { blockDirectiveForm } from '../block-directive-forms.ts'
|
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain/conventions.ts'
|
||||||
|
import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts'
|
||||||
|
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
|
||||||
import { carriedBlock } from '../opaque-carry.ts'
|
import { carriedBlock } from '../opaque-carry.ts'
|
||||||
import { emitInlineLine } from './inline-line.ts'
|
import { emitInlineLine } from './inline-line.ts'
|
||||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
|
import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
|
||||||
import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts'
|
import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts'
|
||||||
import { languageSlot } from '../code-language.ts'
|
import { languageSlot, type LanguageSlot } from '../code-language.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { listBreakSpelling } from '../list-break.ts'
|
|
||||||
import { spellBlockDirectiveOpener } from './block-directive-spelling.ts'
|
import { spellBlockDirectiveOpener } from './block-directive-spelling.ts'
|
||||||
import { spellDirectiveCloser, spellDirectiveOpener } from '../directive-syntax.ts'
|
import { spellDirectiveCloser, spellDirectiveOpener } from '../directive-syntax.ts'
|
||||||
import { tryImage } from './image.ts'
|
import { tryImage } from './image.ts'
|
||||||
@@ -20,32 +21,39 @@ type BlockContainer = 'directive' | 'document' | 'list-item'
|
|||||||
type BlockSpelling = 'commonmark' | 'directive' | 'list'
|
type BlockSpelling = 'commonmark' | 'directive' | 'list'
|
||||||
type EmittedBlock = { headroom: number; spelling: BlockSpelling; text: string }
|
type EmittedBlock = { headroom: number; spelling: BlockSpelling; text: string }
|
||||||
type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
|
type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
|
||||||
type PlacedBlock = EmittedBlock & { node: AdfNode }
|
type PlacedBlock = Omit<EmittedBlock, 'headroom'> & { node: AdfNode }
|
||||||
|
type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
|
||||||
|
// 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>
|
export type SpellingMemo = Map<AdfNode, KeptSpelling>
|
||||||
type Walk = { blocks: readonly PlacedBlock[]; headroom: number }
|
type WalkedItem = { node: AdfNode; walk: PlacedBlocks }
|
||||||
type WalkedItem = { node: AdfNode; walk: Walk }
|
export type Writing = { flavour: Flavour; memo: SpellingMemo | undefined }
|
||||||
|
|
||||||
const largestListMarker = 999999999
|
export const largestListMarker = 999999999
|
||||||
// Bare because emitList admits no item carrying attributes, marks or text.
|
// Bare because tryList admits no item carrying attributes, marks or text.
|
||||||
const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
|
const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
|
||||||
|
|
||||||
export function adfToMarkdown(document: AdfDocument): Result<string> {
|
export function adfToMarkdown(document: AdfDocument): Result<string> {
|
||||||
|
return writeMarkdown(document, 'lossless')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeMarkdown(document: AdfDocument, flavour: Flavour): Result<string> {
|
||||||
const fault = adfDocumentFault(document)
|
const fault = adfDocumentFault(document)
|
||||||
if (fault !== undefined) return faulted(fault, [])
|
if (fault !== undefined) return faulted(fault, [])
|
||||||
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
||||||
const walk = walkBlocks(nodeContent(document), [], 0, undefined)
|
if (document.content === undefined) return success(`${documentSpelling}\n`)
|
||||||
|
const walk = walkBlocks(document.content, [], 0, { flavour, memo: undefined })
|
||||||
if (!walk.ok) return walk
|
if (!walk.ok) return walk
|
||||||
const text = joinBlocks(walk.value.blocks, 'document')
|
const text = joinBlocks(walk.value.blocks, 'document')
|
||||||
return success(text === '' ? '' : `${text}\n`)
|
return success(text === '' ? '' : `${text}\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// headroom: the least slack any depth guard below the walk has.
|
// 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<PlacedBlocks> {
|
||||||
let headroom = largestNesting - depth
|
let headroom = largestNesting - depth
|
||||||
if (headroom < 0) return tooDeep(path)
|
if (headroom < 0) return tooDeep(path)
|
||||||
const blocks: PlacedBlock[] = []
|
const blocks: PlacedBlock[] = []
|
||||||
for (const [index, node] of nodes.entries()) {
|
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
|
if (!block.ok) return block
|
||||||
headroom = Math.min(headroom, block.value.headroom)
|
headroom = Math.min(headroom, block.value.headroom)
|
||||||
blocks.push({ ...block.value, node })
|
blocks.push({ ...block.value, node })
|
||||||
@@ -68,64 +76,133 @@ function joinBlocks(blocks: readonly PlacedBlock[], container: BlockContainer):
|
|||||||
}
|
}
|
||||||
|
|
||||||
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string {
|
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string {
|
||||||
const plainPair = previous.spelling !== 'directive' && next.spelling !== 'directive'
|
const bothCommonMark = previous.spelling !== 'directive' && next.spelling !== 'directive'
|
||||||
if (plainPair && next.spelling === 'list') {
|
if (bothCommonMark && next.spelling === 'list') {
|
||||||
if (previous.spelling === 'list' && previous.node.type === next.node.type) {
|
if (previous.spelling === 'list' && previous.node.type === next.node.type) {
|
||||||
const gap = container === 'directive' ? '\n' : '\n\n'
|
const gap = container === 'directive' ? '\n' : '\n\n'
|
||||||
return `${gap}${listBreakSpelling}${gap}`
|
return `${gap}${listBreakSpelling}${gap}`
|
||||||
}
|
}
|
||||||
if (container === 'list-item') return interruptsParagraph(next.node) ? '\n' : '\n\n'
|
if (container === 'list-item') return interruptsParagraph(next.node) ? '\n' : '\n\n'
|
||||||
}
|
}
|
||||||
return container === 'directive' && !plainPair ? '\n' : '\n\n'
|
return container === 'directive' && !bothCommonMark ? '\n' : '\n\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
function interruptsParagraph(node: AdfNode): boolean {
|
function interruptsParagraph(node: AdfNode): boolean {
|
||||||
const items = nodeContent(node)
|
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)
|
if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty)
|
||||||
return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty)
|
return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> {
|
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
|
||||||
const directive = blockDirective(node.type)
|
const model = blockNodeModel(node.type)
|
||||||
if (directive === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
if (model === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||||
const readable = readableBlock(node, path, depth, memo)
|
const readable = readableBlock(node, path, depth, writing)
|
||||||
if (readable !== undefined) return readable
|
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 {
|
export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<null> | undefined {
|
||||||
const readable = readableBlock(node, path, depth, memo)
|
const readable = readableBlock(node, path, depth, writing)
|
||||||
if (readable === undefined) return undefined
|
if (readable === undefined) return undefined
|
||||||
if (!readable.ok) return readable
|
if (!readable.ok) return readable
|
||||||
return readable.value.spelling === 'directive' ? undefined : success(null)
|
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)
|
const kept = memo?.get(node)
|
||||||
if (kept !== undefined) {
|
if (kept !== undefined) {
|
||||||
if (kept.block === undefined) return 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 })
|
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 })
|
if (spelled === undefined) memo?.set(node, { block: undefined, depth })
|
||||||
else if (spelled.ok) memo?.set(node, { block: spelled.value, depth })
|
else if (spelled.ok) memo?.set(node, { block: spelled.value, depth })
|
||||||
return spelled
|
return spelled
|
||||||
}
|
}
|
||||||
|
|
||||||
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined {
|
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||||
if (node.type === 'blockquote') return emitBlockquote(node, path, depth, memo)
|
const plain = writing.flavour === 'plain' ? spellPlainBlock(node, path, depth, writing) : undefined
|
||||||
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth, memo)
|
if (plain !== undefined) return plain
|
||||||
if (node.type === 'codeBlock') return emitCodeBlock(node, path)
|
if (node.type === 'blockquote') return tryBlockquote(node, path, depth, writing)
|
||||||
if (node.type === 'heading') return emitHeading(node, path)
|
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 === 'mediaSingle') return readableText(tryImage(node, path))
|
||||||
if (node.type === 'paragraph') return emitParagraph(node, path)
|
if (node.type === 'paragraph') return tryParagraph(node, path, writing.flavour)
|
||||||
if (node.type === 'rule') return emitRule(node)
|
if (node.type === 'rule') return readableText(tryRule(node))
|
||||||
if (node.type === 'table') return readableText(tryPipeTable(node, path))
|
if (node.type === 'table') return readableText(tryPipeTable(node, path, writing.flavour))
|
||||||
return undefined
|
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<PlacedBlocks> {
|
||||||
|
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<PlacedBlocks> {
|
||||||
|
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 {
|
function readableText(text: string | undefined): Result<EmittedBlock> | undefined {
|
||||||
return text === undefined ? undefined : success(commonMarkText(text))
|
return text === undefined ? undefined : success(commonMarkText(text))
|
||||||
}
|
}
|
||||||
@@ -143,19 +220,20 @@ function directivePair(node: AdfNode, opener: string, body: string, headroom: nu
|
|||||||
return { headroom, spelling: 'directive', text: `${opener}\n${body === '' ? '' : `${body}\n`}${spellDirectiveCloser(node.type)}` }
|
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<PlacedBlocks>): Result<EmittedBlock> {
|
||||||
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
|
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 (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)
|
if (model.contentModel === 'code') return emitCodeDirective(node, model, path, depth)
|
||||||
const opener = spellBlockDirectiveOpener(node, directive)
|
const opener = spellBlockDirectiveOpener(node, model, path)
|
||||||
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
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<PlacedBlocks>): Result<EmittedBlock> {
|
||||||
if (blockDirectiveForm(node.type) === 'leaf') return success({ headroom: Number.POSITIVE_INFINITY, spelling: 'directive', text: opener })
|
if (blockDirectiveForm(node.type) === 'leaf') return success({ headroom: Number.POSITIVE_INFINITY, spelling: 'directive', text: opener })
|
||||||
if (directive.contentModel === 'inline') {
|
if (model.contentModel === 'inline') {
|
||||||
const line = emitInlineLine(nodeContent(node), 'paragraph', path)
|
const line = emitInlineLine(nodeContent(node), 'paragraph', path, 'lossless')
|
||||||
if (!line.ok) return line
|
if (!line.ok) return line
|
||||||
return success(directivePair(node, opener, line.value))
|
return success(directivePair(node, opener, line.value))
|
||||||
}
|
}
|
||||||
@@ -164,109 +242,109 @@ function emitDirectiveBody(node: AdfNode, directive: BlockDirective, opener: str
|
|||||||
return success(directivePair(node, opener, joinBlocks(walk.value.blocks, 'directive'), walk.value.headroom))
|
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
|
if (!holdsOnlyAttributes(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
|
if (!inner.ok) return inner
|
||||||
const text = joinBlocks(inner.value.blocks, 'document')
|
const text = joinBlocks(inner.value.blocks, 'document')
|
||||||
.split('\n')
|
const alert = writing.flavour === 'plain' && leadingMarker(text, readAlertMarker) !== undefined
|
||||||
.map((line) => (line === '' ? '>' : `> ${line}`))
|
return success(commonMarkText(quoted(alert ? `\\${text}` : text), inner.value.headroom))
|
||||||
.join('\n')
|
|
||||||
return success(commonMarkText(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
|
if (!holdsOnlyAttributes(node, ['language'])) return undefined
|
||||||
const slot = languageSlot(nodeAttrs(node)['language'])
|
const slot = languageSlot(nodeAttrs(node)['language'])
|
||||||
if (slot.kind === 'attribute') return undefined
|
if (slot.kind === 'attribute') return undefined
|
||||||
const text = codeBlockText(node, path)
|
const texts = fencedTexts(node, path)
|
||||||
if (!text.ok) return text
|
if (!texts.ok) return texts
|
||||||
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
|
const [only, ...others] = texts.value ?? []
|
||||||
|
if (only === undefined || others.length > 0) return undefined
|
||||||
|
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', only)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
// spec/flavour.md, The CommonMark blocks: one fence per text node, the language on each; with no fence to carry it, the attribute does.
|
||||||
const slot = languageSlot(nodeAttrs(node)['language'])
|
function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||||
const opener = spellBlockDirectiveOpener(node, directive, slot.kind === 'attribute' ? [] : ['language'])
|
const texts = fencedTexts(node, path)
|
||||||
|
if (!texts.ok) return texts
|
||||||
|
if (texts.value === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||||
|
const slot: LanguageSlot = texts.value.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language'])
|
||||||
|
const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
|
||||||
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||||
const text = codeBlockText(node, path)
|
if (!opener.ok) return opener
|
||||||
if (!text.ok) return text
|
const info = slot.kind === 'fence' ? slot.info : ''
|
||||||
return success(directivePair(node, opener, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
|
return success(directivePair(node, opener.value, texts.value.map((text) => fencedCodeBlock(info, text)).join('\n')))
|
||||||
}
|
}
|
||||||
|
|
||||||
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
// The text each fence holds, or `undefined` where a child is no plain text node, which the carry holds instead.
|
||||||
let text = ''
|
function fencedTexts(node: AdfNode, path: ConvertErrorPath): Result<string[] | undefined> {
|
||||||
for (const [index, child] of nodeContent(node).entries()) {
|
if (node.content === undefined) return success([''])
|
||||||
|
if (node.content.some((child) => !isUnmarkedBareText(child))) return success(undefined)
|
||||||
|
const texts: string[] = []
|
||||||
|
for (const [index, child] of node.content.entries()) {
|
||||||
const childPath = [...path, 'content', index]
|
const childPath = [...path, 'content', index]
|
||||||
if (
|
if (typeof child.text !== 'string' || child.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', childPath)
|
||||||
child.type !== 'text' ||
|
|
||||||
typeof child.text !== 'string' ||
|
|
||||||
child.text === '' ||
|
|
||||||
nodeContent(child).length > 0 ||
|
|
||||||
nodeMarks(child).length > 0 ||
|
|
||||||
Object.keys(nodeAttrs(child)).length > 0
|
|
||||||
) {
|
|
||||||
return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath)
|
|
||||||
}
|
|
||||||
if (/\r/.test(child.text)) return failure('unspellable-character', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath)
|
if (/\r/.test(child.text)) return failure('unspellable-character', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath)
|
||||||
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
|
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
|
||||||
text += child.text
|
texts.push(child.text)
|
||||||
}
|
}
|
||||||
return success(text)
|
return success(texts)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
if (!holdsOnlyAttributes(node, ['level'])) return undefined
|
||||||
const level = nodeAttrs(node)['level']
|
const level = nodeAttrs(node)['level']
|
||||||
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
|
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
|
||||||
const hashes = '#'.repeat(level)
|
const hashes = '#'.repeat(level)
|
||||||
const content = nodeContent(node)
|
const content = nodeContent(node)
|
||||||
if (content.length === 0) return success(commonMarkText(hashes))
|
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
|
if (!line.ok) return line
|
||||||
return success(commonMarkText(`${hashes} ${line.value}`))
|
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'
|
const ordered = node.type === 'orderedList'
|
||||||
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
|
if (!holdsOnlyAttributes(node, ordered ? ['order'] : [])) return undefined
|
||||||
const items = nodeContent(node)
|
const items = nodeContent(node)
|
||||||
const start = listStart(node, items.length)
|
const start = listStart(node, items.length)
|
||||||
if (start === undefined || items.length === 0) return undefined
|
if (start === undefined || items.length === 0) return undefined
|
||||||
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined
|
if (items.some((item) => item.type !== 'listItem' || !holdsOnlyAttributes(item, []))) return undefined
|
||||||
const walked: WalkedItem[] = []
|
const walked: WalkedItem[] = []
|
||||||
let headroom = Number.POSITIVE_INFINITY
|
let headroom = Number.POSITIVE_INFINITY
|
||||||
for (const [offset, item] of items.entries()) {
|
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
|
if (!walk.ok) return walk
|
||||||
headroom = Math.min(headroom, walk.value.headroom)
|
headroom = Math.min(headroom, walk.value.headroom)
|
||||||
walked.push({ node: item, walk: walk.value })
|
walked.push({ node: item, walk: walk.value })
|
||||||
}
|
}
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
for (const [offset, item] of walked.entries()) {
|
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) {
|
if (line === undefined) {
|
||||||
|
// The directive form spends a level the walk did not count.
|
||||||
if (headroom < 1) return tooDeep(path)
|
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)
|
lines.push(line)
|
||||||
}
|
}
|
||||||
return success({ headroom, spelling: 'list', text: lines.join('\n') })
|
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[] {
|
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 {
|
function listStart(node: AdfNode, items: number): number | undefined {
|
||||||
if (node.type !== 'orderedList') return 0
|
if (node.type !== 'orderedList') return 0
|
||||||
const start = nodeAttrs(node)['order']
|
const start = nodeAttrs(node)['order']
|
||||||
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined
|
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || Object.is(start, -0) || start > largestListMarker) return undefined
|
||||||
return start + items - 1 > largestListMarker ? undefined : start
|
return start + items - 1 > largestListMarker ? undefined : start
|
||||||
}
|
}
|
||||||
|
|
||||||
function listItemLines(blocks: readonly PlacedBlock[], marker: string): string | undefined {
|
function tryListItemLines(inner: string, marker: string): string | undefined {
|
||||||
const inner = joinBlocks(blocks, 'list-item')
|
|
||||||
if (inner === '') return marker.trimEnd()
|
if (inner === '') return marker.trimEnd()
|
||||||
const body = inner.split('\n')
|
const body = inner.split('\n')
|
||||||
if (body.some((line) => line !== '' && isBlankLine(line))) return undefined
|
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')
|
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)
|
const content = nodeContent(node)
|
||||||
if (content.length === 0 || !carriesOnly(node, [])) return undefined
|
if (content.length === 0 || !holdsOnlyAttributes(node, [])) return undefined
|
||||||
const line = emitInlineLine(content, 'paragraph', path)
|
const line = emitInlineLine(content, 'paragraph', path, flavour)
|
||||||
if (!line.ok) return line
|
if (!line.ok) return line
|
||||||
return success(commonMarkText(line.value))
|
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
|
if (!holdsOnlyAttributes(node, []) || nodeContent(node).length > 0) return undefined
|
||||||
return success(commonMarkText('---'))
|
return '---'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
import type { AdfNode } from '../../adf/document.ts'
|
import type { AdfNode } from '../../adf/document.ts'
|
||||||
import type { BlockDirective } from '../../adf/block-directives.ts'
|
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
||||||
import { blockArgument } from '../block-directive-arguments.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 { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
|
||||||
import { markValues, marksAttribute } from '../block-directive-marks.ts'
|
import { overNested } from '../../json-value.ts'
|
||||||
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
import { spellEmptyKeys } from '../empty-keys.ts'
|
||||||
import { vocabularyPairs } from '../../adf/attribute-vocabulary.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 argumentAttribute = blockArgument(node.type)
|
||||||
const slot = bareArgument(node, argumentAttribute)
|
const slot = bareArgument(node, argumentAttribute)
|
||||||
if (slot === undefined) return undefined
|
if (slot === undefined) return undefined
|
||||||
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
|
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
|
if (pairs === undefined) return undefined
|
||||||
const spelledPairs = spellVocabulary(pairs)
|
const spelledPairs = [...spellVocabulary(pairs), ...spellEmptyKeys(node)]
|
||||||
const marks = nodeMarks(node)
|
const marks = nodeMarks(node)
|
||||||
if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))])
|
if (marks.length > 0) {
|
||||||
return spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs))
|
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.
|
// `undefined` where the argument slot holds a value no bare token spells.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AdfNode } from '../../adf/document.ts'
|
import type { AdfNode } from '../../adf/document.ts'
|
||||||
import type { ConvertErrorPath } from '../../result.ts'
|
import type { ConvertErrorPath } from '../../result.ts'
|
||||||
import { carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
import { holdsOnlyAttributes, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||||
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||||
import { tryImageLine } from './inline-line.ts'
|
import { tryImageLine } from './inline-line.ts'
|
||||||
|
|
||||||
@@ -16,8 +16,8 @@ export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefi
|
|||||||
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
|
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
|
||||||
const content = nodeContent(node)
|
const content = nodeContent(node)
|
||||||
const media = content[0]
|
const media = content[0]
|
||||||
if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined
|
if (!holdsOnlyAttributes(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined
|
||||||
if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || nodeContent(media).length > 0) return undefined
|
if (media === undefined || content.length !== 1 || media.type !== 'media' || !holdsOnlyAttributes(media, imageAttributes) || nodeContent(media).length > 0) return undefined
|
||||||
const attrs = nodeAttrs(media)
|
const attrs = nodeAttrs(media)
|
||||||
const alt = attrs['alt']
|
const alt = attrs['alt']
|
||||||
const url = attrs['url']
|
const url = attrs['url']
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { AdfNode } from '../../adf/document.ts'
|
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 { nodeAttrs } from '../../adf/document.ts'
|
||||||
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
|
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
|
||||||
|
import { spellEmptyKeys } from '../empty-keys.ts'
|
||||||
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
||||||
|
|
||||||
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined {
|
export function spellInlineNodeAttributes(node: AdfNode, model: InlineNodeModel): string | undefined {
|
||||||
const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, directive.textAttribute === undefined ? [] : [directive.textAttribute])
|
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, model.textAttribute === undefined ? [] : [model.textAttribute])
|
||||||
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
|
return pairs === undefined ? undefined : spellAttributes([...spellVocabulary(pairs), ...spellEmptyKeys(node)])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
import type { InlineDirective } from '../../adf/inline-directives.ts'
|
import type { Flavour } from '../plain/conventions.ts'
|
||||||
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.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 { carriedInline } from '../opaque-carry.ts'
|
||||||
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||||
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
||||||
|
import { identicalMark, isBareText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||||
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
|
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
|
||||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.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 { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
|
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||||
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
|
||||||
import { sameMark } from '../../adf/editor-normal.ts'
|
|
||||||
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
|
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
|
||||||
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
|
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
|
||||||
import { spellTextDirective } from '../text-directive.ts'
|
import { spellTextDirective } from '../text-directive.ts'
|
||||||
@@ -23,6 +26,7 @@ type InlineContext = {
|
|||||||
atBlockEnd: boolean
|
atBlockEnd: boolean
|
||||||
bracketed: boolean
|
bracketed: boolean
|
||||||
carried: ReadonlySet<number>
|
carried: ReadonlySet<number>
|
||||||
|
flavour: Flavour
|
||||||
openingLinkAsDirective: boolean
|
openingLinkAsDirective: boolean
|
||||||
path: ConvertErrorPath
|
path: ConvertErrorPath
|
||||||
spansLines: boolean
|
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 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> {
|
export type PlainLineFallback = { kind: 'claimed-line'; line: number; text: string } | { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
|
||||||
const emitted = emitLine(nodes, container, path)
|
|
||||||
|
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
|
if (!emitted.ok) return emitted
|
||||||
return success(emitted.value.line)
|
return success(emitted.value.line)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<boolean> {
|
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
|
if (!emitted.ok) return emitted
|
||||||
return success(emitted.value.openingLinkAsDirective)
|
return success(emitted.value.openingLinkAsDirective)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
|
export function plainLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<PlainLineFallback | undefined> {
|
||||||
const emitted = emitLine(nodes, 'table-cell', path)
|
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.ok) return undefined
|
||||||
if (emitted.value.segments.some((segment) => isSyntax(segment.escaping) && segment.text.includes('|'))) return undefined
|
if (emitted.value.segments.some((segment) => isSyntax(segment.escaping) && segment.text.includes('|'))) return undefined
|
||||||
return emitted.value.line
|
return emitted.value.line
|
||||||
@@ -58,12 +72,13 @@ export function tryImageLine(alt: string | undefined, href: string, path: Conver
|
|||||||
const destination = spellDestination(href)
|
const destination = spellDestination(href)
|
||||||
if (destination === undefined) return undefined
|
if (destination === undefined) return undefined
|
||||||
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
|
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
|
return attempt.ok ? attempt.value.line : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<EmittedLine> {
|
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<EmittedLine> {
|
||||||
const fallbacks: LineFallbacks = { carried: new Set(), openingLinkAsDirective: false }
|
const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false }
|
||||||
|
// Terminates because takeFallback refuses a pass that took no new fallback.
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const emission = lineSegments(nodes, container, path, fallbacks)
|
const emission = lineSegments(nodes, container, path, fallbacks)
|
||||||
if (!emission.ok) return emission
|
if (!emission.ok) return emission
|
||||||
@@ -72,7 +87,7 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con
|
|||||||
if (!taken.ok) return taken
|
if (!taken.ok) return taken
|
||||||
continue
|
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.ok) return attempt
|
||||||
if (attempt.value.line !== undefined) {
|
if (attempt.value.line !== undefined) {
|
||||||
return success({ line: attempt.value.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
|
return success({ line: attempt.value.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
|
||||||
@@ -99,48 +114,55 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
|
|||||||
const emission = emitRun(nodes, 0, 0, context)
|
const emission = emitRun(nodes, 0, 0, context)
|
||||||
if (!emission.ok) return emission
|
if (!emission.ok) return emission
|
||||||
if (emission.value.carry !== undefined) return emission
|
if (emission.value.carry !== undefined) return emission
|
||||||
return success({ segments: carryStrippedWhitespace(emission.value.segments) })
|
return success({ segments: spellEdgeWhitespace(emission.value.segments) })
|
||||||
}
|
}
|
||||||
|
|
||||||
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> {
|
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<LineAttempt> {
|
||||||
const assembled = assembleInlineLine(segments, container)
|
const verdict = lineVerdict(segments, container, flavour)
|
||||||
if (assembled.openingLinkAsDirective) return success({ fallback: 'opening-link' })
|
if (verdict.kind === 'opening-link') return success({ fallback: 'opening-link' })
|
||||||
if (assembled.unspellableRun !== undefined) return success({ fallback: assembled.unspellableRun })
|
if (verdict.kind === 'unspellable-run') return success({ fallback: verdict.runs[0] })
|
||||||
for (const [index, single] of assembled.line.split('\n').entries()) {
|
if (verdict.kind === 'claimed-line') return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(verdict.text)}`, path)
|
||||||
if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) {
|
return success({ line: verdict.text })
|
||||||
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`, path)
|
}
|
||||||
}
|
|
||||||
}
|
// The fallbacks in the order a line takes them, or the line where it takes none.
|
||||||
return success({ line: assembled.line })
|
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.
|
// spec/flavour.md, Inline nodes.
|
||||||
function carryStrippedWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
|
function spellEdgeWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
|
||||||
const carried: InlineSegment[] = []
|
const spelled: InlineSegment[] = []
|
||||||
for (const [index, segment] of segments.entries()) {
|
for (const [index, segment] of segments.entries()) {
|
||||||
const previous = segments[index - 1]
|
const previous = segments[index - 1]
|
||||||
const next = segments[index + 1]
|
const next = segments[index + 1]
|
||||||
const leading = previous === undefined || previous.text.includes('\n')
|
const leading = previous === undefined || previous.text.includes('\n')
|
||||||
const trailing = next === undefined || next.text.includes('\n')
|
const trailing = next === undefined || next.text.includes('\n')
|
||||||
carried.push(...carryEdges(segment, leading, trailing))
|
spelled.push(...edgeWhitespaceSegments(segment, leading, trailing))
|
||||||
}
|
}
|
||||||
return carried
|
return spelled
|
||||||
}
|
}
|
||||||
|
|
||||||
function carryEdges(segment: InlineSegment, leading: boolean, trailing: boolean): InlineSegment[] {
|
function edgeWhitespaceSegments(segment: InlineSegment, leading: boolean, trailing: boolean): InlineSegment[] {
|
||||||
if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment]
|
if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment]
|
||||||
const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : ''
|
const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : ''
|
||||||
const body = segment.text.slice(head.length)
|
const body = segment.text.slice(head.length)
|
||||||
const middle = trailing ? trimTrailingSpace(body) : body
|
const middle = trailing ? trimTrailingSpace(body) : body
|
||||||
const tail = body.slice(middle.length)
|
const tail = body.slice(middle.length)
|
||||||
const edges: InlineSegment[] = []
|
const edges: InlineSegment[] = []
|
||||||
if (head !== '') edges.push(carriedText(head))
|
if (head !== '') edges.push(textDirectiveSegment(head))
|
||||||
if (middle !== '') edges.push({ escaping: segment.escaping, text: middle })
|
if (middle !== '') edges.push({ escaping: segment.escaping, text: middle })
|
||||||
if (tail !== '') edges.push(carriedText(tail))
|
if (tail !== '') edges.push(textDirectiveSegment(tail))
|
||||||
return edges
|
return edges
|
||||||
}
|
}
|
||||||
|
|
||||||
function carriedText(text: string): InlineSegment {
|
function textDirectiveSegment(text: string): InlineSegment {
|
||||||
return syntax(spellTextDirective(text))
|
return syntax(spellTextDirective(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +186,7 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c
|
|||||||
const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
|
const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
|
||||||
const segments: InlineSegment[] = []
|
const segments: InlineSegment[] = []
|
||||||
for (const [offset, run] of runs.entries()) {
|
for (const [offset, run] of runs.entries()) {
|
||||||
|
if (takesTextBreak(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling))
|
||||||
const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 }
|
const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 }
|
||||||
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
|
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
|
||||||
if (!emitted.ok) return emitted
|
if (!emitted.ok) return emitted
|
||||||
@@ -184,19 +207,25 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const previous = runs[runs.length - 1]
|
const previous = runs[runs.length - 1]
|
||||||
if (previous?.kind === 'marked' && sameMark(previous.mark, mark)) previous.nodes.push(node)
|
if (previous?.kind === 'marked' && identicalMark(previous.mark, mark)) previous.nodes.push(node)
|
||||||
else runs.push({ index, kind: 'marked', mark, nodes: [node] })
|
else runs.push({ index, kind: 'marked', mark, nodes: [node] })
|
||||||
}
|
}
|
||||||
return runs
|
return runs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean {
|
||||||
|
if (previous?.kind !== 'plain' || run.kind !== 'plain') return false
|
||||||
|
if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false
|
||||||
|
return joinsWhenRead(previous.node, run.node)
|
||||||
|
}
|
||||||
|
|
||||||
function nodePath(context: InlineContext, index: number): ConvertErrorPath {
|
function nodePath(context: InlineContext, index: number): ConvertErrorPath {
|
||||||
return [...context.path, 'content', index]
|
return [...context.path, 'content', index]
|
||||||
}
|
}
|
||||||
|
|
||||||
function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
|
function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
|
||||||
if (carried.has(index)) return true
|
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> {
|
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> {
|
||||||
@@ -208,27 +237,27 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
|
|||||||
}
|
}
|
||||||
const types = nodeMarks(node).map((mark) => mark.type)
|
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)
|
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)
|
const model = inlineNodeModel(node.type)
|
||||||
if (directive === undefined) return emitText(node, context, index, path)
|
if (model === undefined) return emitText(node, context, index, path)
|
||||||
if (node.type === 'hardBreak') return emitHardBreak(node, directive, context, index, path)
|
if (node.type === 'hardBreak') return emitHardBreak(node, model, context, index, path)
|
||||||
return emitInlineDirective(node, directive, 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)
|
const empty = refuseContentAndText(node, path)
|
||||||
if (!empty.ok) return empty
|
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 === undefined) return success({ carry: { first: index, last: index } })
|
||||||
if (attributes === '' && context.spansLines && !context.atBlockEnd) return success({ segments: [syntax('\\\n')] })
|
if (attributes === '' && context.spansLines && !context.atBlockEnd) return success({ segments: [syntax('\\\n')] })
|
||||||
return success({ segments: [syntax(spellInlineLeafDirective('hardBreak', attributes))] })
|
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)
|
const empty = refuseContentAndText(node, path)
|
||||||
if (!empty.ok) return empty
|
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 === 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 (slot === undefined) return success({ segments: [syntax(spellInlineLeafDirective(node.type, attributes))] })
|
||||||
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
|
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
|
||||||
const spans = slotLineEndingFault(node.type, slot)
|
const spans = slotLineEndingFault(node.type, slot)
|
||||||
@@ -238,27 +267,33 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n
|
|||||||
return success({ segments: [syntax(spellInlineDirectiveOpener(node.type)), ...content, syntax(`]${attributes}`)] })
|
return success({ segments: [syntax(spellInlineDirectiveOpener(node.type)), ...content, syntax(`]${attributes}`)] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function textHoldingContent(node: AdfNode, path: ConvertErrorPath): Result<never> | undefined {
|
||||||
|
return node.type === 'text' && nodeContent(node).length > 0 ? failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
|
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
|
||||||
if (Object.keys(nodeAttrs(node)).length > 0) return success({ carry: { first: index, last: index } })
|
const holding = textHoldingContent(node, path)
|
||||||
|
if (holding !== undefined) return holding
|
||||||
|
if (!isBareText(node)) return success({ carry: { first: index, last: index } })
|
||||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
||||||
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
|
|
||||||
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
|
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
|
||||||
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
||||||
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
|
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
|
||||||
const parts = node.text.split(/(\n+)/).filter((part) => part !== '')
|
const parts = node.text.split(/(\n+)/).filter((part) => part !== '')
|
||||||
return success({ segments: parts.map((part) => (part.startsWith('\n') ? carriedText(part) : { escaping, text: part })) })
|
return success({ segments: parts.map((part) => (part.startsWith('\n') ? textDirectiveSegment(part) : { escaping, text: part })) })
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
|
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
|
||||||
const path = nodePath(context, index)
|
const path = nodePath(context, index)
|
||||||
const range: NodeRange = { first: index, last: index + nodes.length - 1 }
|
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)
|
const spelling = markSpelling(mark.type)
|
||||||
if (spelling === undefined) return success({ carry: range })
|
if (spelling === undefined) return success({ carry: range })
|
||||||
const attributes = spellMarkAttributes(mark, spelling.attributes)
|
const attributes = spellMarkAttributes(mark, spelling)
|
||||||
if (attributes === undefined) return success({ carry: range })
|
if (attributes === undefined) return success({ carry: range })
|
||||||
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
|
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
|
||||||
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
|
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
|
if (link !== undefined) return link
|
||||||
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
|
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
@@ -270,29 +305,40 @@ function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number
|
|||||||
const inner = emitRun(nodes, depth + 1, range.first, context)
|
const inner = emitRun(nodes, depth + 1, range.first, context)
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
if (inner.value.carry !== undefined) return inner
|
if (inner.value.carry !== undefined) return inner
|
||||||
const carried = carryStrippedWhitespace(inner.value.segments)
|
const spelled = spellEdgeWhitespace(inner.value.segments)
|
||||||
return success({
|
return success({
|
||||||
segments: [
|
segments: [
|
||||||
{ emphasis: 'open', escaping: 'none', nodes: range, text: spelling },
|
{ emphasis: 'open', escaping: 'none', nodes: { ...range, depth }, text: spelling },
|
||||||
...carried,
|
...spelled,
|
||||||
{ 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 }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each node its own span: CommonMark reads two adjacent text nodes in one span back as one.
|
||||||
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
|
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
|
||||||
let text = ''
|
const spans: string[] = []
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.type !== 'text' || nodeMarks(node).length !== depth + 1) return success({ carry: range })
|
const holding = textHoldingContent(node, path)
|
||||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
if (holding !== undefined) return holding
|
||||||
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
|
if (!isBareText(node) || nodeMarks(node).length !== depth + 1) return success({ carry: range })
|
||||||
text += node.text
|
const { text } = node
|
||||||
|
if (typeof text !== 'string' || text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
||||||
|
if (/[\n\r]/.test(text)) return success({ carry: range })
|
||||||
|
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
|
||||||
|
const fence = '`'.repeat(longestBacktickRun(text) + 1)
|
||||||
|
spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`)
|
||||||
}
|
}
|
||||||
if (/[\n\r]/.test(text)) return success({ carry: range })
|
return success({ segments: [syntax(spans.join(textBreakSpelling))] })
|
||||||
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
|
|
||||||
const fence = '`'.repeat(longestBacktickRun(text) + 1)
|
|
||||||
const padded = needsPadding(text) ? ` ${text} ` : text
|
|
||||||
return success({ segments: [syntax(`${fence}${padded}${fence}`)] })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function needsPadding(text: string): boolean {
|
function needsPadding(text: string): boolean {
|
||||||
@@ -300,8 +346,7 @@ function needsPadding(text: string): boolean {
|
|||||||
return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text)
|
return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// `undefined` where the link takes the directive form the caller spells.
|
function tryLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result<Emission> | undefined {
|
||||||
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result<Emission> | undefined {
|
|
||||||
const href = linkHref(nodeAttrs(mark))
|
const href = linkHref(nodeAttrs(mark))
|
||||||
if (href === undefined) return success({ carry: range })
|
if (href === undefined) return success({ carry: range })
|
||||||
const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective
|
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 { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark/grammar.ts'
|
||||||
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
|
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||||
import { claimsDirectivePrefix } from '../directive-syntax.ts'
|
import { claimsDirectivePrefix } from '../directive-syntax.ts'
|
||||||
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.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 { isBareDelimiterRow } from '../pipe-table-syntax.ts'
|
||||||
import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
|
import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
|
||||||
import { readEntityReference } from '../commonmark/entity-references.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 InlineEscaping = 'backslash' | 'bracketed' | 'bracketed-link-target' | 'none'
|
||||||
|
|
||||||
export type NodeRange = { first: number; last: number }
|
export type NodeRange = { first: number; last: number }
|
||||||
|
|
||||||
export type InlineSegment =
|
export type MarkRun = NodeRange & { depth: number }
|
||||||
| { 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 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 }
|
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 EmittedDelimiter = { closes: boolean; offset: number; pair: number; width: number }
|
||||||
|
|
||||||
type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; delimiters: EmittedDelimiter[]; length: number; start: number }
|
type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; delimiters: EmittedDelimiter[]; length: number; start: number }
|
||||||
@@ -31,8 +35,8 @@ const delimiters = ['*', '_', '`', '~']
|
|||||||
|
|
||||||
const followsLinkText = /[([]/
|
const followsLinkText = /[([]/
|
||||||
|
|
||||||
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
|
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): AssembledLine {
|
||||||
return escape(resolveEmphasis(segments), container)
|
return escape(resolveEmphasis(segments), container, flavour === 'plain')
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
|
function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
|
||||||
@@ -64,11 +68,11 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
|
|||||||
return resolved
|
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 scan = segments.map((segment) => segment.text).join('')
|
||||||
const escapings: InlineEscaping[] = []
|
const escapings: InlineEscaping[] = []
|
||||||
for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) escapings.push(segment.escaping)
|
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[] = []
|
const placements: number[] = []
|
||||||
let output = ''
|
let output = ''
|
||||||
for (let index = 0; index < scan.length; index += 1) {
|
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)
|
output += scan.charAt(index)
|
||||||
}
|
}
|
||||||
if (container === 'paragraph' && opensLinkDefinition(output)) {
|
if (container === 'paragraph' && opensLinkDefinition(output)) {
|
||||||
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRun: undefined }
|
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRuns: [] }
|
||||||
return { line: `\\${output}`, unspellableRun: unspellableRun(segments, output, placements) }
|
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 escaped = new Set<number>()
|
||||||
const linkClose = lastLinkClose(scan, escapings)
|
const linkClose = lastLinkClose(scan, escapings)
|
||||||
let line = scanLine(scan, 0)
|
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) {
|
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)
|
if (index > line.start + line.text.length) line = scanLine(scan, line.start + line.text.length + 1)
|
||||||
const escaping = escapings[index]
|
const escaping = escapings[index]
|
||||||
const escapable = escaping === 'backslash' || escaping === 'bracketed'
|
const escapable = escaping === 'backslash' || escaping === 'bracketed'
|
||||||
if (
|
const opensEquals: boolean = highlights && !pairsEquals && scan.startsWith(highlightDelimiter, index)
|
||||||
|
const claimed: boolean =
|
||||||
(escapable &&
|
(escapable &&
|
||||||
(claimsLineStart(line, index, container) ||
|
((opensEquals && claimsHighlight(scan, index)) ||
|
||||||
|
claimsLineStart(line, index, container) ||
|
||||||
mergesWithSyntax(scan, escapings, index) ||
|
mergesWithSyntax(scan, escapings, index) ||
|
||||||
opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))) ||
|
opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, afterEscape))) ||
|
||||||
(escaping === 'bracketed-link-target' &&
|
(escaping === 'bracketed-link-target' &&
|
||||||
((scan.charAt(index) === '`' && opensCodeSpan(scan, index, escaped)) || claimsDirectivePrefix(scan, index)))
|
((scan.charAt(index) === '`' && opensCodeSpan(scan, index, afterEscape)) || claimsDirectivePrefix(scan, index)))
|
||||||
) {
|
if (claimed) escaped.add(index)
|
||||||
escaped.add(index)
|
afterEscape = claimed
|
||||||
}
|
pairsEquals = opensEquals && !claimed
|
||||||
}
|
}
|
||||||
escapeClosedRuns(scan, escapings, escaped)
|
|
||||||
return 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.
|
// 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>()
|
const formed = new Set<number>()
|
||||||
let end = scan.length - 1
|
let end = scan.length - 1
|
||||||
while (end >= 0) {
|
while (end >= 0) {
|
||||||
@@ -119,23 +134,41 @@ function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], es
|
|||||||
while (scan.charAt(start - 1) === '`') start -= 1
|
while (scan.charAt(start - 1) === '`') start -= 1
|
||||||
let segmentEnd = end
|
let segmentEnd = end
|
||||||
for (let index = end; index > start; index -= 1) {
|
for (let index = end; index > start; index -= 1) {
|
||||||
if (!escaped.has(index)) continue
|
if (!claimed.has(index)) continue
|
||||||
formed.add(segmentEnd - index + 1)
|
formed.add(segmentEnd - index + 1)
|
||||||
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)) {
|
else if (escapings[start] !== 'none' && formed.has(end - start + 1)) {
|
||||||
for (let index = start; index <= end; index += 1) escaped.add(index)
|
for (let index = start; index <= end; index += 1) escaped.add(index)
|
||||||
formed.add(1)
|
formed.add(1)
|
||||||
}
|
}
|
||||||
end = start - 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 { nodes, runs } = emittedRuns(segments, placements, output)
|
||||||
const pair = misflanked(runs) ?? unpaired(runs)
|
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 {
|
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)
|
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 runs: EmittedRun[] = []
|
||||||
const nodes: NodeRange[] = []
|
const nodes: MarkRun[] = []
|
||||||
const open: number[] = []
|
const open: number[] = []
|
||||||
let cursor = 0
|
let cursor = 0
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
@@ -232,10 +265,10 @@ function opensConstruct(
|
|||||||
index: number,
|
index: number,
|
||||||
inBrackets: boolean,
|
inBrackets: boolean,
|
||||||
container: LineContainer,
|
container: LineContainer,
|
||||||
escaped: ReadonlySet<number>,
|
afterEscape: boolean,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (container === 'heading' && closesHeading(scan, index)) return true
|
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.
|
// 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,
|
index: number,
|
||||||
inBrackets: boolean,
|
inBrackets: boolean,
|
||||||
container: LineContainer,
|
container: LineContainer,
|
||||||
escaped: ReadonlySet<number>,
|
afterEscape: boolean,
|
||||||
): boolean {
|
): boolean {
|
||||||
const character = scan.charAt(index)
|
const character = scan.charAt(index)
|
||||||
if (inBrackets && (character === '[' || character === ']')) return true
|
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 opensBracketedAutolink(scan, index) || opensEmailAutolink(scan, index) || inlineHtmlConstruct(scan, index) !== undefined
|
||||||
if (character === '!') return claimsDirectivePrefix(scan, index)
|
if (character === '!') return claimsDirectivePrefix(scan, index)
|
||||||
if (character === '[') return index < linkClose
|
if (character === '[') return index < linkClose
|
||||||
if (character === '`') return opensCodeSpan(scan, index, escaped)
|
if (character === '`') return opensCodeSpan(scan, index, afterEscape)
|
||||||
if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, escaped)
|
if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, afterEscape)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,16 +318,16 @@ function lastLinkClose(scan: string, escapings: readonly (InlineEscaping | undef
|
|||||||
return -1
|
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.
|
// 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 (afterEscape && scan.charAt(index - 1) === '`') return true
|
||||||
if (!startsRun(scan, index, escaped)) return false
|
if (!startsRun(scan, index, afterEscape)) return false
|
||||||
const opener = backtickRun(scan, index)
|
const opener = backtickRun(scan, index)
|
||||||
return closingBacktickRun(scan, index + opener, opener) !== undefined
|
return closingBacktickRun(scan, index + opener, opener) !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function claimsEmphasis(scan: string, index: number, escaped: ReadonlySet<number>): boolean {
|
function claimsEmphasis(scan: string, index: number, afterEscape: boolean): boolean {
|
||||||
if (!startsRun(scan, index, escaped)) return false
|
if (!startsRun(scan, index, afterEscape)) return false
|
||||||
const character = scan.charAt(index)
|
const character = scan.charAt(index)
|
||||||
const length = runLength(scan, index)
|
const length = runLength(scan, index)
|
||||||
if (character === '~' && length !== 2) return false
|
if (character === '~' && length !== 2) return false
|
||||||
@@ -302,8 +335,8 @@ function claimsEmphasis(scan: string, index: number, escaped: ReadonlySet<number
|
|||||||
return flags.canClose || flags.canOpen
|
return flags.canClose || flags.canOpen
|
||||||
}
|
}
|
||||||
|
|
||||||
function startsRun(scan: string, index: number, escaped: ReadonlySet<number>): boolean {
|
function startsRun(scan: string, index: number, afterEscape: boolean): boolean {
|
||||||
if (index === 0 || escaped.has(index - 1)) return true
|
if (index === 0 || afterEscape) return true
|
||||||
return scan.charAt(index - 1) !== scan.charAt(index)
|
return scan.charAt(index - 1) !== scan.charAt(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user