Compare commits
201 Commits
d1ab7f713e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f689a51b0 | |||
| ddc5dfa1be | |||
| 6c589ecabc | |||
| da63faa4a4 | |||
| d0f0873ca9 | |||
| 058a5fd2f8 | |||
| e84cd47f08 | |||
| 7adab8e19e | |||
| dff4983d4a | |||
| d9bacc4072 | |||
| 58a7bf91b0 | |||
| 91adec5797 | |||
| 2252232fd6 | |||
| 4d3231c86b | |||
| 95af770e0a | |||
| 361139a4d4 | |||
| dccd8dcf3a | |||
| cd2b573372 | |||
| 94eaee6bae | |||
| e949f2099c | |||
| 32df104649 | |||
| 1df58cf33e | |||
| e17e247351 | |||
| 5f3705c430 | |||
| 7b344334b6 | |||
| 8d9d1ecb5b | |||
| 3f2f63aac0 | |||
| 8cecf27757 | |||
| 1f7d11ea3e | |||
| 050e52296a | |||
| bd8d240712 | |||
| 3edc12aaa9 | |||
| c81bda9bfb | |||
| 160c2a7052 | |||
| e2a660ee8e | |||
| f105bc9a57 | |||
| 6c499127b5 | |||
| 151bf38398 | |||
| a4b6b48635 | |||
| 1a266d5661 | |||
| 9bdbcd880e | |||
| c3e86f8430 | |||
| 2395cf077e | |||
| 95ff9ee910 | |||
| 6681bec391 | |||
| 6c8ed03d36 | |||
| 43869a7219 | |||
| 33f3bf7ee4 | |||
| 1d369ec5c5 | |||
| 52cf681a1c | |||
| 06103ea2ba | |||
| 7c15b02258 | |||
| 692433b712 | |||
| b9e2842310 | |||
| 75c5917712 | |||
| daaa2134b3 | |||
| af7658a863 | |||
| 13b04e46d3 | |||
| 474e6a2991 | |||
| 23804a5b3a | |||
| 8d2d2405f3 | |||
| 4443ea57af | |||
| 09d2e54f5a | |||
| 2701e165c4 | |||
| 2a3c90d63b | |||
| bbfc8724fc | |||
| fa6df33987 | |||
| 8a898436ea | |||
| 621dc75f86 | |||
| d5dae87c9b | |||
| 348865398c | |||
| ff23145a5a | |||
| d2a3d74030 | |||
| 50f97513e8 | |||
| e5771bc60b | |||
| fb97285899 | |||
| c64eed9301 | |||
| d10da170b1 | |||
| c694896090 | |||
| becd12e294 | |||
| f855e1b348 | |||
| c6781aa9b3 | |||
| 740a7b1e59 | |||
| a74e8742ff | |||
| 11e55732e7 | |||
| 8e09cd2214 | |||
| 5d5952c576 | |||
| 1271365b38 | |||
| 1f4a94974a | |||
| 326352fa98 | |||
| 9e2c0fda15 | |||
| f594dafc5b | |||
| 613dc0edbb | |||
| 2f03e20549 | |||
| ae2dc6054b | |||
| 7a53b6f7a0 | |||
| 1eb7b54f19 | |||
| 4df17055ef | |||
| 8cb85f3d27 | |||
| 969326b776 | |||
| b42218e655 | |||
| c1fed0885b | |||
| bf87e6ea18 | |||
| 53ff7e0204 | |||
| d1a208146f | |||
| 687ba9bf90 | |||
| 0094b361ab | |||
| dbd80b98c3 | |||
| 8572d76ee9 | |||
| 62ece3f9ac | |||
| 8f33f67f72 | |||
| 3a18627389 | |||
| dc11daa540 | |||
| 7b814e02dc | |||
| 5e55ce2a8e | |||
| cf9a737e71 | |||
| 1c0a6526b1 | |||
| 8418150cd3 | |||
| d35637c8fa | |||
| c46b8243ba | |||
| 69f116db1b | |||
| 1104c57c0e | |||
| c6acef081f | |||
| 59a37d1945 | |||
| 37abc1ddc1 | |||
| cb30512a90 | |||
| 3fce43e884 | |||
| 0644d1bc5d | |||
| b1dc6a1f45 | |||
| 0fd412e426 | |||
| 3d0f82cbd4 | |||
| 68942c9919 | |||
| d5370b126d | |||
| 15fb7fca81 | |||
| b90baa257c | |||
| d0088fc922 | |||
| 2b15530a57 | |||
| 132f9f7467 | |||
| 7be4f59f0b | |||
| d3a2129967 | |||
| 6715c97599 | |||
| 75791783d8 | |||
| 6944d505f1 | |||
| 754f1e3b33 | |||
| 2f7005590c | |||
| 67c3345fd2 | |||
| 7f290d220d | |||
| c2acee7a1d | |||
| e964abaa4b | |||
| 3d0926dcaf | |||
| 3ee1605ed3 | |||
| 0c8fd36ef8 | |||
| 39ea801fbe | |||
| c2bf5c0ea8 | |||
| eed1ac0290 | |||
| 77ae596d2e | |||
| f79241f56f | |||
| b969bb36d6 | |||
| 6efbac4598 | |||
| 4b2f3b1bb3 | |||
| 057729f830 | |||
| 8b5e05f5a9 | |||
| ee10b63fe7 | |||
| e34d39fdee | |||
| 5c47fb69ad | |||
| 61faf6f493 | |||
| 665c63af4b | |||
| 78b2924fe1 | |||
| 1fb712c76d | |||
| be25a7a3f5 | |||
| 6ffafe7536 | |||
| 00e18d66b5 | |||
| cd3bdf0d5d | |||
| d6756ea5e6 | |||
| 3f8afcb9d7 | |||
| 4ab1ed5132 | |||
| 7d1463da0d | |||
| 4a80e38f19 | |||
| 20cd21789b | |||
| 8961cbd20a | |||
| a2928a8789 | |||
| 9d5a8e4137 | |||
| 6e9e38776f | |||
| 3a22ff388d | |||
| b695c4c88c | |||
| 88e5213326 | |||
| 451a617b13 | |||
| ed099a093d | |||
| 5b78e608f0 | |||
| f303a815f7 | |||
| 42ab63a03b | |||
| 128e55e448 | |||
| 39039081ee | |||
| dfc91a651b | |||
| 0361646e1a | |||
| 047ce3bc42 | |||
| a774be59f4 | |||
| 7b726ef6ec | |||
| 503efa4f82 | |||
| 538e0ce665 | |||
| 4147f6623c |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"categories": { "correctness": "off" },
|
||||||
|
"ignorePatterns": ["src/**/*.test.ts", "src/conformance/property-harness.ts"],
|
||||||
|
"rules": {
|
||||||
|
"eslint/max-lines-per-function": ["error", { "IIFEs": true, "max": 52, "skipBlankLines": false, "skipComments": false }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,391 +1,182 @@
|
|||||||
# 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 editor-normal
|
||||||
|
- Unknown nodes ride the carry
|
||||||
|
- Foreign HTML sorts three ways
|
||||||
|
- Names stay text
|
||||||
|
- Directives under `!adf:`
|
||||||
|
- CommonMark is a subset
|
||||||
|
- Tables
|
||||||
|
- Links
|
||||||
|
- Ids stay site-local
|
||||||
|
- Plain task ids come from position
|
||||||
|
- A callout title keeps its link targets
|
||||||
|
- The plain flavour's spellings
|
||||||
|
- The HTML dialect
|
||||||
|
- No runtime dependencies
|
||||||
|
- Standards ship as data
|
||||||
|
- fast-check
|
||||||
|
- Any ES2022 engine
|
||||||
|
- ESM only
|
||||||
|
- One built entrypoint
|
||||||
|
- Public on npm
|
||||||
|
- The formats are API
|
||||||
|
- The code list
|
||||||
|
- Which code a cause takes
|
||||||
|
- `message` and `path`
|
||||||
|
- Publish on a version bump
|
||||||
|
- Docs describe the release being built
|
||||||
|
- No schema validation
|
||||||
|
- The gate runs on Deno and Bun
|
||||||
|
- The gate installs the tarball
|
||||||
|
- Firefox reads the build
|
||||||
|
- The coverage floors
|
||||||
|
- The size ratchet
|
||||||
|
- Properties on a fixed seed
|
||||||
|
- The CommonMark suite checks three ways
|
||||||
|
- The flavour spec is read as a source
|
||||||
|
- The node tables answer to Atlassian's schema
|
||||||
|
- Nothing recurses unbounded
|
||||||
|
- Nothing spreads an unbounded array
|
||||||
|
- A retry loop checks its own termination
|
||||||
|
- Readers scan by index
|
||||||
|
- The spelling memo
|
||||||
|
- Cost fixes are measured, never timed
|
||||||
|
- Only the hard break holds a raw newline
|
||||||
|
- Emphasis follows CommonMark's matching
|
||||||
|
- Readable spellings take the `try` prefix
|
||||||
|
- The attribute vocabulary is ADF's
|
||||||
|
- The source parts by ADF and format
|
||||||
|
|
||||||
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything
|
## 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).
|
|
||||||
- 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`: few, each earning its keep; they never reach a consumer. `fast-check` earns its
|
|
||||||
place shrinking a failing generated document to the nodes that break it.
|
|
||||||
|
|
||||||
## 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 milestone meeting a new failure cause
|
|
||||||
reuses a code where one fits; the list is complete at `0.1.0`. A code names the
|
|
||||||
cause; where one cause recurs across node types, across one mark's attributes or across
|
|
||||||
directions, one code covers them all and
|
|
||||||
`path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever
|
|
||||||
direction hits it, `unspellable-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,
|
||||||
coverage does not decline, 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,
|
Every leg announces its name and, where a container is in play, the image, before it runs and its
|
||||||
and neither extra leg is Node's proof twice. Deno refuses an extensionless or directory specifier,
|
elapsed time after, `publish.sh` alongside `ci.sh`, so a long run reads as progress rather than as
|
||||||
so it holds the module graph to the fully-spelled form a browser can load; Bun runs
|
a hang. A leg added later owes the same marker, and a function a leg reaches chains its statements
|
||||||
JavaScriptCore, the one engine of the three that is not V8, where the Unicode property escapes
|
with `&&`, because the `||` that captures the leg's status suspends `set -e` for everything it
|
||||||
emphasis matching leans on can disagree. Both refuse a run matching no test, so Node's is the only
|
calls. A leg whose output is both streamed and grepped keeps the copy in a `mktemp`
|
||||||
vacuous-green guard, and a test may reach only for what all three `node:` shims carry — the price
|
file: `tee /dev/stderr` reopens fd 2, and under `./ci.sh > log 2>&1` the two offsets punch NUL
|
||||||
of proving those engines over the corpus rather than over a smoke import.
|
holes through each other's lines.
|
||||||
|
|
||||||
The gate then packs the build and installs the tarball under `package-tests/`, so `files`,
|
`PROPERTY_RUNS=<runs>` raises the property runs and randomizes the seed for local digging. The
|
||||||
`exports` and `types` are proved on the artifact that ships rather than on the source tree a
|
generators and run parameters properties share live in `src/conformance/property-harness.ts`,
|
||||||
self-reference would resolve against. `consumer.ts` typechecks the emitted `.d.ts` from outside
|
outside the build and coverage.
|
||||||
`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`
|
Each `- ` bullet in `spec/flavour.md`'s `## Block nodes`, `## Inline nodes` and `## Marks` declares
|
||||||
over HTTP and converts the round-trip, normalization and error fixtures and the real payloads —
|
the nodes named before its first em dash, with the attributes following `Attributes: ` — a
|
||||||
the `commonmark-spec` sort is the Node suite's to check — which is §6's browser half and the only
|
parenthesized value set reading `string`; fenced examples are skipped. Keep prose out of a bullet.
|
||||||
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.
|
|
||||||
|
|
||||||
The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and
|
## 4. Code rules
|
||||||
functions, and a branch floor that only ever moves upward. It sits below 100 because the guards
|
|
||||||
`noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index
|
|
||||||
compared against `undefined` — have a half no valid document reaches.
|
|
||||||
|
|
||||||
The corpus, all checked in: hand-built fixtures per node and combination; real ADF Atlassian's
|
- Two-space indent, English everywhere. Alphabetical order wherever order carries no meaning,
|
||||||
editor wrote; the CommonMark spec suite against `markdownToAdf` and `markdownToHtml`.
|
keyed on the name a line introduces: an import sorts on its first binding, type imports ahead of
|
||||||
|
value imports, so moving or renaming a module reorders nothing.
|
||||||
Beside the corpus, properties run over documents generated from the node tables and over generated
|
|
||||||
markdown, on a fixed seed in the gate; `PROPERTY_RUNS=<runs>` raises the runs and randomizes the
|
|
||||||
seed for local digging, and a counterexample found becomes a round-trip fixture. The generators and
|
|
||||||
run parameters properties share live in `src/property-harness.ts`, outside the build and coverage.
|
|
||||||
|
|
||||||
`spec/flavour.md` is read as a source too, so the node tables cannot drift from the prose they
|
|
||||||
copy: each `- ` bullet in `## Block nodes`, `## Inline nodes` and `## Marks` declares the nodes
|
|
||||||
named before its first em dash, with the attributes following `Attributes: ` — a parenthesized
|
|
||||||
value set reading `string` — and must equal the tables in `adf/`. Keep prose in those sections out
|
|
||||||
of a bullet; fenced examples are skipped. It guards the attributes alone: nodes that differ in
|
|
||||||
content model share a bullet, and the argument attribute is spelled ahead of `Attributes: `, so
|
|
||||||
both answer to the round-trip corpus and to nothing else where a node has no fixture.
|
|
||||||
|
|
||||||
The tables answer to Atlassian's schema too (§5): for every node and mark they spell, the attribute
|
|
||||||
names and kinds equal what `full.json` and `stage-0.json` hold between them. Value sets stay
|
|
||||||
documentation, since any value round-trips. What the schema holds and the tables do not spell is
|
|
||||||
pinned by name — an attribute as a gap, a type as carried — so a re-pin adding either goes red until
|
|
||||||
someone spells it or pins it.
|
|
||||||
|
|
||||||
## 11. Code rules
|
|
||||||
|
|
||||||
- Two-space indent, strict TypeScript, English everywhere. Alphabetical order wherever order
|
|
||||||
carries no meaning.
|
|
||||||
- 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 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).
|
|
||||||
- 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 and imports no format. Each format directory (`markdown/`,
|
- Failures are values: everything returns `Result<T>`, nothing throws. `try/catch` only wrapped
|
||||||
`html/`) parts into `emit/` (ADF→format) and `parse/` (format→ADF), its root holding what both
|
tightly around a call that genuinely throws, converted to a result on the spot. A reader with no
|
||||||
directions read. A construct's reader lives in that root beside the regex the emitter escapes
|
path to name returns `Read<T>`, and the walk attaches the path where it knows it.
|
||||||
against, so the two cannot drift; a reader with no emit counterpart goes in `parse/`, unless it is
|
|
||||||
part of a construct the root already holds — a grammar stays in one file rather than splitting
|
|
||||||
across the seam. A rule both
|
|
||||||
directions must answer alike — whether a list marker interrupts a paragraph — is one function
|
|
||||||
there too, never a copy per direction, however conservative the copy would be. Where the rule is
|
|
||||||
the emitter's own choice, input consults it rather than restating it: the parser 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`.
|
|
||||||
- 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.
|
||||||
- Every prose comment in a diff is a review question; the default answer is delete.
|
|
||||||
- A doc paragraph says what the repo cannot say for itself, or it goes. The fix for a redundant
|
- 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
|
Per chunk:
|
||||||
figure is promised. A CLI is a later goal (`todo.md`), not a non-goal.
|
|
||||||
|
|
||||||
## 15. The working loop
|
1. Fresh worktree off updated `origin/main`; implement tests-first (§3).
|
||||||
|
|
||||||
One unchecked `todo.md` item per session, in the smallest PR-able chunk — split a big milestone
|
|
||||||
into sub-items in `todo.md` before starting it. A chunk running a little over or under that is not
|
|
||||||
worth deliberating; what matters is that nothing is left undone in the end. Per chunk:
|
|
||||||
|
|
||||||
1. Fresh worktree off updated `origin/main`; implement tests-first (§10).
|
|
||||||
2. Run the larv-review flow until it passes and CI is green. A reviewer launch states the latest
|
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. The next chunk gets a fresh session.
|
|
||||||
|
|
||||||
Ask, don't guess: any choice where what the maintainer would pick is not near-certain gets asked,
|
Reserved for the maintainer whatever any rule here says: changing `version` in `package.json` (a
|
||||||
and the answer lands as a decision in this file. The confidence bar is very high — asking too
|
bump on `main` publishes, `docs/decisions.md` §Publish on a version bump — every release is the
|
||||||
often is the accepted cost, guessing wrong is not.
|
maintainer's) and the `NPM_TOKEN` secret.
|
||||||
|
|
||||||
Reserved for the maintainer, never the agent: changing `version` in `package.json` (a bump on
|
### Ask, don't guess
|
||||||
`main` publishes, §9 — every release is the maintainer's) and the `NPM_TOKEN` secret.
|
|
||||||
|
|
||||||
A continuous loop session (`/loop`) counts as a chain of sessions: one chunk per iteration, each
|
Any choice where what the maintainer would pick is not near-certain gets asked. The confidence bar
|
||||||
iteration starting by re-reading `AGENTS.md` and `todo.md` and trusting them over anything
|
is very high — asking too often is the accepted cost, guessing wrong is not.
|
||||||
remembered from earlier iterations. The loop session is a thin driver: each chunk's work runs in
|
|
||||||
a fresh-context subagent holding this file as its charter, and the driver only relays maintainer
|
An ask is a gap in `docs/decisions.md`, and its answer is the entry that closes it, landing there —
|
||||||
questions, runs the review flow, merges, and cleans up. The loop stops when only
|
never the instance alone; an answer that is a goal lands in the README, one that is a working rule
|
||||||
maintainer-reserved acts remain.
|
here. Before asking, name the class the question belongs to and the entries of that class; where
|
||||||
|
one already decides it, apply it without asking, and where it reads two ways on this input, that
|
||||||
|
reading is the ask. Never ask "A or B?": state the gap, the earlier entries of its class, the
|
||||||
|
nearest text, a candidate entry in that file's voice, and the instance it yields. An entry that
|
||||||
|
keeps collecting instances is wrong: rewrite it.
|
||||||
|
|
||||||
|
Which output the audience expects — README goal 5 — is settled by a reader panel rather than
|
||||||
|
asked: three fresh-context readers, one per README persona the conversion serves, each given only
|
||||||
|
`## Audience` and the input, writing what they expect before picking among outputs the goals
|
||||||
|
allow, rendered, shuffled, with no rationale and nothing saying what is implemented. Three agreeing
|
||||||
|
settle it; otherwise four more read, five of seven settle it, and less is a missing goal, asked.
|
||||||
|
The verdict lands in `docs/decisions.md`.
|
||||||
|
|
||||||
|
### Stated numbers
|
||||||
|
|
||||||
|
A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks, naming
|
||||||
|
the number it can reach. A number the code needs and no entry states is a gap.
|
||||||
|
|
||||||
|
### The continuous loop
|
||||||
|
|
||||||
|
A `/loop` session counts as a chain of sessions, each iteration starting by re-reading `AGENTS.md`
|
||||||
|
and `todo.md` and trusting them over anything remembered from earlier iterations. The loop session
|
||||||
|
is a thin driver: each chunk's work runs in a fresh-context subagent holding this file as its
|
||||||
|
charter, and the driver only relays maintainer questions, runs the review flow, merges, and cleans
|
||||||
|
up. The loop stops when only maintainer-reserved acts remain.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- **Breaking:** directives, the opaque carry among them (now `carry`), are spelled under an `!adf:`
|
||||||
|
prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`, `!adf:name arg {attrs}`) in place
|
||||||
|
of the `:::`/`::`/`:name` forms: text holding an unescaped `!adf:` is claimed, and `adf` is an
|
||||||
|
ordinary code block language. Convert stored markdown per `MIGRATION.md`.
|
||||||
|
- **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no
|
||||||
|
CommonMark escape spells is written as `!adf:link[text]{attrs}`.
|
||||||
|
- **Breaking:** some directive refusals carry `malformed-directive` where they carried
|
||||||
|
`unsupported-node-shape`, and an empty node's leaf and closed spellings swap which one parses;
|
||||||
|
`MIGRATION.md` lists each.
|
||||||
|
- **Breaking:** a link whose text holds another link keeps the inner link and leaves the outer
|
||||||
|
brackets literal text, where `0.1.0` split the outer link around it; see `MIGRATION.md`.
|
||||||
|
- Add `adfToPlainMarkdown` and `plainMarkdownToAdf`, a lossy pair converting ADF to and from
|
||||||
|
markdown GitHub, GitLab and Obsidian render: alerts, callouts, task lists, `==highlights==` and
|
||||||
|
pipe tables.
|
||||||
|
- Spell `rule`'s `color`, `style` and `weight`, `layoutSection`'s `columnRuleStyle` and a link's
|
||||||
|
`collection`, `id` and `occurrenceKey` directly where they rode the opaque carry.
|
||||||
|
- Fix an image inside another image's description: it flattens into the alt text, where it was
|
||||||
|
refused.
|
||||||
|
|
||||||
|
## 0.1.0
|
||||||
|
|
||||||
|
- First release: lossless conversion between ADF and an extended markdown flavour —
|
||||||
|
`adfToMarkdown`, `markdownToAdf` and `isAdfDocument`. Nothing throws, and every error carries a `code` from a
|
||||||
|
closed list.
|
||||||
+9
-1
@@ -6,7 +6,7 @@ Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling with
|
|||||||
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 spelling table. Stored ADF needs no change.
|
the tables below. Stored ADF needs no change.
|
||||||
|
|
||||||
### Convert markdown
|
### Convert markdown
|
||||||
|
|
||||||
@@ -47,6 +47,14 @@ function migrateMarkdown(stored: string) {
|
|||||||
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 `carry` fence are claimed instead.
|
||||||
|
|
||||||
|
### Readings
|
||||||
|
|
||||||
|
Markdown the spelling table leaves alone, which `0.2.0` reads as a different document.
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
### Error codes
|
### Error codes
|
||||||
|
|
||||||
`unspellable-link` leaves `ConvertErrorCode`: a `switch` naming it stops compiling, and the link
|
`unspellable-link` leaves `ConvertErrorCode`: a `switch` naming it stops compiling, and the link
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
@@ -19,6 +22,36 @@ one-directional and lossy. A consumer that shows a document and lets someone edi
|
|||||||
directions lossless — otherwise saving destroys the panels, mentions and attachments it could not
|
directions lossless — otherwise saving destroys the panels, mentions and attachments it could not
|
||||||
represent.
|
represent.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
The most useful ADF conversion library available, judged by these goals, in priority order:
|
||||||
|
|
||||||
|
1. **Lossless, and every call returns a result, never a throw.**
|
||||||
|
2. **ADF is the hub.**
|
||||||
|
3. **Each format reads and writes as its standard says.**
|
||||||
|
4. **Our markdown is CommonMark, extended only where CommonMark has no spelling.**
|
||||||
|
5. **No surprises: output reads and edits the way its audience expects.**
|
||||||
|
6. **Lossy conversion drops form, never content.**
|
||||||
|
7. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.**
|
||||||
|
8. **Fast, and linear in the document's size.**
|
||||||
|
9. **Easy to find, and clear at a glance what it does.**
|
||||||
|
|
||||||
|
## Audience
|
||||||
|
|
||||||
|
Application developers embedding the library, in four personas. All four rely on the guarantees
|
||||||
|
below and on an error's `code` being a closed list; none may rely on an error message's wording,
|
||||||
|
which is free text.
|
||||||
|
|
||||||
|
- **Viewer/editor app** — shows a document, lets a human edit, posts it back. Relies on the
|
||||||
|
round-trip holding for whatever the site's editor wrote, unknown node types included, and on a
|
||||||
|
refusal arriving before the save rather than after.
|
||||||
|
- **Bot posting content** — turns generated markdown into ADF. Relies on plain CommonMark being
|
||||||
|
valid input, so nothing upstream has to learn a flavour.
|
||||||
|
- **Export/indexing tool** — converts ADF to markdown or HTML in bulk. Relies on readable output
|
||||||
|
and on every refusal being deterministic, so a document that fails fails the same way next run.
|
||||||
|
- **LLM/agent pipeline** — hands documents to a model as markdown and writes the edits back.
|
||||||
|
Relies on the round-trip and on markdown a reader half-knowing the lossless flavour can still edit.
|
||||||
|
|
||||||
## The shape
|
## The shape
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -37,25 +70,84 @@ if (result.ok) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Pure functions, no I/O, no configuration. ADF is the hub: markdown↔HTML compose through it.
|
Serves Goals 1, 2 and 7. Pure functions, each taking a whole document and returning a whole
|
||||||
|
result; no I/O, no configuration. `markdownToHtml` and `htmlToMarkdown` convert through ADF:
|
||||||
|
they keep only what ADF holds, and refuse what `markdownToAdf` or `htmlToAdf` refuses.
|
||||||
|
|
||||||
```ts
|
```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
|
||||||
@@ -75,15 +167,15 @@ 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 a `carry` | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text |
|
||||||
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
|
| `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 markdown holds a raw HTML tag, comment or processing instruction | remove it or write it in the flavour — ADF holds no raw-HTML node, and the element mapping lands at `0.2.0` |
|
| `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, 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`:
|
||||||
|
|
||||||
@@ -102,26 +194,33 @@ 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 markdown writes as a directive a node or mark the flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body |
|
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body |
|
||||||
|
|
||||||
## The guarantees
|
## The guarantees
|
||||||
|
|
||||||
|
Serves Goals 1, 3 and 4.
|
||||||
|
|
||||||
- `markdownToAdf(adfToMarkdown(doc))` equals `doc` — unknown node types included, carried opaquely
|
- `markdownToAdf(adfToMarkdown(doc))` equals `doc` — unknown node types included, carried opaquely
|
||||||
(AGENTS.md §3).
|
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
|
||||||
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML below, with three
|
- Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from
|
||||||
carve-outs — literal text matching directive, pipe-table or strikethrough syntax is claimed
|
`0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
|
||||||
(escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as its own
|
name every exception.
|
||||||
title-less paragraph; mid-text and titled images are error results. Converting back yields the
|
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
|
||||||
library's canonical spelling, which round-trips byte-identically — where it converts back at
|
names, with three carve-outs — literal text matching directive, pipe-table or strikethrough
|
||||||
all: a parse succeeding is no promise of that, so keep the source until the way back succeeds.
|
syntax is claimed (escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as
|
||||||
|
its own title-less paragraph; mid-text and titled images are error results, save an image inside
|
||||||
|
another's description, which flattens into the alt text. Converting back yields the library's
|
||||||
|
canonical spelling, which round-trips byte-identically — where it converts back at all: a parse
|
||||||
|
succeeding is no promise of that, so keep the source until the way back succeeds.
|
||||||
``` ` `` ` ``` reads cleanly and then refuses.
|
``` ` `` ` ``` reads cleanly and then refuses.
|
||||||
- Three CommonMark spellings parse without an error and build a document the reference renders
|
- Four CommonMark spellings parse without an error and build a document the reference
|
||||||
differently: `[](/url)` and `[]()` stay literal text against CommonMark's empty link, a list
|
implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's
|
||||||
continuing past a marker change stays one list against CommonMark's two, and a shortcut
|
empty link, a list continuing past a marker change stays one list against CommonMark's two, a
|
||||||
reference matching its definition only under Unicode case folding stays unresolved. Each is
|
shortcut reference matching its definition only under Unicode case folding stays unresolved, and
|
||||||
pinned `pending` in `corpus/commonmark-spec/exceptions.json`.
|
a link whose text holds an autolink keeps the inner link and leaves the outer brackets literal
|
||||||
- Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a
|
text, which the spec requires and the reference itself breaks, nesting one `<a>` in the other.
|
||||||
processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.2.0`.
|
The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite
|
||||||
|
holds no example of the fourth.
|
||||||
- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
|
- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
|
||||||
a carriage return, or a paragraph line beginning with a code span whose backticks read back as a
|
a carriage return, or a paragraph line beginning with a code span whose backticks read back as a
|
||||||
fence. Show the refusal and keep the document read-only; saving markdown you could not produce
|
fence. Show the refusal and keep the document read-only; saving markdown you could not produce
|
||||||
@@ -131,26 +230,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.
|
||||||
|
- The emitted formats are semver surface
|
||||||
|
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#the-formats-are-api)).
|
||||||
- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides
|
- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides
|
||||||
`data-*` attributes. Foreign HTML maps a documented element set, an unmappable element is an
|
`data-*` attributes. Foreign HTML maps a documented element set, which markdown's raw HTML reads
|
||||||
error, and well-formed HTML only — no tag-soup recovery.
|
through as well, and a construct outside it is an error; well-formed HTML only — no tag-soup
|
||||||
|
recovery.
|
||||||
## Who it is for
|
|
||||||
|
|
||||||
Personas, never named consumers (AGENTS.md §7):
|
|
||||||
|
|
||||||
- **Viewer/editor app** — shows a document, lets a human edit, posts back. Losslessness above all.
|
|
||||||
- **Bot posting content** — converts generated markdown to ADF; needs the CommonMark promise.
|
|
||||||
- **Export/indexing tool** — bulk ADF→markdown/HTML; needs readable output.
|
|
||||||
- **LLM/agent pipeline** — documents to a model as markdown, edits back; needs the round-trip and
|
|
||||||
markdown legible to a reader that half-knows the flavour.
|
|
||||||
|
|
||||||
## 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,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync, readdirSync } from 'node:fs'
|
|
||||||
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 { toEditorNormal } from '../dist/adf/editor-normal.js'
|
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' }
|
||||||
|
|||||||
@@ -3,28 +3,31 @@ set -euo pipefail
|
|||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
source ./docker-runner.sh
|
source ./docker-runner.sh
|
||||||
|
|
||||||
in_image "$node_image" npm ci
|
leg "install ($node_image)" in_image "$node_image" npm ci
|
||||||
in_image "$node_image" npm run typecheck
|
leg "typecheck ($node_image)" in_image "$node_image" npm run typecheck
|
||||||
|
leg "size ratchet ($node_image)" in_image "$node_image" npm run size-ratchet
|
||||||
|
|
||||||
if ! test_output=$(in_image "$node_image" npm test 2>&1); then
|
test_log=$(mktemp)
|
||||||
printf '%s\n' "$test_output"
|
node_tests() {
|
||||||
exit 1
|
in_image "$node_image" npm test 2>&1 | tee "$test_log"
|
||||||
fi
|
}
|
||||||
printf '%s\n' "$test_output"
|
|
||||||
if printf '%s' "$test_output" | grep -q 'ℹ tests 0'; then
|
leg "tests ($node_image)" node_tests
|
||||||
|
if grep -q 'ℹ tests 0' "$test_log"; then
|
||||||
echo 'the gate ran zero tests — failing instead of a vacuous green'
|
echo 'the gate ran zero tests — failing instead of a vacuous green'
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
rm -f "$test_log"
|
||||||
|
|
||||||
in_image "$deno_image" deno test --allow-env=PROPERTY_RUNS --allow-read --no-check src/
|
leg "tests ($deno_image)" in_image "$deno_image" deno test --allow-env=PROPERTY_RUNS --allow-read --no-check src/
|
||||||
in_image "$bun_image" bun test src/
|
leg "tests ($bun_image)" in_image "$bun_image" bun test src/
|
||||||
|
|
||||||
in_image "$node_image" npm run build
|
leg "build ($node_image)" in_image "$node_image" npm run build
|
||||||
in_image "$node_image" sh -c 'set -e
|
leg "pack and install the tarball ($node_image)" in_image "$node_image" sh -c 'set -e
|
||||||
rm -rf package-tests/node_modules
|
rm -rf package-tests/node_modules
|
||||||
npm pack --pack-destination /tmp >/dev/null
|
npm pack --pack-destination /tmp
|
||||||
npm install --no-audit --no-fund --no-package-lock --no-save --offline --prefix package-tests /tmp/*.tgz >/dev/null'
|
npm install --no-audit --no-fund --no-package-lock --no-save --offline --prefix package-tests /tmp/*.tgz'
|
||||||
in_image "$node_image" npx tsc -p package-tests
|
leg "typecheck the consumer ($node_image)" in_image "$node_image" npx tsc -p package-tests
|
||||||
in_image "$floor_image" node package-tests/node-floor.js
|
leg "round-trip on the engines floor ($floor_image)" in_image "$floor_image" node package-tests/node-floor.js
|
||||||
|
|
||||||
with_firefox in_image "$node_image" node browser-tests/run.js
|
leg "browser ($firefox_image)" with_firefox in_image "$node_image" node browser-tests/run.js
|
||||||
|
|||||||
+6
-7
@@ -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,10 @@ One directory per contract kind, each landing with its milestone:
|
|||||||
is the suite; `refusals.json` pins each refusing example to its error `code`; `exceptions.json`
|
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 editor-normal (`docs/decisions.md` §Equality is editor-normal), two-space indent, keys
|
||||||
upstream machine-readable suite, byte-exact from
|
sorted. `spec.json` is the vendored, upstream machine-readable suite, byte-exact from
|
||||||
[spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John
|
[spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John
|
||||||
MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not
|
MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not
|
||||||
re-serialized by the corpus gate.
|
re-serialized by the corpus gate.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
See !adf:link[the docs]{title="Setup guide"} here.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
See !adf:link[<https://example.com/docs>]{collection=contentId-98237 href="/docs"} here.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "[",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "https://example.com/"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "https://example.com/",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "](/v)",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "[a",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "https://example.com/b"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "https://example.com/b",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "c](/v)",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "[",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"collection": "contentId-98237",
|
||||||
|
"href": "/docs"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "the docs",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "](/v)",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "[",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "/u"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "a",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "](/v)",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[<https://example.com/>](/v)
|
||||||
|
|
||||||
|
[a<https://example.com/b>c](/v)
|
||||||
|
|
||||||
|
[!adf:link[the docs]{collection=contentId-98237 href="/docs"}](/v)
|
||||||
|
|
||||||
|
[[a](/u)](/v)
|
||||||
+16
-1
@@ -1,3 +1,5 @@
|
|||||||
|
: "${EPOCHREALTIME:?the gate times its legs with EPOCHREALTIME — bash 5 or newer}"
|
||||||
|
|
||||||
bun_image=oven/bun:1.4.0-alpine
|
bun_image=oven/bun:1.4.0-alpine
|
||||||
deno_image=denoland/deno:2.9.6
|
deno_image=denoland/deno:2.9.6
|
||||||
firefox_image=selenium/standalone-firefox:153.0.4
|
firefox_image=selenium/standalone-firefox:153.0.4
|
||||||
@@ -10,9 +12,22 @@ in_image() {
|
|||||||
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${PROPERTY_RUNS+-e PROPERTY_RUNS} ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
|
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${PROPERTY_RUNS+-e PROPERTY_RUNS} ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Markers on stderr so a captured leg's value stays clean; leg_* because bash scopes local into the leg's own call.
|
||||||
|
leg() {
|
||||||
|
local leg_name=$1 leg_elapsed leg_started leg_status=0
|
||||||
|
shift
|
||||||
|
printf '\n\033[1;34m==> %s\033[0m\n' "$leg_name" >&2
|
||||||
|
# EPOCHREALTIME carries the locale's radix character, so keep the digits and read microseconds.
|
||||||
|
leg_started=${EPOCHREALTIME//[^0-9]/}
|
||||||
|
"$@" || leg_status=$?
|
||||||
|
leg_elapsed=$((${EPOCHREALTIME//[^0-9]/} - leg_started))
|
||||||
|
printf '\033[1;34m<== %s: %d.%ds\033[0m\n' "$leg_name" "$((leg_elapsed / 1000000))" "$((leg_elapsed % 1000000 / 100000))" >&2
|
||||||
|
return $leg_status
|
||||||
|
}
|
||||||
|
|
||||||
with_firefox() {
|
with_firefox() {
|
||||||
local container in_image_network status=0
|
local container in_image_network status=0
|
||||||
container=$(docker run -d --rm "$firefox_image")
|
container=$(docker run -d --rm "$firefox_image") || return $?
|
||||||
# The id is baked in: the trap fires after this function's locals are gone.
|
# The id is baked in: the trap fires after this function's locals are gone.
|
||||||
trap "docker rm -f $container >/dev/null 2>&1" EXIT
|
trap "docker rm -f $container >/dev/null 2>&1" EXIT
|
||||||
trap 'exit 130' INT
|
trap 'exit 130' INT
|
||||||
|
|||||||
@@ -0,0 +1,594 @@
|
|||||||
|
# Decisions
|
||||||
|
|
||||||
|
## Plain markdown is a flavour of the grammar
|
||||||
|
|
||||||
|
2026-09-27, the maintainer. Goal 2. Valid while the plain flavour's spellings are ones the markdown
|
||||||
|
grammar can read and write.
|
||||||
|
|
||||||
|
The lossy pair is the plain flavour: the markdown grammar's reader and writer with the flavour set,
|
||||||
|
its spellings — alerts, callouts, task markers, `==` — read and written there, so a marker line and
|
||||||
|
a backslash reach them intact; what the flavour cannot spell reduces ADF→ADF ahead of the writer.
|
||||||
|
|
||||||
|
## The round-trip is the product
|
||||||
|
|
||||||
|
2026-08-23, real payloads 2026-09-15, the maintainer. Goal 1. Valid while a consumer saves back
|
||||||
|
through the lossless pair.
|
||||||
|
|
||||||
|
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything
|
||||||
|
less silently destroys content an editor could not represent, in a document it did not author.
|
||||||
|
When losslessness and readability conflict, losslessness wins. Round-trip equality is a property
|
||||||
|
tested over a checked-in corpus (`corpus/README.md`), not a claim made in prose. Its real payloads
|
||||||
|
are invented content written in Atlassian's editor on the maintainer's test site, so none is
|
||||||
|
sanitized and a mention keeps the test user's real account id.
|
||||||
|
|
||||||
|
## Markdown in is a canonical fixpoint
|
||||||
|
|
||||||
|
2026-08-23, the maintainer. Goals 1 and 4. Valid while markdown input may be written by hand.
|
||||||
|
|
||||||
|
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
|
||||||
|
back yields the library's canonical spelling, and that spelling round-trips byte-identically —
|
||||||
|
where there is a way back. CommonMark spells some things the flavour has no escape for — a
|
||||||
|
paragraph opening with a code span whose backticks read back as a fence — so a parse succeeding
|
||||||
|
does not imply a spellable document; `corpus/commonmark-spec/exceptions.json` names those.
|
||||||
|
|
||||||
|
## Equality is editor-normal
|
||||||
|
|
||||||
|
2026-08-24, the maintainer. Goal 1. Valid while markdown cannot tell apart the ADF shapes this
|
||||||
|
merges.
|
||||||
|
|
||||||
|
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
|
||||||
|
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content
|
||||||
|
array the absent key — the only domain markdown can restore.
|
||||||
|
`todo.md` item 40 replaces this with deep equality (2026-09-28, the maintainer).
|
||||||
|
|
||||||
|
## Unknown nodes ride the carry
|
||||||
|
|
||||||
|
2026-08-23, extended to misplaced known nodes 2026-08-26, the maintainer. Goal 1. Valid while ADF
|
||||||
|
holds nodes, or node positions, this library does not spell.
|
||||||
|
|
||||||
|
An unknown ADF node is carried opaquely — raw JSON rides a dedicated syntax in both formats and
|
||||||
|
restores to a deep-equal node. The round-trip holds for documents newer than the library. So does
|
||||||
|
a known node no section spells where it stands: a markdown serializer spells a node by type without
|
||||||
|
checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`.
|
||||||
|
Where a container's own spelling cannot hold the child it has — a `bulletList` holding other than
|
||||||
|
`listItem`, a `codeBlock` other than text — the error result names that instead.
|
||||||
|
|
||||||
|
## Foreign HTML sorts three ways
|
||||||
|
|
||||||
|
2026-08-23, the sort 2026-09-20, the maintainer. Goals 1 and 4. Valid while ADF holds no node
|
||||||
|
for a bare container, a comment or a script. Lands with `todo.md` item 6.
|
||||||
|
|
||||||
|
Every foreign element `htmlToAdf` and `markdownToAdf` read sorts one of three ways, never a silent
|
||||||
|
drop of content:
|
||||||
|
|
||||||
|
- A container around document content that ADF has no node for unwraps to its children, its own
|
||||||
|
attributes dropped: `<div align="center">text</div>` keeps `text`, losing the alignment.
|
||||||
|
- Content ADF cannot hold is an error result naming it. A comment is one: a person wrote those
|
||||||
|
words, and neither of Atlassian's schemas holds them — `annotation`'s `inlineComment` carries an
|
||||||
|
id, `placeholder` is the editor's own hint, `extension` names a vendor app.
|
||||||
|
- What is not document content drops whole: `<script>` and `<style>`, their text with them.
|
||||||
|
|
||||||
|
`<details><summary>Title</summary>…</details>` is an `expand` titled by its summary, a
|
||||||
|
`nestedExpand` inside another; an empty one is refused, since `expand` requires content. A `style`
|
||||||
|
attribute is not read at `0.2.0`: the `textColor` and `backgroundColor` it could reach cost more
|
||||||
|
than they buy.
|
||||||
|
`plainMarkdownToAdf` reads through `markdownToAdf`'s parser, so it takes the same set.
|
||||||
|
|
||||||
|
## Names stay text
|
||||||
|
|
||||||
|
2026-08-23, the maintainer. Goal 7. Valid while resolving a name to an id needs I/O.
|
||||||
|
|
||||||
|
A bare `@name` or `:smile:` in typed text stays a text node. Only directives produce
|
||||||
|
mention/emoji/media nodes; resolving names to ids is the consumer's job.
|
||||||
|
|
||||||
|
## Directives under `!adf:`
|
||||||
|
|
||||||
|
2026-08-23, prefixed `!adf:` 2026-09-16, the maintainer. Goals 4 and 5. Valid while prose does not
|
||||||
|
write `!adf:`.
|
||||||
|
|
||||||
|
Directives are one grammar for everything markdown lacks, namespaced under `!adf:`:
|
||||||
|
`!adf:panel info` … `!adf:/panel` blocks, `!adf:mention[@Mikael]{id=5b10a2}` inline, `\!adf:` the
|
||||||
|
one escape. Not CommonMark's generic-directives proposal: its `:::` claims a form prose writes, and
|
||||||
|
its fence-length discipline ties a container's opener to its own body, where closing from the
|
||||||
|
opener nests by itself and leaf versus container falls out of the node's content model.
|
||||||
|
|
||||||
|
## CommonMark is a subset
|
||||||
|
|
||||||
|
2026-08-23, the maintainer. Goal 4. Valid while prose rarely writes the shapes the carve-outs claim.
|
||||||
|
|
||||||
|
Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a
|
||||||
|
directive, a pipe table or a `~~` pair is claimed — plus one image gap.
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
2026-08-23, the maintainer. Goal 5. Valid while a pipe table holds only one header row and inline
|
||||||
|
cells.
|
||||||
|
|
||||||
|
One header row plus plain inline cells → pipe table; anything richer → directive form.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
2026-09-13, nesting 2026-09-17, the maintainer. Goals 1 and 5. Valid while CommonMark's link
|
||||||
|
syntax is what readers edit.
|
||||||
|
|
||||||
|
`[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark spells the
|
||||||
|
mark; `!adf:link[text]{attrs}` where it does not — an attribute CommonMark cannot hold, an `href` or
|
||||||
|
`title` no canonical escape spells, a paragraph opening whose CommonMark spelling would read as a
|
||||||
|
link reference definition — and a directive link CommonMark could spell is refused. No link wraps a
|
||||||
|
link — the bracket form goes literal, the directive form refused — which is CommonMark's prose
|
||||||
|
where its reference implementation nests one `<a>` in another.
|
||||||
|
|
||||||
|
## Ids stay site-local
|
||||||
|
|
||||||
|
2026-08-23, the maintainer. Goal 1. Valid while ADF ids are minted per site.
|
||||||
|
|
||||||
|
Identity-bearing nodes carry their ids in attributes; a document is only portable within its site —
|
||||||
|
accepted.
|
||||||
|
|
||||||
|
## Plain task ids come from position
|
||||||
|
|
||||||
|
2026-09-26, spelling 2026-09-29, the maintainer. Goals 6 and 7. Valid while a site rejects a task
|
||||||
|
node with no `localId`.
|
||||||
|
|
||||||
|
`plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId`
|
||||||
|
in the editor's UUID v4 shape, hashed from the whole markdown and the node's order among those it
|
||||||
|
mints, skipping any id the document holds: the same markdown reads to the same ids every run,
|
||||||
|
different markdown to different ids. The same markdown pasted twice into one document repeats its
|
||||||
|
ids: determinism wins over that case. A node the carry restores stays deep-equal (§Unknown nodes ride the carry): its
|
||||||
|
ids are only skipped.
|
||||||
|
|
||||||
|
## A callout title keeps its link targets
|
||||||
|
|
||||||
|
2026-09-29, the maintainer. Goals 5 and 6. Valid while an expand's `title` is a string.
|
||||||
|
|
||||||
|
`plainMarkdownToAdf` writes a link in a folded callout's title as its text and its target in
|
||||||
|
parentheses: `> [!faq]- See [x](http://y)` reads to the title `See x (http://y)`. A link whose text
|
||||||
|
is its target, with or without `mailto:`, keeps its text alone: `<http://y>` titles `http://y`,
|
||||||
|
`<a@b.c>` `a@b.c` — three persona readers agreeing, 2026-09-30.
|
||||||
|
|
||||||
|
## The plain flavour's spellings
|
||||||
|
|
||||||
|
2026-09-14, panels 2026-09-25 and 2026-09-29, the maintainer. Goals 5 and 6. Valid while GitHub's
|
||||||
|
renderer is the one the audience's markdown is read in.
|
||||||
|
|
||||||
|
README §Plain markdown's rows come from a survey of GitHub, GitLab, Gitea, Obsidian, Pandoc,
|
||||||
|
MkDocs, Docusaurus, Typora, Joplin, Logseq, Bear, Notion, Azure DevOps and Discord, GitHub's
|
||||||
|
renderer confirming each shape. Reader panels settled `error` as an error panel, the `==` bounds
|
||||||
|
(3 of 3) and a Han, Hangul, kana, Thai, Lao, Khmer or Myanmar character on either side bounding a
|
||||||
|
delimiter, so `は==日本語==で` (3 of 3), `==한국어==에서만` and `iPhone==専用==` (6 of 7) highlight,
|
||||||
|
the external image's two forms (6 of 7), a rule opening a list item dropping and the omission
|
||||||
|
notes (3 of 3), and a list's numbering overflowing into bullets (3 of 3, 5 of 7).
|
||||||
|
An omission note reads as the converter's, never as the author's.
|
||||||
|
Reading takes other tools' spellings, since it reads their output and writes none of them.
|
||||||
|
Rejected: `~sub~` and `^sup^` (`~2~` is a strike on GitHub, so `subsup` drops), underline and colour
|
||||||
|
spellings, raw HTML (`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family,
|
||||||
|
footnotes, definition lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states
|
||||||
|
past `[x]`/`[ ]`, and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes.
|
||||||
|
|
||||||
|
## The HTML dialect
|
||||||
|
|
||||||
|
2026-08-23, the maintainer. Goals 5 and 7. Valid while HTML output is read by consumers styling it
|
||||||
|
themselves.
|
||||||
|
|
||||||
|
The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*`
|
||||||
|
for what HTML cannot express, text always escaped. No stylesheet ships.
|
||||||
|
|
||||||
|
## No runtime dependencies
|
||||||
|
|
||||||
|
2026-08-23, the maintainer. Goal 7. Valid while ~20 lines of own code, or a vendored table, do each
|
||||||
|
job a dependency would.
|
||||||
|
|
||||||
|
`dependencies` is empty. A runtime dependency enters only through an entry here stating why ~20
|
||||||
|
lines of own code cannot do the job, who maintains it, and what auditing it costs. So the CommonMark
|
||||||
|
and HTML parsers are written in this repo.
|
||||||
|
|
||||||
|
## Standards ship as data
|
||||||
|
|
||||||
|
2026-08-30, the CommonMark suite 2026-09-05 and ADF's schemas 2026-09-13, the maintainer. Goals 1,
|
||||||
|
4 and 7. Valid while each table is fixed data a dependency would only wrap.
|
||||||
|
|
||||||
|
A table a standard fixes is data rather than a dependency: HTML5's 2125 semicolon-terminated
|
||||||
|
character references ship packed in their own module, so entity decoding is complete without one.
|
||||||
|
The CommonMark spec suite is the same shape of data and ships vendored at `corpus/commonmark-spec/`
|
||||||
|
rather than as the `commonmark-spec` dev dependency — that package is CommonJS-only, and Renovate
|
||||||
|
auto-bumping a spec version would silently point the vendored exception list's example numbers at a
|
||||||
|
renumbered suite. A spec bump is a deliberate re-pin, exceptions re-derived by hand beside it.
|
||||||
|
Atlassian's ADF JSON Schemas ship vendored the same way, at `spec/adf-schema/`, rather than as the
|
||||||
|
`@atlaskit/adf-schema` dev dependency — CommonJS-only, some fifty packages with React among them,
|
||||||
|
and a release most days for Renovate to automerge — re-pinned by hand when a payload or a report
|
||||||
|
shows the need.
|
||||||
|
|
||||||
|
## fast-check
|
||||||
|
|
||||||
|
2026-09-14, the maintainer. Goal 1. Valid while a failing generated document needs shrinking by
|
||||||
|
hand otherwise.
|
||||||
|
|
||||||
|
`fast-check` earns its place as a devDependency shrinking a failing generated document to the
|
||||||
|
nodes that break it.
|
||||||
|
|
||||||
|
## Any ES2022 engine
|
||||||
|
|
||||||
|
2026-09-01, the maintainer. Goal 7. Valid while ES2022 is the floor browsers and servers share.
|
||||||
|
|
||||||
|
The library runs on any ES2022 engine, not only Node — a browser as readily as a server. The
|
||||||
|
shipped source is ECMAScript and nothing else: no host import, no host global, no DOM.
|
||||||
|
`tsconfig.build.json` is that gate, typechecking and emitting the shipped files alone, so
|
||||||
|
`node:fs`, `process` and an ES2024 method are compile errors here rather than a consumer's crash
|
||||||
|
there. The standard is the line, never an engine list: one implementing it in part — Hermes is the
|
||||||
|
live doubt, on the Unicode property escapes emphasis matching leans on and on lookbehind — is out
|
||||||
|
of scope rather than a bug. Node's test runner, the corpus reads and the build are the repo's own,
|
||||||
|
never the library's, and `engines.node` states the floor the shipped JavaScript needs — `>=18` —
|
||||||
|
never the higher one those repo-only tools want.
|
||||||
|
|
||||||
|
## ESM only
|
||||||
|
|
||||||
|
2026-08-23, the maintainer. Goal 7. Valid while the audience's toolchains all import ES modules.
|
||||||
|
|
||||||
|
No CommonJS build, no dual-package hazard.
|
||||||
|
|
||||||
|
## One built entrypoint
|
||||||
|
|
||||||
|
2026-08-23, the maintainer. Goal 7. Valid while Node refuses to type-strip under `node_modules`.
|
||||||
|
|
||||||
|
Built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint — Node refuses to
|
||||||
|
type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so it cannot serve
|
||||||
|
an npm consumer.
|
||||||
|
|
||||||
|
## Public on npm
|
||||||
|
|
||||||
|
2026-08-23, the name 2026-09-01, the maintainer. Goals 2 and 7. Valid while the package's source
|
||||||
|
stays public beside it.
|
||||||
|
|
||||||
|
Published to public npm as `@larvit/adf-codec`. Public source: the Gitea repo goes public,
|
||||||
|
LICENSE in place, before the first publish. A codec, since it converts both directions, and named
|
||||||
|
for the hub rather than the formats around it.
|
||||||
|
|
||||||
|
## The formats are API
|
||||||
|
|
||||||
|
2026-08-23, strict input 2026-09-01, content models 2026-09-16, the maintainer. Goal 1.
|
||||||
|
Valid while consumers store what the library emits.
|
||||||
|
|
||||||
|
The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing
|
||||||
|
differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR.
|
||||||
|
Pre-1.0, normal 0.x rules. A spelled node's content model is part of that contract — leaf or
|
||||||
|
container is the model, not the syntax — so giving a spelled node's model content it had not, or
|
||||||
|
taking it away, is MAJOR whatever ADF's own schema does. Input reads the canonical directive
|
||||||
|
spelling alone — spacing, key order, each value's spelling — since loosening it later is MINOR.
|
||||||
|
|
||||||
|
The error surface is a contract too; `README.md` §The errors states it to the consumer, and the
|
||||||
|
types in `src/result.ts` hold its shape.
|
||||||
|
|
||||||
|
## The code list
|
||||||
|
|
||||||
|
2026-08-25, the maintainer; dated below where a rule came later. Goal 1. Valid while a consumer
|
||||||
|
switches on `code` with no `default`.
|
||||||
|
|
||||||
|
- Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose
|
||||||
|
name reads true of it in both directions; where none does and a plain name exists, a new code —
|
||||||
|
in any 0.x minor, and after 1.0 only in a MAJOR (2026-09-18).
|
||||||
|
- A refusal whose cause is this library's own invariant rather than the input takes the existing
|
||||||
|
code nearest what the consumer sees — a document that does not convert is
|
||||||
|
`unsupported-node-shape` — since a code no input reaches is one no consumer can switch on
|
||||||
|
(2026-09-20).
|
||||||
|
- A refusal no spelling recovers from is a gap in the flavour rather than a code: give the flavour
|
||||||
|
the spelling and the code goes (`unspellable-link`, 2026-09-13). A cause the carry answers gets
|
||||||
|
no code: a mark no spelling writes rides the carry with its node.
|
||||||
|
|
||||||
|
## Which code a cause takes
|
||||||
|
|
||||||
|
2026-08-28, the maintainer; dated below where a rule came later. Goal 1. Valid while a consumer
|
||||||
|
handles one cause alike whichever node, attribute or direction raised it.
|
||||||
|
|
||||||
|
- A code names the cause; where one cause recurs across node types, across one mark's attributes
|
||||||
|
or across directions, one code covers them all and `path` and `message` say which —
|
||||||
|
`unsupported-nesting-depth` is the 500-level guard whichever direction hits it,
|
||||||
|
`unspellable-character` the text node and the code block alike. Where two codes stay apart, the
|
||||||
|
line between them is what they name: `unspellable-character` is a character CommonMark rewrites
|
||||||
|
wherever text holds it, `unspellable-whitespace` the newline no inline directive's content slot
|
||||||
|
spans, in either direction.
|
||||||
|
- A claim code names the spelling claimed, never the node that spelling would have built: a
|
||||||
|
malformed `!adf:table` is a `malformed-directive`, and an alignment colon a
|
||||||
|
`malformed-pipe-table` — the flavour's own delimiter row is `-` runs, so the grammar refuses the
|
||||||
|
colon rather than ADF's missing column model doing it. What the grammar itself refuses stays a
|
||||||
|
claim code, key order among it, and a leaf given a body is refused at its opener, as a container
|
||||||
|
missing its closer is (2026-09-16).
|
||||||
|
- A directive whose name reads back to no node is `unknown-directive-name` rather than a claim
|
||||||
|
code — the spelling is well formed, and telling that apart from a typo is what a consumer
|
||||||
|
switches on when a later MINOR gives the name meaning. A reserved name is a known name, so never
|
||||||
|
that code, and the two the flavour reserves part on form: a form the grammar does not have is a
|
||||||
|
claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place
|
||||||
|
is `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one
|
||||||
|
type (2026-09-01).
|
||||||
|
- A well-formed directive the node tables refuse — an attribute a node does not hold or spells
|
||||||
|
elsewhere, a value outside its kind or its canonical spelling, an argument, or a body of a shape
|
||||||
|
its content model does not take — is `unsupported-node-shape`, the emitter's code for the same
|
||||||
|
mismatch read the other way: one code across both directions for good, since the call site
|
||||||
|
knows which direction it called and parting them after `0.1.0` is MAJOR (2026-09-23).
|
||||||
|
- A non-finite number takes two codes: `unsupported-node-shape` parsing, `not-an-adf-document`
|
||||||
|
emitting — no document holds one, so no round-trip crosses them (2026-09-23).
|
||||||
|
|
||||||
|
## `message` and `path`
|
||||||
|
|
||||||
|
2026-09-03, the path 2026-09-23, the maintainer. Goals 1 and 4. Valid while a person fixing the
|
||||||
|
input reads `message`.
|
||||||
|
|
||||||
|
- A message names the violation, not the rule alone — a rule by itself states a truth the reader
|
||||||
|
must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose
|
||||||
|
it names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and
|
||||||
|
inline alike, `\|` for every pipe row.
|
||||||
|
- `not-an-adf-document` carries the document's own path throughout: seven of the guard's eight
|
||||||
|
branches read the document's own shape, and threading a path to the eighth — a malformed node
|
||||||
|
anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces. The message
|
||||||
|
names the violation instead.
|
||||||
|
|
||||||
|
## Publish on a version bump
|
||||||
|
|
||||||
|
2026-08-23, converging 2026-09-03, the maintainer. Goal 7. Valid while CI on `main` holds the npm
|
||||||
|
token.
|
||||||
|
|
||||||
|
`package.json` version on `main` is the source of truth. CI on `main`: tests green and the version
|
||||||
|
not yet on npm → publish and tag `vX.Y.Z`. No bump, no deploy. `publish.sh` is that job.
|
||||||
|
|
||||||
|
The publish and the tag each check their own end state — the version on npm, the tag on the
|
||||||
|
remote — so a run that dies between them converges on the next push
|
||||||
|
to `main` rather than leaving npm ahead of the tags. An unanswered registry reads the same as an
|
||||||
|
unpublished version, which npm's own duplicate rejection is what catches. The job rebuilds rather
|
||||||
|
than taking the gate's `dist`: the lockfile is committed, the image is patch-pinned and `tsc` is
|
||||||
|
deterministic, so the two builds agree, and promoting an artifact would make the release path
|
||||||
|
depend on a store that the gate would then have to keep.
|
||||||
|
|
||||||
|
## Docs describe the release being built
|
||||||
|
|
||||||
|
2026-09-16, the maintainer. Goal 7. Valid while a bump on `main` publishes.
|
||||||
|
|
||||||
|
Docs on `main` describe the release being built rather than the version npm holds, so they match it
|
||||||
|
the moment the bump publishes; add no interim note marking the gap.
|
||||||
|
|
||||||
|
## No schema validation
|
||||||
|
|
||||||
|
2026-08-23, the mark refusal 2026-08-25, the maintainer. Goal 1. Valid while the site a document
|
||||||
|
is saved to validates it.
|
||||||
|
|
||||||
|
No ADF schema validation or exported validator. A refusal that keeps the round-trip is not schema
|
||||||
|
validation, so the one a spelled node carrying the same mark type twice earns stays, and input
|
||||||
|
nesting a spelling inside its own kind (`*(*a*)*`) names that mark once.
|
||||||
|
|
||||||
|
## The gate runs on Deno and Bun
|
||||||
|
|
||||||
|
2026-09-01, Deno's reason 2026-09-28, the maintainer. Goals 3 and 7. Valid while the library claims
|
||||||
|
any ES2022 engine.
|
||||||
|
|
||||||
|
The gate runs the suite under Deno and Bun as well as Node. Bun runs JavaScriptCore, the one engine
|
||||||
|
of the three that is not V8, where the Unicode property escapes emphasis matching leans on can
|
||||||
|
disagree. Deno shares Node's V8 and stays to prove the library runs there too, catching what the
|
||||||
|
two runtimes leave undocumented. Both refuse a run matching no test, so Node's is the only
|
||||||
|
vacuous-green guard, and `AGENTS.md` §3's `node:` shims rule is the price of proving those engines
|
||||||
|
over the corpus rather than over a smoke import.
|
||||||
|
|
||||||
|
## The gate installs the tarball
|
||||||
|
|
||||||
|
2026-09-03, the maintainer. Goal 7. Valid while consumers install the packed package.
|
||||||
|
|
||||||
|
The gate packs the build and installs the tarball under `package-tests/`, so `files`, `exports`
|
||||||
|
and `types` are proved on the artifact that ships rather than on the source tree a self-reference
|
||||||
|
would resolve against. `consumer.ts` typechecks the emitted `.d.ts` from outside
|
||||||
|
`tsconfig.build.json` — declaration emit leaves the `.ts` specifiers
|
||||||
|
`rewriteRelativeImportExtensions` rewrites in the JavaScript, and this is what says a consumer's
|
||||||
|
resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` stays unproven.
|
||||||
|
`node-floor.js` round-trips the installed package under a Node pinned to `engines.node`'s floor.
|
||||||
|
|
||||||
|
## Firefox reads the build
|
||||||
|
|
||||||
|
2026-09-04, the maintainer. Goal 7. Valid while the library claims a browser and no other leg runs
|
||||||
|
SpiderMonkey.
|
||||||
|
|
||||||
|
A headless Firefox loads `dist/index.js` over HTTP and converts the round-trip, normalization and
|
||||||
|
error fixtures and the real payloads — the `commonmark-spec` sort is the Node suite's to check —
|
||||||
|
which is the browser half of §Any ES2022 engine and the only SpiderMonkey there is — the gate's
|
||||||
|
other engines are two V8s and a JavaScriptCore that is not Safari's. A WebDriver session is what
|
||||||
|
carries a verdict back out, the driver and the page's server sharing one network namespace so each
|
||||||
|
is the other's `127.0.0.1`; `--headless --screenshot` has no such channel, and loading
|
||||||
|
`dist/index.js` in a globals-stripped realm buys one by not running a browser. The leg re-checks
|
||||||
|
the conversions and nothing else — each fixture's emitted markdown, its parsed document, its error
|
||||||
|
code — leaving the corpus's pairing, uniqueness, source positions and byte-level equality to the
|
||||||
|
Node suite that owns them. `selenium/standalone-firefox` runs it over the smaller
|
||||||
|
`instrumentisto/geckodriver`: the leg is worth a current SpiderMonkey, and that image fell four
|
||||||
|
Firefox majors behind.
|
||||||
|
|
||||||
|
## The coverage floors
|
||||||
|
|
||||||
|
2026-08-24, the maintainer. Goal 1. Valid while `noUncheckedIndexedAccess` and ADF's optional keys
|
||||||
|
force guards with a half no valid document reaches.
|
||||||
|
|
||||||
|
The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and
|
||||||
|
functions, and a branch floor that only ever moves upward. It sits below 100 because the guards
|
||||||
|
`noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index
|
||||||
|
compared against `undefined` — have a half no valid document reaches.
|
||||||
|
|
||||||
|
## The size ratchet
|
||||||
|
|
||||||
|
2026-09-20, the maintainer. KISS, a technical principle. Valid while no measure picks out what
|
||||||
|
readers find hard better than a function's length.
|
||||||
|
|
||||||
|
`.oxlintrc.json`'s single rule, over the files `tsconfig.build.json` builds, is a per-function line
|
||||||
|
ceiling, set at that set's worst and moving only downward. It covers the built files alone, since
|
||||||
|
one ceiling over the tests too would have to be their worst, loosening the guard over the shipped
|
||||||
|
code. It guards against drift and never drives a refactor, so no cyclomatic rule and no second lint
|
||||||
|
rule join it: neither measure picked out what nine readers found hard (the comprehension panel,
|
||||||
|
2026-09-20). `oxlint` measures it since TypeScript 7 is a native compiler publishing no in-process
|
||||||
|
parser, only the `unstable/` AST surface an out-of-process handshake reaches. Three switches guard
|
||||||
|
a silent green: `IIFEs: true`, since oxlint exempts an IIFE otherwise; an explicit `-c`, so a
|
||||||
|
config gone missing fails the leg instead of falling back to oxlint's own defaults; and
|
||||||
|
`--deny-warnings`, since a rule from a category this config never names arrives as a warning it
|
||||||
|
exits 0 on.
|
||||||
|
|
||||||
|
## Properties on a fixed seed
|
||||||
|
|
||||||
|
2026-09-14, the maintainer. Goal 1. Valid while a red gate must reproduce.
|
||||||
|
|
||||||
|
Beside the corpus, properties run over documents generated from the node tables and over generated
|
||||||
|
markdown, on a fixed seed in the gate; a counterexample found becomes a round-trip fixture.
|
||||||
|
|
||||||
|
## The CommonMark suite checks three ways
|
||||||
|
|
||||||
|
2026-08-27, the maintainer. Goals 1 and 3. Valid while the suite's answers are HTML ADF cannot be
|
||||||
|
compared against.
|
||||||
|
|
||||||
|
Each example is a named error or markdown that parses and emits to itself byte for byte; its
|
||||||
|
reference HTML's text, tags stripped and entities decoded, equals the parsed document's; and its
|
||||||
|
elements count the marks and nodes they map to. The fixpoint alone passes a parser returning the
|
||||||
|
empty document, the text alone one dropping every emphasis. An exception is the maintainer's to
|
||||||
|
add, and valid CommonMark parsing to a document `adfToMarkdown` refuses where a spelling could exist
|
||||||
|
is a bug to fix, never an exception.
|
||||||
|
|
||||||
|
## The flavour spec is read as a source
|
||||||
|
|
||||||
|
2026-09-01, the maintainer. Goal 1. Valid while `spec/flavour.md` restates the node tables in
|
||||||
|
prose.
|
||||||
|
|
||||||
|
`spec/flavour.md` is read as a source, so the node tables cannot drift from the prose they copy:
|
||||||
|
its node and mark bullets must equal the tables in `adf/`. It guards the attributes alone: nodes
|
||||||
|
that differ in content model share a bullet, and the argument attribute is spelled outside the
|
||||||
|
bullet's attribute list, so both answer to the round-trip corpus and to nothing else where a node
|
||||||
|
has no fixture.
|
||||||
|
|
||||||
|
## The node tables answer to Atlassian's schema
|
||||||
|
|
||||||
|
2026-09-13, the maintainer. Goal 1. Valid while a site's editor writes what Atlassian's schema
|
||||||
|
holds.
|
||||||
|
|
||||||
|
For every node and mark the tables spell, the attribute names and kinds equal what `full.json` and
|
||||||
|
`stage-0.json` (§Standards ship as data) hold between them. Value sets stay documentation, since
|
||||||
|
any value round-trips. What the schema holds and the tables do not spell is pinned by name — an
|
||||||
|
attribute as a gap, a type as carried — so a re-pin adding either goes red until someone spells it
|
||||||
|
or pins it.
|
||||||
|
|
||||||
|
## Nothing recurses unbounded
|
||||||
|
|
||||||
|
2026-08-25, the directive form's count 2026-09-18, the maintainer. Goal 1. Valid while an engine's
|
||||||
|
stack overflows near 2000 frames.
|
||||||
|
|
||||||
|
The guards walk iteratively, and blocks, marks and JSON values — an attribute's and a carried
|
||||||
|
node's alike — are all held to 500 levels (`largestNesting`), so a deep document is a `Result`
|
||||||
|
rather than the stack overflow that waits near 2000. An attribute is counted from its value; a
|
||||||
|
spelling that nests it deeper — the block directive's `marks`, the carry — refuses in its own
|
||||||
|
format, as its parser does. A list giving way to the directive form refuses at zero headroom rather
|
||||||
|
than walking again; counting every list twice halved the list limit, counting the directive form
|
||||||
|
once doubled the parser's frames per level.
|
||||||
|
|
||||||
|
## Nothing spreads an unbounded array
|
||||||
|
|
||||||
|
2026-09-18, the maintainer. Goal 1. Valid while engines cap a call's arguments.
|
||||||
|
|
||||||
|
Nothing spreads an unbounded array into a call — a node's siblings, a code block's held lines, a
|
||||||
|
mark run's segments: the argument list caps near 125k and throws a `RangeError` where a `Result` is
|
||||||
|
owed. A walk pushes one at a time. A literal spread (`[...value]`) is not the same thing and is
|
||||||
|
fine.
|
||||||
|
|
||||||
|
## A retry loop checks its own termination
|
||||||
|
|
||||||
|
2026-09-20, the maintainer. Goal 1. Valid while a fallback can fail to spell what it is handed.
|
||||||
|
|
||||||
|
A loop retrying an input until a fallback spells it refuses the pass taking no fallback, so its
|
||||||
|
termination is the loop's own check.
|
||||||
|
|
||||||
|
## Readers scan by index
|
||||||
|
|
||||||
|
2026-08-30, the kept scan 2026-09-18, the maintainer. Goal 8. Valid while the pipeline persona
|
||||||
|
feeds documents nobody typed.
|
||||||
|
|
||||||
|
A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the line
|
||||||
|
before it reads, `indexOf` — never a fresh slice per character, and a per-character walk hoists the
|
||||||
|
scan that does not vary with the character: a megabyte through a quadratic walk is a minute rather
|
||||||
|
than a millisecond. A scan may keep what it read for a later walk of the same text, and the
|
||||||
|
fallback where it kept nothing must be the same reader over the same text at the same index, so the
|
||||||
|
two cannot disagree — which is what makes the kept value a memo rather than a second spelling.
|
||||||
|
|
||||||
|
## The spelling memo
|
||||||
|
|
||||||
|
2026-09-19, the maintainer. Goal 8. Valid while the `commonMarkSpelling` ask spells a node once
|
||||||
|
per level above it otherwise.
|
||||||
|
|
||||||
|
The parse and the plain reduction keep each node's readable spelling in a memo, so the
|
||||||
|
`commonMarkSpelling` ask stops spelling a node once per level above it. `text` and `spelling` carry
|
||||||
|
no depth and `headroom` is affine in it, so a read at or above the depth that filled the entry
|
||||||
|
rebases; a read below re-spells, because a hit skips the depth guards the walk it replaces runs and
|
||||||
|
an ordered list past the marker cap gives way, spending two emitter levels where the parser spent
|
||||||
|
one. Only what succeeded is kept, so no path minted at another position is ever read.
|
||||||
|
|
||||||
|
## Cost fixes are measured, never timed
|
||||||
|
|
||||||
|
2026-09-18, the maintainer and the stability-reviewer. Goal 8. Valid while Goal 8 promises growth
|
||||||
|
rather than a figure.
|
||||||
|
|
||||||
|
A cost fix that changes no behaviour lands on the suite staying green with no fixture output
|
||||||
|
changed, and a before-and-after figure in its PR; the gate times nothing. Measured and kept:
|
||||||
|
`adfDocumentFault`'s shape and depth walks stay two — the parting gives depth its own code — at
|
||||||
|
52 ms for a 9 MB document the emit takes 314 ms over; and `continuesContainer`'s re-scan per item
|
||||||
|
level stays, linear in the lines and bounded in depth by the 500-level guard.
|
||||||
|
|
||||||
|
## Only the hard break holds a raw newline
|
||||||
|
|
||||||
|
2026-08-26, the maintainer. Goal 1. Valid while the whitespace carry finds a line edge by its raw
|
||||||
|
newline.
|
||||||
|
|
||||||
|
Only the hard break's inline segment holds a raw newline — every other spelling escapes one or
|
||||||
|
refuses it — which is how the whitespace carry finds a line edge.
|
||||||
|
|
||||||
|
## Emphasis follows CommonMark's matching
|
||||||
|
|
||||||
|
2026-08-27, the maintainer. Goals 1 and 3. Valid while CommonMark's emphasis rules are the
|
||||||
|
reader's.
|
||||||
|
|
||||||
|
Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text
|
||||||
|
escapes wherever CommonMark could open or close with it, leaving the emitter's own delimiters the
|
||||||
|
only ones in play, and a pair that matching hands to another delimiter rides the carry instead.
|
||||||
|
|
||||||
|
## Readable spellings take the `try` prefix
|
||||||
|
|
||||||
|
2026-09-21, the maintainer. Goals 1 and 5. Valid while a readable spelling's refusal would cost a
|
||||||
|
document the general form spells.
|
||||||
|
|
||||||
|
A readable spelling tried ahead of a general one takes the `try` prefix and fails only where the
|
||||||
|
general form fails on the same node: refusing there refuses a document the general form spells, so a
|
||||||
|
refusal the general form does not share belongs in the general form or nowhere. A readable spelling
|
||||||
|
that must spell its subtree before it can give way — the list, whose thematic-break first line and
|
||||||
|
blank lines exist only spelled — hands that one walk to the general form instead: giving way after
|
||||||
|
the walk walks again at every level, doubling per level.
|
||||||
|
|
||||||
|
## The attribute vocabulary is ADF's
|
||||||
|
|
||||||
|
2026-08-27, the maintainer. Goal 2. Valid while every format spells the same ADF attributes.
|
||||||
|
|
||||||
|
`adf/` walks the attribute vocabulary and narrows each value to its kind, and a format spells the
|
||||||
|
narrowed value. A spelling that re-checks the type is the check's second copy. Reading a spelling
|
||||||
|
back is the format's own: the reader sits beside the spelling it inverts, so
|
||||||
|
decode-respell-compare cannot drift, and each format writes its own — canonical JSON for a number
|
||||||
|
is the markdown flavour's choice, not ADF's.
|
||||||
|
|
||||||
|
## The source parts by ADF and format
|
||||||
|
|
||||||
|
2026-08-27, placement 2026-09-18, `adf/`'s bar 2026-09-21, the maintainer. Goal 2. Valid while
|
||||||
|
each format has a reader and a writer through ADF.
|
||||||
|
|
||||||
|
`src/adf/` holds ADF's own knowledge, imports no format, and is where a construct both formats read
|
||||||
|
lives: the question is answered in ADF's vocabulary — a node type, an attribute kind, a content
|
||||||
|
model — and no delimiter, element name or escape reaches it. A helper that cannot answer that way
|
||||||
|
is two constructs, the ADF question there and the spelling in each format, the seam
|
||||||
|
`markAttributes` and `markSpellings` already draw; one that cannot be split is a gap to ask.
|
||||||
|
`markdown/` and `html/` are peers: neither imports the other, and no third directory sits between
|
||||||
|
them. A primitive knowing neither ADF nor a format stays at `src/` root. A construct rises to
|
||||||
|
`adf/` on its second consumer, not in anticipation of one. A directory follows a split
|
||||||
|
`spec/flavour.md` draws, and a placement nothing here settles goes beside its only reader, or in
|
||||||
|
what both read where there are two.
|
||||||
|
|
||||||
|
Each format directory parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest of it
|
||||||
|
holding what both directions read. A construct's reader lives there beside the regex the emitter
|
||||||
|
escapes against, so the two cannot drift; a reader with no emit counterpart goes in `parse/`,
|
||||||
|
unless it is part of a construct that side already holds — a grammar stays in one file rather than
|
||||||
|
splitting across the seam. A rule both directions must answer alike — whether a list marker
|
||||||
|
interrupts a paragraph — is one function there too, never a copy per direction, however
|
||||||
|
conservative the copy would be. Where the rule is the emitter's own choice, input consults it
|
||||||
|
rather than restating it, and that is the only import `parse/` takes from `emit/` —
|
||||||
|
`commonMarkSpelling` and `openingLinkTakesDirective` — so no fixture the emitter writes can be
|
||||||
|
refused, and a spelling the emitter refuses gives its own error rather than a second name for it.
|
||||||
Generated
+397
@@ -11,12 +11,360 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
"fast-check": "4.10.0",
|
"fast-check": "4.10.0",
|
||||||
|
"oxlint": "1.83.0",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-android-arm64": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-darwin-arm64": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-darwin-x64": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-freebsd-x64": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-linux-x64-musl": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-openharmony-arm64": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.3",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
@@ -390,6 +738,55 @@
|
|||||||
"node": ">=12.17.0"
|
"node": ">=12.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oxlint": {
|
||||||
|
"version": "1.83.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.83.0.tgz",
|
||||||
|
"integrity": "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"oxlint": "bin/oxlint"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/oxc-project"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@oxlint/binding-android-arm-eabi": "1.83.0",
|
||||||
|
"@oxlint/binding-android-arm64": "1.83.0",
|
||||||
|
"@oxlint/binding-darwin-arm64": "1.83.0",
|
||||||
|
"@oxlint/binding-darwin-x64": "1.83.0",
|
||||||
|
"@oxlint/binding-freebsd-x64": "1.83.0",
|
||||||
|
"@oxlint/binding-linux-arm-gnueabihf": "1.83.0",
|
||||||
|
"@oxlint/binding-linux-arm-musleabihf": "1.83.0",
|
||||||
|
"@oxlint/binding-linux-arm64-gnu": "1.83.0",
|
||||||
|
"@oxlint/binding-linux-arm64-musl": "1.83.0",
|
||||||
|
"@oxlint/binding-linux-ppc64-gnu": "1.83.0",
|
||||||
|
"@oxlint/binding-linux-riscv64-gnu": "1.83.0",
|
||||||
|
"@oxlint/binding-linux-riscv64-musl": "1.83.0",
|
||||||
|
"@oxlint/binding-linux-s390x-gnu": "1.83.0",
|
||||||
|
"@oxlint/binding-linux-x64-gnu": "1.83.0",
|
||||||
|
"@oxlint/binding-linux-x64-musl": "1.83.0",
|
||||||
|
"@oxlint/binding-openharmony-arm64": "1.83.0",
|
||||||
|
"@oxlint/binding-win32-arm64-msvc": "1.83.0",
|
||||||
|
"@oxlint/binding-win32-ia32-msvc": "1.83.0",
|
||||||
|
"@oxlint/binding-win32-x64-msvc": "1.83.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"oxlint-tsgolint": ">=7.0.2001",
|
||||||
|
"vite-plus": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"oxlint-tsgolint": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite-plus": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pure-rand": {
|
"node_modules/pure-rand": {
|
||||||
"version": "8.4.2",
|
"version": "8.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
+3
-1
@@ -23,12 +23,14 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"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\"",
|
"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/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": {
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
"fast-check": "4.10.0",
|
"fast-check": "4.10.0",
|
||||||
|
"oxlint": "1.83.0",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-15
@@ -3,35 +3,42 @@ set -euo pipefail
|
|||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
source ./docker-runner.sh
|
source ./docker-runner.sh
|
||||||
|
|
||||||
read_field() {
|
|
||||||
in_image "$node_image" npm pkg get "$1" | tr -d '"\r'
|
|
||||||
}
|
|
||||||
|
|
||||||
published_version() {
|
published_version() {
|
||||||
in_image "$node_image" npm view "$1@$2" version 2>/dev/null || true
|
in_image "$node_image" npm view "$1@$2" version 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
private=$(read_field private)
|
push_tag() {
|
||||||
|
git tag "v$version" && git push origin "v$version"
|
||||||
|
}
|
||||||
|
|
||||||
|
read_field() {
|
||||||
|
in_image "$node_image" npm pkg get "$1" | tr -d '"\r'
|
||||||
|
}
|
||||||
|
|
||||||
|
read_package_fields() {
|
||||||
|
private=$(read_field private) &&
|
||||||
|
name=$(read_field name) &&
|
||||||
|
version=$(read_field version)
|
||||||
|
}
|
||||||
|
|
||||||
|
leg "read package.json ($node_image)" read_package_fields
|
||||||
if [ "$private" = 'true' ]; then
|
if [ "$private" = 'true' ]; then
|
||||||
echo 'package.json is private — the maintainer removes that in the bump that first publishes'
|
echo 'package.json is private — the maintainer removes that in the bump that first publishes'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
name=$(read_field name)
|
published=$(leg "ask npmjs for $name@$version ($node_image)" published_version "$name" "$version")
|
||||||
version=$(read_field version)
|
tagged=$(leg "ask origin for v$version" git ls-remote --tags origin "v$version")
|
||||||
published=$(published_version "$name" "$version")
|
|
||||||
tagged=$(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}"
|
||||||
in_image "$node_image" npm ci
|
leg "install ($node_image)" in_image "$node_image" npm ci
|
||||||
in_image "$node_image" npm run build
|
leg "build ($node_image)" in_image "$node_image" npm run build
|
||||||
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e NPM_TOKEN -v "$PWD:/app" -w /app --entrypoint sh "$node_image" -c \
|
leg "publish $name@$version ($node_image)" \
|
||||||
|
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e NPM_TOKEN -v "$PWD:/app" -w /app --entrypoint sh "$node_image" -c \
|
||||||
'printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" > "$HOME/.npmrc" && npm publish --access public'
|
'printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" > "$HOME/.npmrc" && npm publish --access public'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$tagged" ]; then
|
if [ -z "$tagged" ]; then
|
||||||
git tag "v$version"
|
leg "tag v$version" push_tag
|
||||||
git push origin "v$version"
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
+59
-55
@@ -1,12 +1,12 @@
|
|||||||
# 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 three 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, and 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 one gap: a CommonMark
|
||||||
CommonMark image fits only as its own
|
image fits only as its own title-less paragraph — mid-text and titled images are named errors. The
|
||||||
title-less paragraph — mid-text and titled images are named errors. The emitted form is contract
|
emitted form is contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this
|
||||||
(AGENTS.md §8). Per-node syntaxes build on this grammar in the sections below.
|
grammar in the sections below.
|
||||||
|
|
||||||
## Canonical form
|
## Canonical form
|
||||||
|
|
||||||
@@ -67,22 +67,21 @@ normalizes to it through the round-trip.
|
|||||||
matching below, which is what lets the emitter decide its own pairings.
|
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 with no blocks is the empty string.
|
||||||
block's
|
|
||||||
content, single trailing newline; a document with no blocks is the empty string.
|
|
||||||
|
|
||||||
## 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. Two reserved names read back to no node: `carry` for the opaque carry,
|
||||||
info string, and `listBreak` for the leaf that parts two adjacent lists (Canonical form).
|
as both directive name and fence info string, and `listBreak` for the leaf that parts two adjacent
|
||||||
|
lists (Canonical form).
|
||||||
|
|
||||||
**Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name`
|
**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 +122,7 @@ Which of the two a node takes is its content model, never the spelling: a model
|
|||||||
written as an opener–closer pair and one taking none as a leaf, so a leaf given a body and a
|
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
|
||||||
@@ -154,15 +153,15 @@ outside code spans and code blocks, `\!adf:` in input yields the literal text.
|
|||||||
naming no open container or a node other than the innermost open one, a leaf given a body, an
|
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 `carry`, body = the node's JSON —
|
||||||
two-space indent, object keys sorted.
|
two-space indent, object keys sorted.
|
||||||
@@ -177,25 +176,27 @@ In block-directive position `!adf:carry` is a named error — the carry's block
|
|||||||
## Raw HTML in input
|
## 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. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty; editor-normal
|
||||||
editor-normal ADF reads an empty attrs object, marks array or content array as the absent key
|
ADF reads an empty attrs object, marks array or content array as the absent key (`docs/decisions.md`
|
||||||
(AGENTS.md §2) — the grammar's empty-`{attrs}` omission already collapses the two spellings.
|
§Equality is editor-normal) — the grammar's empty-`{attrs}` omission already collapses the two
|
||||||
|
spellings.
|
||||||
|
|
||||||
Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a
|
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: `!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`.
|
||||||
@@ -300,10 +301,10 @@ other text, or one carrying a title, is a named error: `mediaInline` carries a m
|
|||||||
### Tables
|
### 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.
|
||||||
|
|
||||||
@@ -449,14 +450,14 @@ Shipped !adf:emoji[🎉]{shortName=":tada:"} on !adf:date{timestamp=175608000000
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Whitespace CommonMark cannot hold.** A newline inside a text node, and a space or tab where
|
**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;
|
||||||
the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks and no
|
`markdownToAdf` merges adjacent text nodes carrying identical marks and no attributes
|
||||||
attributes (AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and
|
(`docs/decisions.md` §Equality is editor-normal). Input reads that spelling alone: the value is one
|
||||||
tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries plainly —
|
run of spaces and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark
|
||||||
is a named error.
|
carries plainly — is a named error.
|
||||||
|
|
||||||
```
|
```
|
||||||
!adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines.
|
!adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines.
|
||||||
@@ -473,7 +474,10 @@ the inline directive `!adf:link[text]{attrs}` only where CommonMark does not: an
|
|||||||
`href` and `title`, an `href` or `title` no canonical escape spells (a control character, a
|
`href` and `title`, an `href` or `title` no canonical escape spells (a control character, a
|
||||||
backslash, an entity reference, an angle bracket beside a space or opening a bare destination, a
|
backslash, an entity reference, an angle bracket beside a space or opening a bare destination, a
|
||||||
newline in the title), or a link opening a paragraph whose markdown spelling would read as a link
|
newline in the title), or a link opening a paragraph whose markdown spelling would read as a link
|
||||||
reference definition. A directive link CommonMark could spell is a named error.
|
reference definition. Every such spelling carries an `href`: a directive link CommonMark could
|
||||||
|
spell is a named error, and so is one spelling none. No link wraps a link at any nesting, which is
|
||||||
|
CommonMark's own rule: a `[text]` already holding one leaves the outer brackets literal text, and
|
||||||
|
the directive form, open to no literal reading, is a named error.
|
||||||
|
|
||||||
- `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3).
|
- `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3).
|
||||||
- `code`, `em`, `strike`, `strong` — Attributes: none.
|
- `code`, `em`, `strike`, `strong` — Attributes: none.
|
||||||
@@ -485,11 +489,11 @@ reference definition. A directive link CommonMark could spell 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 editor-normal restores the array, not a set — and opens each
|
||||||
restores the array, not a set — and opens each spelling once over the longest run of adjacent
|
spelling once over the longest run of adjacent inline nodes carrying an identical mark, attributes
|
||||||
inline nodes carrying an identical mark, attributes included, at that depth. A run breaks at every
|
included, at that depth. A run breaks at every node the emitter carries, so no emitted carry sits
|
||||||
node the emitter carries, so no emitted carry sits inside a mark spelling.
|
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
|
||||||
@@ -499,7 +503,7 @@ close where the run sits (`un**-real**istic`), or one CommonMark's matching pair
|
|||||||
intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the
|
intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the
|
||||||
merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a
|
merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a
|
||||||
mark spelling is a named error in input: the carry restores its node exactly, marks included
|
mark spelling is a named error in input: the carry restores its node exactly, marks included
|
||||||
(AGENTS.md §3).
|
(`docs/decisions.md` §Unknown nodes ride the carry).
|
||||||
|
|
||||||
```
|
```
|
||||||
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
|
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import fc from 'fast-check'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
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', () => {
|
||||||
|
|||||||
+5
-8
@@ -23,9 +23,6 @@ export type AdfDocument = {
|
|||||||
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,8 +44,8 @@ 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 carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
|
||||||
@@ -116,15 +113,15 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
|
|||||||
|
|
||||||
function marksFault(marks: readonly AdfMark[]): ConvertFault | undefined {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function mergesText(node: AdfNode): boolean {
|
|||||||
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
|
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameMarks(previous: AdfNode, node: AdfNode): boolean {
|
export function sameMarks(previous: AdfNode, node: AdfNode): boolean {
|
||||||
return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node))
|
return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import fc from 'fast-check'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import type { AdfNode } from '../adf/document.ts'
|
||||||
|
import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts'
|
||||||
|
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||||
|
import { adfToPlainMarkdown, reduceToPlain } from '../markdown/emit/plain-reduction.ts'
|
||||||
|
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
||||||
|
import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||||
|
import { toEditorNormal } from '../adf/editor-normal.ts'
|
||||||
|
|
||||||
|
const gateRuns = 1600
|
||||||
|
const renamedPrefix = '!adg:'
|
||||||
|
|
||||||
|
// Renaming the prefix changes what markdown reads only where a directive was read.
|
||||||
|
function readsNoDirective(markdown: string): boolean {
|
||||||
|
const read = markdownToAdf(markdown)
|
||||||
|
const renamed = markdownToAdf(markdown.replaceAll(directivePrefix, renamedPrefix))
|
||||||
|
return read.ok && renamed.ok && JSON.stringify(read.value).replaceAll(directivePrefix, renamedPrefix) === JSON.stringify(renamed.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each block's text, an expand's title and an image's alt and url, in document order: what the plain pair keeps.
|
||||||
|
function shownText(nodes: readonly AdfNode[]): string[] {
|
||||||
|
const shown: string[] = []
|
||||||
|
for (const node of nodes) {
|
||||||
|
const attrs = node.attrs ?? {}
|
||||||
|
if ((node.type === 'expand' || node.type === 'nestedExpand') && typeof attrs['title'] === 'string') shown.push(attrs['title'])
|
||||||
|
if (node.type === 'media') shown.push(`${JSON.stringify(attrs['alt'] ?? '')} ${JSON.stringify(attrs['url'])}`)
|
||||||
|
const content = node.content ?? []
|
||||||
|
if (content.some((child) => child.type === 'text' || child.type === 'hardBreak')) shown.push(content.map((child) => child.text ?? '\n').join(''))
|
||||||
|
else for (const text of shownText(content)) shown.push(text)
|
||||||
|
}
|
||||||
|
return shown.filter((text) => text !== '')
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(adfDocument, (document) => {
|
||||||
|
const emitted = adfToMarkdown(document)
|
||||||
|
if (!emitted.ok) return
|
||||||
|
const read = markdownToAdf(emitted.value)
|
||||||
|
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
|
||||||
|
assert.deepEqual(toEditorNormal(read.value), document, `reading ${JSON.stringify(emitted.value)}`)
|
||||||
|
}),
|
||||||
|
propertyRuns(gateRuns),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a generated document writes plain markdown refusing only what the guard refuses, and that markdown reads back to its text and to itself', { timeout: propertyTimeout }, () => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(adfDocument, (document) => {
|
||||||
|
const written = adfToPlainMarkdown(document)
|
||||||
|
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
|
||||||
|
assert.ok(readsNoDirective(written.value), `a directive in ${JSON.stringify(written.value)}`)
|
||||||
|
const read = plainMarkdownToAdf(written.value)
|
||||||
|
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(written.value)}`)
|
||||||
|
const reduced = reduceToPlain(document)
|
||||||
|
assert.deepEqual(shownText(read.value.content ?? []), reduced.ok ? shownText(reduced.value.content ?? []) : reduced, `reading ${JSON.stringify(written.value)}`)
|
||||||
|
assert.deepEqual(adfToPlainMarkdown(read.value), written, `reading ${JSON.stringify(written.value)}`)
|
||||||
|
}),
|
||||||
|
propertyRuns(gateRuns),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import test from 'node:test'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts'
|
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||||
import { blockDirectives } from './adf/block-directives.ts'
|
import { blockArgument } from '../markdown/block-directive.ts'
|
||||||
import { inlineDirectives } from './adf/inline-directives.ts'
|
import { blockNodes } from '../adf/block-nodes.ts'
|
||||||
import { markAttributes } from './adf/mark-attributes.ts'
|
import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||||
import { blockArgument } from './markdown/block-directive-arguments.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)),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { createHash } from 'node:crypto'
|
import { createHash } from 'node:crypto'
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import test from 'node:test'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
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',
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync, readdirSync } from 'node:fs'
|
|
||||||
import { basename, dirname, join, sep } from 'node:path'
|
import { basename, dirname, join, sep } from 'node:path'
|
||||||
import test from 'node:test'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
import { 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'
|
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')
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import test from 'node:test'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts'
|
import 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)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import fc from 'fast-check'
|
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
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,17 @@ import {
|
|||||||
spellJsonAttribute,
|
spellJsonAttribute,
|
||||||
spellStringAttribute,
|
spellStringAttribute,
|
||||||
spellVocabulary,
|
spellVocabulary,
|
||||||
} from './markdown/directive-syntax.ts'
|
} from '../markdown/directive-syntax.ts'
|
||||||
import { fencedCodeBlock } from './markdown/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 { toEditorNormal } from '../adf/editor-normal.ts'
|
||||||
import { textDirectiveName } from './markdown/text-directive.ts'
|
import { vocabularyPairs } from '../adf/attribute-vocabulary.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 +48,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 +231,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 +326,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 {
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import fc from 'fast-check'
|
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { env } from 'node:process'
|
import { env } from 'node:process'
|
||||||
|
import fc from 'fast-check'
|
||||||
|
|
||||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
|
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../adf/document.ts'
|
||||||
import type { Arbitrary } from 'fast-check'
|
import type { 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 { inlineNodes } from '../adf/inline-nodes.ts'
|
||||||
import { markAttributes } from './adf/mark-attributes.ts'
|
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||||
import { toEditorNormal } from './adf/editor-normal.ts'
|
import { toEditorNormal } from '../adf/editor-normal.ts'
|
||||||
|
|
||||||
type Positions = { block: AdfNode; inline: AdfNode }
|
type Positions = { block: AdfNode; inline: AdfNode }
|
||||||
|
|
||||||
@@ -23,9 +23,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(
|
||||||
@@ -80,6 +80,7 @@ function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }):
|
|||||||
|
|
||||||
const mark: Arbitrary<AdfMark> = fc.oneof(
|
const mark: Arbitrary<AdfMark> = 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 })
|
||||||
@@ -94,8 +95,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 })),
|
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 }[] {
|
||||||
@@ -112,17 +113,17 @@ const positions = fc.letrec<Positions>((tie) => {
|
|||||||
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 = 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 = fc
|
||||||
.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType })
|
.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)
|
.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 = fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: fc.array(backtickRunNode, { maxLength: 4, minLength: 2 }), weight: 1 }).map((content): AdfNode => ({ content, type: 'paragraph' }))
|
||||||
const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type }))
|
const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type }))
|
||||||
@@ -135,8 +136,12 @@ const positions = fc.letrec<Positions>((tie) => {
|
|||||||
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[]>) =>
|
||||||
|
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' })),
|
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: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) })
|
||||||
@@ -148,7 +153,7 @@ const positions = fc.letrec<Positions>((tie) => {
|
|||||||
{ depthIdentifier, depthSize: 'small', maxDepth: 4 },
|
{ 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,8 +161,8 @@ 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 },
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
+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/emit/plain-reduction.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'
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import type { BlockType } from '../adf/block-directives.ts'
|
|
||||||
|
|
||||||
const argumentByType = new Map(
|
|
||||||
Object.entries({
|
|
||||||
blockTaskItem: 'state',
|
|
||||||
panel: 'panelType',
|
|
||||||
taskItem: 'state',
|
|
||||||
} satisfies Partial<Record<BlockType, string>>),
|
|
||||||
)
|
|
||||||
|
|
||||||
export function blockArgument(type: string): string | undefined {
|
|
||||||
return argumentByType.get(type)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { blockDirective } from '../adf/block-directives.ts'
|
|
||||||
import { listBreakName } from './list-break.ts'
|
|
||||||
|
|
||||||
export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined {
|
|
||||||
if (name === listBreakName) return 'leaf'
|
|
||||||
const directive = blockDirective(name)
|
|
||||||
if (directive === undefined) return undefined
|
|
||||||
return directive.contentModel === 'none' ? 'leaf' : 'container'
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,36 @@
|
|||||||
import type { AdfMark } from '../adf/document.ts'
|
import type { AdfMark } from '../adf/document.ts'
|
||||||
|
import type { BlockType } from '../adf/block-nodes.ts'
|
||||||
import type { JsonValue } from '../json-value.ts'
|
import type { JsonValue } from '../json-value.ts'
|
||||||
|
import { blockNodeModel } from '../adf/block-nodes.ts'
|
||||||
import { isAdfMark, nodeAttrs } from '../adf/document.ts'
|
import { isAdfMark, nodeAttrs } from '../adf/document.ts'
|
||||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||||
|
import { spellDirectiveOpener } from './directive-syntax.ts'
|
||||||
|
|
||||||
|
const argumentByType = new Map(
|
||||||
|
Object.entries({
|
||||||
|
blockTaskItem: 'state',
|
||||||
|
panel: 'panelType',
|
||||||
|
taskItem: 'state',
|
||||||
|
} satisfies Partial<Record<BlockType, string>>),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const listBreakName = 'listBreak'
|
||||||
|
|
||||||
|
export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
|
||||||
|
|
||||||
export const marksAttribute = 'marks'
|
export const marksAttribute = 'marks'
|
||||||
|
|
||||||
|
export function blockArgument(type: string): string | undefined {
|
||||||
|
return argumentByType.get(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined {
|
||||||
|
if (name === listBreakName) return 'leaf'
|
||||||
|
const model = blockNodeModel(name)
|
||||||
|
if (model === undefined) return undefined
|
||||||
|
return model.contentModel === 'none' ? 'leaf' : 'container'
|
||||||
|
}
|
||||||
|
|
||||||
export function markValues(marks: readonly AdfMark[]): JsonValue {
|
export function markValues(marks: readonly AdfMark[]): JsonValue {
|
||||||
return marks.map((mark) => {
|
return marks.map((mark) => {
|
||||||
const attrs = nodeAttrs(mark)
|
const attrs = nodeAttrs(mark)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { JsonValue } from '../json-value.ts'
|
import type { JsonValue } from '../json-value.ts'
|
||||||
import { carryName } from './opaque-carry.ts'
|
import { carryName } from './opaque-carry.ts'
|
||||||
import { holdsControlCharacter } from './commonmark-grammar.ts'
|
import { holdsControlCharacter } from './commonmark/grammar.ts'
|
||||||
import { holdsEntityReference } from './entity-references.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' }
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isUnicodeWhitespace } from './commonmark-grammar.ts'
|
import { isUnicodeWhitespace } from './grammar.ts'
|
||||||
|
|
||||||
type DelimiterRun = { canClose: boolean; canOpen: boolean; character: string; length: number }
|
type DelimiterRun = { canClose: boolean; canOpen: boolean; character: string; length: number }
|
||||||
|
|
||||||
@@ -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>()
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync } from 'node:fs'
|
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import test from 'node:test'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
import { readEntityReference } from './entity-references.ts'
|
import { readEntityReference } from './entity-references.ts'
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { backslashEscape, decodeTextEscapes, holdsControlCharacter } from './commonmark-grammar.ts'
|
import { backslashEscape, decodeTextEscapes, holdsControlCharacter } from './grammar.ts'
|
||||||
import { holdsEntityReference } from './entity-references.ts'
|
import { holdsEntityReference } from './entity-references.ts'
|
||||||
|
|
||||||
export type LinkDefinition = { destination: string; title?: string }
|
export type LinkDefinition = { destination: string; title?: string }
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts'
|
import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts'
|
||||||
import type { ConvertFault } from '../result.ts'
|
import type { ConvertFault } from '../result.ts'
|
||||||
import type { JsonValue } from '../json-value.ts'
|
import type { JsonValue } from '../json-value.ts'
|
||||||
import { backslashEscape } from './commonmark-grammar.ts'
|
import { backslashEscape } from './commonmark/grammar.ts'
|
||||||
import { backtickRun, closingBacktickRun } from './backtick-runs.ts'
|
import { backtickRun, closingBacktickRun } from './commonmark/backtick-runs.ts'
|
||||||
import { isJsonValue, overNested } from '../json-value.ts'
|
import { isJsonValue, overNested } from '../json-value.ts'
|
||||||
import { largestNesting } from '../nesting.ts'
|
import { largestNesting } from '../nesting.ts'
|
||||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||||
|
|||||||
@@ -353,8 +353,8 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
|
|||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth')
|
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]
|
||||||
@@ -375,11 +375,18 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
|
|||||||
assert.deepEqual(toEditorNormal(read.value), document(node))
|
assert.deepEqual(toEditorNormal(read.value), document(node))
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em', largestNesting - 3))
|
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em'))
|
||||||
|
assert.equal(
|
||||||
|
markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { deep: nested(largestNesting) }, type: 'em' }], text: 'x', type: 'text' })))),
|
||||||
|
`unsupported-nesting-depth: a carried node's JSON nests deeper than the ${largestNesting} levels its position leaves`,
|
||||||
|
)
|
||||||
assert.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper('data', 'inlineCard'))
|
assert.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))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 type { Flavour } from '../plain-conventions.ts'
|
||||||
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||||
import { blockDirective, blockDirectives } from '../../adf/block-directives.ts'
|
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain-conventions.ts'
|
||||||
import { blockDirectiveForm } from '../block-directive-forms.ts'
|
import { blockDirectiveForm, 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 '../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 } 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'
|
||||||
@@ -19,31 +20,39 @@ import { tryPipeTable } from './pipe-table.ts'
|
|||||||
type BlockContainer = 'directive' | 'document' | 'list-item'
|
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 PlacedBlock = EmittedBlock & { node: AdfNode }
|
type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
|
||||||
|
type PlacedBlock = Omit<EmittedBlock, 'headroom'> & { node: AdfNode }
|
||||||
|
// Keyed by reference: only a caller building one object per position (the parse, the plain reduction) passes one; a consumer's document may share a node.
|
||||||
|
export type SpellingMemo = Map<AdfNode, KeptSpelling>
|
||||||
type Walk = { blocks: readonly PlacedBlock[]; headroom: number }
|
type Walk = { blocks: readonly PlacedBlock[]; headroom: number }
|
||||||
type WalkedItem = { node: AdfNode; walk: Walk }
|
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)
|
const walk = walkBlocks(nodeContent(document), [], 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): Result<Walk> {
|
function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, writing: Writing): Result<Walk> {
|
||||||
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)
|
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 })
|
||||||
@@ -79,38 +88,120 @@ function separationBetween(previous: PlacedBlock, next: PlacedBlock, container:
|
|||||||
|
|
||||||
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): 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)
|
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))
|
return emitDirectiveBlock(node, model, path, depth, () => walkBlocks(nodeContent(node), path, depth + 1, writing))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number): Result<null> | undefined {
|
export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<null> | undefined {
|
||||||
const readable = readableBlock(node, path, depth)
|
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): Result<EmittedBlock> | undefined {
|
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||||
if (node.type === 'blockquote') return emitBlockquote(node, path, depth)
|
const { memo } = writing
|
||||||
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth)
|
const kept = memo?.get(node)
|
||||||
if (node.type === 'codeBlock') return emitCodeBlock(node, path)
|
if (kept !== undefined) {
|
||||||
if (node.type === 'heading') return emitHeading(node, path)
|
if (kept.block === undefined) return undefined
|
||||||
|
// A read below the fill would skip the depth guards the walk it replaces runs (docs/decisions.md §The spelling memo).
|
||||||
|
if (depth <= kept.depth) return success({ ...kept.block, headroom: kept.block.headroom + kept.depth - depth })
|
||||||
|
}
|
||||||
|
const spelled = spellReadableBlock(node, path, depth, writing)
|
||||||
|
if (spelled === undefined) memo?.set(node, { block: undefined, depth })
|
||||||
|
else if (spelled.ok) memo?.set(node, { block: spelled.value, depth })
|
||||||
|
return spelled
|
||||||
|
}
|
||||||
|
|
||||||
|
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||||
|
const plain = writing.flavour === 'plain' ? spellPlainBlock(node, path, depth, writing) : undefined
|
||||||
|
if (plain !== undefined) return plain
|
||||||
|
if (node.type === 'blockquote') return tryBlockquote(node, path, depth, writing)
|
||||||
|
if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, writing)
|
||||||
|
if (node.type === 'codeBlock') return tryCodeBlock(node, path)
|
||||||
|
if (node.type === 'heading') return tryHeading(node, path, writing.flavour)
|
||||||
if (node.type === 'mediaSingle') return readableText(tryImage(node, path))
|
if (node.type === '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<Walk> {
|
||||||
|
const block = emitBlock(node, path, depth, writing)
|
||||||
|
return block.ok ? success({ blocks: [{ ...block.value, node }], headroom: block.value.headroom }) : block
|
||||||
|
}
|
||||||
|
|
||||||
|
// The marker leads the first paragraph, or stands as one where the blocks open with another.
|
||||||
|
function taskBlocks(task: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<Walk> {
|
||||||
|
const marker = taskMarker(nodeAttrs(task)['state'])
|
||||||
|
const markerBlock: PlacedBlock = { node: { type: 'paragraph' }, spelling: 'commonmark', text: marker }
|
||||||
|
if (task.type === 'taskItem') {
|
||||||
|
const content = nodeContent(task)
|
||||||
|
const line = content.length === 0 ? success('') : emitInlineLine(content, 'paragraph', path, writing.flavour)
|
||||||
|
if (!line.ok) return line
|
||||||
|
return success({ blocks: [{ ...markerBlock, text: line.value === '' ? marker : `${marker} ${line.value}` }], headroom: Number.POSITIVE_INFINITY })
|
||||||
|
}
|
||||||
|
const walk = walkBlocks(nodeContent(task), path, depth, writing)
|
||||||
|
if (!walk.ok) return walk
|
||||||
|
const [first, ...rest] = walk.value.blocks
|
||||||
|
const blocks = first?.node.type === 'paragraph' ? [{ ...first, text: `${marker} ${first.text}` }, ...rest] : [markerBlock, ...walk.value.blocks]
|
||||||
|
return success({ blocks, headroom: walk.value.headroom })
|
||||||
|
}
|
||||||
|
|
||||||
function readableText(text: string | undefined): Result<EmittedBlock> | undefined {
|
function readableText(text: string | undefined): Result<EmittedBlock> | undefined {
|
||||||
return text === undefined ? undefined : success(commonMarkText(text))
|
return text === undefined ? undefined : success(commonMarkText(text))
|
||||||
}
|
}
|
||||||
@@ -128,19 +219,20 @@ function directivePair(node: AdfNode, opener: string, body: string, headroom: nu
|
|||||||
return { headroom, spelling: 'directive', text: `${opener}\n${body === '' ? '' : `${body}\n`}${spellDirectiveCloser(node.type)}` }
|
return { headroom, spelling: 'directive', text: `${opener}\n${body === '' ? '' : `${body}\n`}${spellDirectiveCloser(node.type)}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number, walkBody: () => Result<Walk>): Result<EmittedBlock> {
|
function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number, walkBody: () => Result<Walk>): Result<EmittedBlock> {
|
||||||
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
|
if (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<Walk>): 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))
|
||||||
}
|
}
|
||||||
@@ -149,18 +241,16 @@ 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): Result<EmittedBlock> | undefined {
|
function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||||
if (!carriesOnly(node, [])) return undefined
|
if (!carriesOnly(node, [])) return undefined
|
||||||
const inner = walkBlocks(nodeContent(node), path, depth + 1)
|
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 (!carriesOnly(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
|
||||||
@@ -169,13 +259,14 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
|
|||||||
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
|
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||||
const slot = languageSlot(nodeAttrs(node)['language'])
|
const slot = languageSlot(nodeAttrs(node)['language'])
|
||||||
const opener = spellBlockDirectiveOpener(node, directive, slot.kind === 'attribute' ? [] : ['language'])
|
const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
|
||||||
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||||
|
if (!opener.ok) return opener
|
||||||
const text = codeBlockText(node, path)
|
const text = codeBlockText(node, path)
|
||||||
if (!text.ok) return text
|
if (!text.ok) return text
|
||||||
return success(directivePair(node, opener, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
|
return success(directivePair(node, opener.value, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||||
@@ -199,19 +290,19 @@ function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
|||||||
return success(text)
|
return success(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
|
||||||
if (!carriesOnly(node, ['level'])) return undefined
|
if (!carriesOnly(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): 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 (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
|
||||||
const items = nodeContent(node)
|
const items = nodeContent(node)
|
||||||
@@ -221,26 +312,29 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<
|
|||||||
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)
|
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 {
|
||||||
@@ -250,8 +344,7 @@ function listStart(node: AdfNode, items: number): number | 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
|
||||||
@@ -261,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 || !carriesOnly(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 (!carriesOnly(node, []) || nodeContent(node).length > 0) return undefined
|
||||||
return success(commonMarkText('---'))
|
return '---'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
import type { AdfNode } from '../../adf/document.ts'
|
import type { 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 { 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)
|
||||||
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 { carriesOnly, nodeAttrs, nodeContent } 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 { serializeCanonicalJson } from '../../canonical-json.ts'
|
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||||
import { tryImageLine } from './inline-line.ts'
|
import { tryImageLine } from './inline-line.ts'
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
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 { 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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
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, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
||||||
import { escapeUnbalanced, spellDestination } from '../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 { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { longestBacktickRun } from '../backtick-runs.ts'
|
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||||
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||||
import { sameMark } from '../../adf/editor-normal.ts'
|
import { sameMark } from '../../adf/editor-normal.ts'
|
||||||
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
|
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.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
|
||||||
@@ -30,25 +34,34 @@ type InlineContext = {
|
|||||||
|
|
||||||
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
|
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
|
||||||
|
|
||||||
type LineAttempt =
|
type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } | { fallback?: undefined; line: string }
|
||||||
| { carry: NodeRange; line?: undefined; openingLinkAsDirective?: undefined }
|
|
||||||
| { carry?: undefined; line?: undefined; openingLinkAsDirective: true }
|
|
||||||
| { carry?: undefined; line: string; openingLinkAsDirective?: undefined }
|
|
||||||
|
|
||||||
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
|
type LineFallbacks = { carried: Set<number>; flavour: Flavour; openingLinkAsDirective: boolean }
|
||||||
const emitted = emitLine(nodes, container, path)
|
|
||||||
|
export type PlainLineFallback = { kind: 'claimed-line'; line: number; text: string } | { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
|
||||||
|
|
||||||
|
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<string> {
|
||||||
|
const emitted = emitLine(nodes, container, path, flavour)
|
||||||
if (!emitted.ok) return emitted
|
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
|
||||||
@@ -59,57 +72,68 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every pass carries at least one more node, or flips openingLinkAsDirective, which happens once — so the loop ends.
|
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<EmittedLine> {
|
||||||
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<EmittedLine> {
|
const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false }
|
||||||
const carried = new Set<number>()
|
// Terminates because takeFallback refuses a pass that took no new fallback.
|
||||||
let openingLinkAsDirective = false
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const emission = lineSegments(nodes, container, path, carried, openingLinkAsDirective)
|
const emission = lineSegments(nodes, container, path, fallbacks)
|
||||||
if (!emission.ok) return emission
|
if (!emission.ok) return emission
|
||||||
if (emission.value.carry !== undefined) {
|
if (emission.value.carry !== undefined) {
|
||||||
carryRange(carried, emission.value.carry)
|
const taken = takeFallback(fallbacks, emission.value.carry, path)
|
||||||
|
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) return success({ line: attempt.value.line, openingLinkAsDirective, segments: emission.value.segments })
|
if (attempt.value.line !== undefined) {
|
||||||
if (attempt.value.carry !== undefined) carryRange(carried, attempt.value.carry)
|
return success({ line: attempt.value.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
|
||||||
else openingLinkAsDirective = true
|
}
|
||||||
|
const taken = takeFallback(fallbacks, attempt.value.fallback, path)
|
||||||
|
if (!taken.ok) return taken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function carryRange(carried: Set<number>, range: NodeRange): void {
|
function takeFallback(fallbacks: LineFallbacks, fallback: NodeRange | 'opening-link', path: ConvertErrorPath): Result<null> {
|
||||||
for (let index = range.first; index <= range.last; index += 1) carried.add(index)
|
if (fallback === 'opening-link') {
|
||||||
|
if (fallbacks.openingLinkAsDirective) return failure('unsupported-node-shape', 'an opening link spelled as a directive still reads as a link definition, so the line has no spelling left', path)
|
||||||
|
fallbacks.openingLinkAsDirective = true
|
||||||
|
return success(null)
|
||||||
|
}
|
||||||
|
const before = fallbacks.carried.size
|
||||||
|
for (let index = fallback.first; index <= fallback.last; index += 1) fallbacks.carried.add(index)
|
||||||
|
if (fallbacks.carried.size === before) return failure('unsupported-node-shape', 'a carry took no inline node the line had not carried, so the line has no spelling left', path)
|
||||||
|
return success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineSegments(
|
function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result<Emission> {
|
||||||
nodes: readonly AdfNode[],
|
const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, path, spansLines: container === 'paragraph' }
|
||||||
container: LineContainer,
|
|
||||||
path: ConvertErrorPath,
|
|
||||||
carried: ReadonlySet<number>,
|
|
||||||
openingLinkAsDirective: boolean,
|
|
||||||
): Result<Emission> {
|
|
||||||
const context: InlineContext = { atBlockEnd: true, bracketed: false, carried, openingLinkAsDirective, path, spansLines: container === 'paragraph' }
|
|
||||||
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: carryStrippedWhitespace(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({ openingLinkAsDirective: true })
|
if (verdict.kind === 'opening-link') return success({ fallback: 'opening-link' })
|
||||||
if (assembled.unspellableRun !== undefined) return success({ carry: 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.
|
||||||
@@ -194,7 +218,7 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath {
|
|||||||
|
|
||||||
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> {
|
||||||
@@ -206,27 +230,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)
|
||||||
@@ -250,13 +274,14 @@ function emitText(node: AdfNode, context: InlineContext, index: number, path: Co
|
|||||||
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
|
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.attributes)
|
||||||
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
|
||||||
@@ -271,13 +296,22 @@ function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number
|
|||||||
const carried = carryStrippedWhitespace(inner.value.segments)
|
const carried = carryStrippedWhitespace(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,
|
...carried,
|
||||||
{ emphasis: 'close', escaping: 'none', nodes: range, text: spelling },
|
{ emphasis: 'close', escaping: 'none', nodes: { ...range, depth }, text: spelling },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRange, context: InlineContext): Result<Emission> {
|
||||||
|
const inner = emitRun(nodes, depth + 1, range.first, context)
|
||||||
|
if (!inner.ok || inner.value.carry !== undefined) return inner
|
||||||
|
const run = { ...range, depth }
|
||||||
|
return success({
|
||||||
|
segments: [{ escaping: 'none', highlight: 'open', nodes: run, text: highlightDelimiter }, ...inner.value.segments, { escaping: 'none', highlight: 'close', nodes: run, text: highlightDelimiter }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
|
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
|
||||||
let text = ''
|
let text = ''
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
@@ -298,10 +332,9 @@ 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 = nodeAttrs(mark)['href']
|
if (href === undefined) return success({ carry: range })
|
||||||
if (typeof href !== 'string') return success({ carry: range })
|
|
||||||
const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective
|
const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective
|
||||||
const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes, depth + 1, context.bracketed)
|
const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes, depth + 1, context.bracketed)
|
||||||
if (commonMark === undefined) return undefined
|
if (commonMark === undefined) return undefined
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
import { backtickRun, closingBacktickRun } from '../backtick-runs.ts'
|
import type { Flavour } from '../plain-conventions.ts'
|
||||||
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../emphasis-matching.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 { claimsDirectivePrefix } from '../directive-syntax.ts'
|
import { claimsDirectivePrefix } from '../directive-syntax.ts'
|
||||||
|
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
|
||||||
|
import { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts'
|
||||||
import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
|
import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
|
||||||
import { opensLinkDefinition } from '../link-reference-definitions.ts'
|
import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
|
||||||
import { readEntityReference } from '../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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { AdfNode } from '../../adf/document.ts'
|
import type { AdfNode } from '../../adf/document.ts'
|
||||||
|
import type { ConvertErrorPath } from '../../result.ts'
|
||||||
|
import type { Flavour } from '../plain-conventions.ts'
|
||||||
import { carriesOnly, nodeContent } from '../../adf/document.ts'
|
import { carriesOnly, nodeContent } from '../../adf/document.ts'
|
||||||
import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
|
import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
|
||||||
import { tryPipeCell } from './inline-line.ts'
|
import { tryPipeCell } from './inline-line.ts'
|
||||||
import type { ConvertErrorPath } from '../../result.ts'
|
|
||||||
|
|
||||||
export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | undefined {
|
export function tryPipeTable(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): string | undefined {
|
||||||
const rows = pipeRows(node)
|
const rows = pipeRows(node)
|
||||||
if (rows === undefined) return undefined
|
if (rows === undefined) return undefined
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
@@ -12,7 +13,7 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
|
|||||||
const cells: string[] = []
|
const cells: string[] = []
|
||||||
for (const [cellIndex, paragraph] of row.entries()) {
|
for (const [cellIndex, paragraph] of row.entries()) {
|
||||||
const content = nodeContent(paragraph)
|
const content = nodeContent(paragraph)
|
||||||
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
|
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], flavour)
|
||||||
if (line === undefined) return undefined
|
if (line === undefined) return undefined
|
||||||
cells.push(line)
|
cells.push(line)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
|
import type { LineContainer } from '../line-container.ts'
|
||||||
|
import type { MarkRun } from './line-escaping.ts'
|
||||||
|
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||||
|
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
|
import { largestNesting } from '../../nesting.ts'
|
||||||
|
import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts'
|
||||||
|
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||||
|
import { plainLineFallback, type PlainLineFallback } from './inline-line.ts'
|
||||||
|
import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
|
||||||
|
|
||||||
|
const highlight = 'backgroundColor'
|
||||||
|
const highlightMark: AdfMark = { type: highlight }
|
||||||
|
const edgeStrippingMarks: readonly string[] = [highlight, 'em', 'strike', 'strong']
|
||||||
|
const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link']
|
||||||
|
|
||||||
|
export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||||
|
const leaves = inlineLeaves(nodes, container, path, depth)
|
||||||
|
if (!leaves.ok) return leaves
|
||||||
|
return spellableLine(trimmedEdges(highlighted(trimmedEdges(leaves.value))), container, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBlockNodeType(type: string): boolean {
|
||||||
|
return blockNodeModel(type) !== undefined || type === 'blockCard' || type === 'embedCard'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||||
|
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
|
||||||
|
const leaves: AdfNode[] = []
|
||||||
|
let joinsNext = false
|
||||||
|
for (const [index, node] of nodes.entries()) {
|
||||||
|
const held = nodeLeaves(node, container, [...path, 'content', index], depth)
|
||||||
|
if (!held.ok) return held
|
||||||
|
if (held.value.length === 0) continue
|
||||||
|
const block = isBlockNodeType(node.type) && node.type !== 'media'
|
||||||
|
if (leaves.length > 0 && (joinsNext || block)) leaves.push(textLeaf(' ', []))
|
||||||
|
joinsNext = block
|
||||||
|
for (const leaf of held.value) leaves.push(leaf)
|
||||||
|
}
|
||||||
|
return success(leaves)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||||
|
const marks = nodeMarks(node)
|
||||||
|
const attrs = nodeAttrs(node)
|
||||||
|
if (node.type === 'text') return success(textLeaves(node.text, marks, container))
|
||||||
|
if (node.type === 'hardBreak') return success([lineBreak(container)])
|
||||||
|
if (node.type === 'date') return success(textLeaves(isoDate(attrs['timestamp']), marks, container))
|
||||||
|
if (node.type === 'emoji') return success(textLeaves(nonEmpty(attrs['text']) ?? attrs['shortName'], marks, container))
|
||||||
|
if (node.type === 'placeholder') return success([])
|
||||||
|
if (node.type === 'mention') return success(textLeaves(nonEmpty(attrs['text']) ?? idMention(attrs['id']), marks, container))
|
||||||
|
if (node.type === 'status') return success(textLeaves(attrs['text'], marks, container))
|
||||||
|
if (['extension', 'inlineExtension'].includes(node.type)) return success(textLeaves(nonEmpty(attrs['text']), marks, container, noteName(attrs['extensionKey']) ?? 'extension'))
|
||||||
|
if (node.type === 'syncBlock') return success(noteLeaves('synced block'))
|
||||||
|
if (['media', 'mediaInline'].includes(node.type)) return success(mediaLeaves(attrs, marks, container))
|
||||||
|
if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(attrs, marks, container))
|
||||||
|
const own = textLeaves(node.text ?? (['expand', 'nestedExpand'].includes(node.type) ? attrs['title'] : undefined), marks, container)
|
||||||
|
const held = inlineLeaves(nodeContent(node), container, path, depth + 1)
|
||||||
|
if (!held.ok) return held
|
||||||
|
return success(own.length > 0 && held.value.length > 0 ? [...own, textLeaf(' ', []), ...held.value] : [...own, ...held.value])
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||||
|
const data = attrs['data']
|
||||||
|
const held = typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {}
|
||||||
|
const url = nonEmpty(attrs['url'])
|
||||||
|
const heldUrl = nonEmpty(held['url'])
|
||||||
|
const name = nonEmpty(held['name'])
|
||||||
|
const href = url ?? heldUrl
|
||||||
|
if (href === undefined) return textLeaves(name, marks, container, 'link card')
|
||||||
|
return linkedLeaves(url ?? name ?? href, href, marks, container)
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkedLeaves(text: string, href: string, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||||
|
return textLeaves(text, [...marks.filter((mark) => mark.type !== 'link'), { attrs: { href }, type: 'link' }], container)
|
||||||
|
}
|
||||||
|
|
||||||
|
function idMention(id: unknown): string | undefined {
|
||||||
|
return typeof id === 'string' && id !== '' ? `@${id}` : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// An image standing inline is a link to it: CommonMark's inline image reads back as no node.
|
||||||
|
function mediaLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
|
||||||
|
const alt = nonEmpty(attrs['alt'])
|
||||||
|
const url = nonEmpty(attrs['url'])
|
||||||
|
if (attrs['type'] !== 'external' || url === undefined) return textLeaves(alt, marks, container, 'image')
|
||||||
|
return linkedLeaves(alt ?? url, url, marks, container)
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteLeaves(name: string): AdfNode[] {
|
||||||
|
return [textLeaf(`(${name} not included)`, [{ type: 'em' }])]
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteName(value: unknown): string | undefined {
|
||||||
|
return nonEmpty(value) === undefined ? undefined : oneLine(String(value)).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oneLine(text: string): string {
|
||||||
|
return text.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmpty(value: unknown): string | undefined {
|
||||||
|
return typeof value === 'string' && oneLine(value).trim() !== '' ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoDate(timestamp: unknown): string | undefined {
|
||||||
|
const milliseconds = typeof timestamp === 'string' && /^-?\d+$/.test(timestamp) ? Number(timestamp) : Number.NaN
|
||||||
|
const date = new Date(milliseconds)
|
||||||
|
if (Number.isNaN(date.getTime())) return undefined
|
||||||
|
const iso = date.toISOString()
|
||||||
|
return iso.slice(0, iso.indexOf('T'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineBreak(container: LineContainer): AdfNode {
|
||||||
|
return container === 'paragraph' ? { type: 'hardBreak' } : textLeaf(' ', [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function textLeaves(value: unknown, marks: readonly AdfMark[], container: LineContainer, note?: string): AdfNode[] {
|
||||||
|
if (typeof value !== 'string') return note === undefined ? [] : noteLeaves(note)
|
||||||
|
const text = value.replace(/[\r\u0000]/g, '')
|
||||||
|
const kept = plainMarks(marks, container, text)
|
||||||
|
const leaves: AdfNode[] = []
|
||||||
|
for (const [index, line] of text.split('\n').entries()) {
|
||||||
|
if (index > 0) leaves.push(lineBreak(container))
|
||||||
|
if (line !== '') leaves.push(textLeaf(line, kept))
|
||||||
|
}
|
||||||
|
return leaves
|
||||||
|
}
|
||||||
|
|
||||||
|
function textLeaf(text: string, marks: readonly AdfMark[]): AdfNode {
|
||||||
|
return marks.length === 0 ? { text, type: 'text' } : { marks: [...marks], text, type: 'text' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A highlight goes first, where `highlighted` looks for it, and code last, the only place its spelling holds.
|
||||||
|
function plainMarks(marks: readonly AdfMark[], container: LineContainer, text: string): AdfMark[] {
|
||||||
|
const kept: AdfMark[] = []
|
||||||
|
for (const mark of marks) {
|
||||||
|
if (!keptMarks.includes(mark.type) || kept.some((held) => held.type === mark.type)) continue
|
||||||
|
if (mark.type === 'code' && container === 'table-cell' && text.includes('|')) continue
|
||||||
|
const plain = mark.type === 'link' ? plainLink(mark, container) : { type: mark.type }
|
||||||
|
if (plain !== undefined) kept.push(plain)
|
||||||
|
}
|
||||||
|
const rank = (mark: AdfMark): number => (mark.type === highlight ? 0 : mark.type === 'code' ? 2 : 1)
|
||||||
|
// The reader highlights no code, as Atlassian's schema allows none.
|
||||||
|
const code = kept.some((mark) => mark.type === 'code')
|
||||||
|
return kept.filter((mark) => !code || mark.type !== highlight).sort((first, second) => rank(first) - rank(second))
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainLink(mark: AdfMark, container: LineContainer): AdfMark | undefined {
|
||||||
|
const attrs = nodeAttrs(mark)
|
||||||
|
const held = attrs['href']
|
||||||
|
const title = typeof attrs['title'] === 'string' ? attrs['title'].replace(/\r/g, '').replace(/\n/g, ' ') : undefined
|
||||||
|
if (typeof held !== 'string') return undefined
|
||||||
|
const href = writableHref(container === 'table-cell' ? held.replaceAll('|', '%7C') : held)
|
||||||
|
if (title === undefined || spellLinkTarget(href, title) === undefined || (container === 'table-cell' && title.includes('|'))) return { attrs: { href }, type: 'link' }
|
||||||
|
return { attrs: { href, title }, type: 'link' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// spec/flavour.md, Links: the characters no destination spelling holds, then an ampersand an entity reference would read.
|
||||||
|
export function writableHref(href: string): string {
|
||||||
|
let written = href
|
||||||
|
for (const unwritable of [/[\u0000-\u001f\u007f\\<>]/g, /&/g]) {
|
||||||
|
if (spellDestination(written) !== undefined) return written
|
||||||
|
written = written.replace(unwritable, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`)
|
||||||
|
}
|
||||||
|
return written
|
||||||
|
}
|
||||||
|
|
||||||
|
// The marks a whole highlight run shares go outside the highlight, so its delimiters open and close inside them.
|
||||||
|
function highlighted(leaves: readonly AdfNode[]): AdfNode[] {
|
||||||
|
const spelled: AdfNode[] = []
|
||||||
|
let run: AdfNode[] = []
|
||||||
|
let shared: AdfMark[] = []
|
||||||
|
for (const leaf of [...leaves, { type: 'hardBreak' }]) {
|
||||||
|
const marks = nodeMarks(leaf)
|
||||||
|
if (marks[0]?.type === highlight) {
|
||||||
|
const held = marks.slice(1)
|
||||||
|
shared = run.length === 0 ? held : shared.filter((mark) => held.some((other) => sameMark(other, mark)))
|
||||||
|
run.push(leaf)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const held of run) spelled.push(withMarks(held, [...shared, highlightMark, ...nodeMarks(held).slice(1).filter((mark) => !shared.some((other) => sameMark(other, mark)))]))
|
||||||
|
run = []
|
||||||
|
spelled.push(leaf)
|
||||||
|
}
|
||||||
|
return spelled.slice(0, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function withMarks(leaf: AdfNode, marks: readonly AdfMark[]): AdfNode {
|
||||||
|
const { marks: _, ...unmarked } = leaf
|
||||||
|
return marks.length === 0 ? unmarked : { ...unmarked, marks: [...marks] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] {
|
||||||
|
for (let current = leaves; ; ) {
|
||||||
|
const merged = withoutEdgeBreaks(mergeAdjacentText(current))
|
||||||
|
let changed = false
|
||||||
|
const trimmed: AdfNode[] = []
|
||||||
|
for (const [index, leaf] of merged.entries()) {
|
||||||
|
const edges = leafEdges(leaf, merged[index - 1], merged[index + 1])
|
||||||
|
if (edges === undefined) {
|
||||||
|
trimmed.push(leaf)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
for (const edge of edges) trimmed.push(edge)
|
||||||
|
}
|
||||||
|
if (!changed) return merged
|
||||||
|
current = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutEdgeBreaks(leaves: readonly AdfNode[]): AdfNode[] {
|
||||||
|
let first = 0
|
||||||
|
let last = leaves.length - 1
|
||||||
|
while (leaves[first]?.type === 'hardBreak') first += 1
|
||||||
|
while (last >= first && leaves[last]?.type === 'hardBreak') last -= 1
|
||||||
|
return leaves.slice(first, last + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// spec/flavour.md, Inline nodes: edge whitespace leaves every stripping mark opening or closing beside it, and goes at a line edge.
|
||||||
|
function leafEdges(leaf: AdfNode, previous: AdfNode | undefined, next: AdfNode | undefined): AdfNode[] | undefined {
|
||||||
|
const marks = nodeMarks(leaf)
|
||||||
|
const text = leaf.text
|
||||||
|
if (text === undefined || marks.some((mark) => mark.type === 'code')) return undefined
|
||||||
|
const lead = text.slice(0, text.search(/[^ \t]|$/))
|
||||||
|
const trail = text.slice(text.search(/[ \t]*$/))
|
||||||
|
const leadDepth = edgeDepth(marks, previous, lead)
|
||||||
|
const trailDepth = edgeDepth(marks, next, trail)
|
||||||
|
if (leadDepth === marks.length && trailDepth === marks.length) return undefined
|
||||||
|
if (lead === text) return leadDepth === undefined || trailDepth === undefined ? [] : [textLeaf(text, marks.slice(0, Math.min(leadDepth, trailDepth)))]
|
||||||
|
const edges: AdfNode[] = []
|
||||||
|
if (lead !== '' && leadDepth !== undefined) edges.push(textLeaf(lead, marks.slice(0, leadDepth)))
|
||||||
|
const core = text.slice(lead.length, text.length - trail.length)
|
||||||
|
if (core !== '') edges.push(textLeaf(core, marks))
|
||||||
|
if (trail !== '' && trailDepth !== undefined) edges.push(textLeaf(trail, marks.slice(0, trailDepth)))
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeDepth(marks: readonly AdfMark[], neighbour: AdfNode | undefined, whitespace: string): number | undefined {
|
||||||
|
if (whitespace === '') return marks.length
|
||||||
|
const lineEdge = neighbour === undefined || neighbour.type === 'hardBreak'
|
||||||
|
const neighbourMarks = lineEdge ? [] : nodeMarks(neighbour)
|
||||||
|
let shared = 0
|
||||||
|
while (shared < marks.length && sameMarkAt(marks, neighbourMarks, shared)) shared += 1
|
||||||
|
const stripping = marks.findIndex((mark, index) => index >= shared && edgeStrippingMarks.includes(mark.type))
|
||||||
|
const kept = stripping === -1 ? marks.length : stripping
|
||||||
|
return kept === 0 && lineEdge ? undefined : kept
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index: number): boolean {
|
||||||
|
const mark = marks[index]
|
||||||
|
const other = others[index]
|
||||||
|
return mark !== undefined && other !== undefined && sameMark(mark, other)
|
||||||
|
}
|
||||||
|
|
||||||
|
function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<AdfNode[]> {
|
||||||
|
for (let current = leaves; ; ) {
|
||||||
|
const fallback = plainLineFallback(current, container, path)
|
||||||
|
if (!fallback.ok) return fallback
|
||||||
|
if (fallback.value === undefined) return success(current)
|
||||||
|
const fixed = withoutFallback(current, fallback.value)
|
||||||
|
if (fixed === undefined) return failure('unsupported-node-shape', 'a plain line keeps a spelling that dropping a mark does not change', path)
|
||||||
|
current = trimmedEdges(fixed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback): AdfNode[] | undefined {
|
||||||
|
if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, fallback.runs)
|
||||||
|
const first = fallback.kind === 'opening-link' ? 0 : lineStart(leaves, fallback.line)
|
||||||
|
const mark = nodeMarks(leaves[first] ?? {})[0]
|
||||||
|
if (mark === undefined || mark.type !== (fallback.kind === 'opening-link' ? 'link' : 'code')) return undefined
|
||||||
|
let last = first
|
||||||
|
while (sameMarkAt(nodeMarks(leaves[last + 1] ?? {}), [mark], 0)) last += 1
|
||||||
|
// A code span is what binds the `]` a link definition reads, and dropping it keeps the link target.
|
||||||
|
const spans = leaves.slice(first, last + 1).some((leaf) => nodeMarks(leaf).length > 1 && nodeMarks(leaf).at(-1)?.type === 'code')
|
||||||
|
if (mark.type === 'link' && spans) return leaves.map((leaf, index) => (index < first || index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code'))))
|
||||||
|
return withoutMarks(leaves, [{ depth: 0, first, last }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineStart(leaves: readonly AdfNode[], line: number): number {
|
||||||
|
let index = 0
|
||||||
|
for (let breaks = 0; breaks < line && index < leaves.length; index += 1) if (leaves[index]?.type === 'hardBreak') breaks += 1
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
// The runs cover disjoint leaves, so one pass drops them all.
|
||||||
|
function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): AdfNode[] {
|
||||||
|
const depths = new Map<number, number>()
|
||||||
|
for (const run of runs) for (let index = run.first; index <= run.last; index += 1) depths.set(index, run.depth)
|
||||||
|
return leaves.map((leaf, index) => {
|
||||||
|
const depth = depths.get(index)
|
||||||
|
return depth === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => held !== depth))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
|
import { adfToPlainMarkdown, reduceToPlain } from './plain-reduction.ts'
|
||||||
|
import { largestNesting } from '../../nesting.ts'
|
||||||
|
|
||||||
|
const code: AdfMark = { type: 'code' }
|
||||||
|
const em: AdfMark = { type: 'em' }
|
||||||
|
const strong: AdfMark = { type: 'strong' }
|
||||||
|
|
||||||
|
function document(...content: AdfNode[]): AdfDocument {
|
||||||
|
return { content, type: 'doc', version: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function plain(...content: AdfNode[]): string {
|
||||||
|
return plainDocument(document(...content))
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainDocument(input: AdfDocument): string {
|
||||||
|
const markdown = adfToPlainMarkdown(input)
|
||||||
|
return markdown.ok ? markdown.value : `${markdown.error.code} at /${markdown.error.path.join('/')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: string, ...marks: AdfMark[]): AdfNode {
|
||||||
|
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(type: string, attrs: AdfAttributes, ...content: AdfNode[]): AdfNode {
|
||||||
|
return { attrs, content, type }
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraph(...content: AdfNode[]): AdfNode {
|
||||||
|
return { content, type: 'paragraph' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function said(value: string): AdfNode {
|
||||||
|
return paragraph(text(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function item(...content: AdfNode[]): AdfNode {
|
||||||
|
return { content, type: 'listItem' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulletList(...content: AdfNode[]): AdfNode {
|
||||||
|
return { content, type: 'bulletList' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cell(type: string, ...content: AdfNode[]): AdfNode {
|
||||||
|
return { content, type }
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(...content: AdfNode[]): AdfNode {
|
||||||
|
return { content, type: 'tableRow' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function link(href: string, title?: string): AdfMark {
|
||||||
|
return { attrs: title === undefined ? { href } : { href, title }, type: 'link' }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('refuses what the document guard refuses, and nothing else', () => {
|
||||||
|
assert.equal(plainDocument({ type: 'doc', version: Number.NaN }), 'not-an-adf-document at /')
|
||||||
|
assert.equal(plainDocument({ type: 'doc', version: 2 }), 'unsupported-document-version at /')
|
||||||
|
let deep: AdfNode = said('x')
|
||||||
|
for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' }
|
||||||
|
assert.match(plainDocument(document(deep)), /^unsupported-nesting-depth at \/content\/0(\/content\/0)+$/)
|
||||||
|
let deepInline: AdfNode = text('x')
|
||||||
|
for (let level = 0; level <= largestNesting; level += 1) deepInline = { content: [deepInline], type: 'unknownInline' }
|
||||||
|
assert.match(plainDocument(document(paragraph(deepInline))), /^unsupported-nesting-depth at /)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refuses nesting past 500 levels wherever the reduction walks', () => {
|
||||||
|
const lowest = (bottom: AdfNode): string => {
|
||||||
|
let deep = bottom
|
||||||
|
for (let level = 0; level < largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' }
|
||||||
|
return plainDocument(document(deep)).split(' ')[0] ?? ''
|
||||||
|
}
|
||||||
|
const wrapped: AdfNode = { content: [text('x')], type: 'unknownInline' }
|
||||||
|
const bottoms: AdfNode[] = [
|
||||||
|
bulletList(item(said('x'))),
|
||||||
|
node('taskList', {}, node('taskList', {}, node('taskItem', {}, text('x')))),
|
||||||
|
node('taskList', {}, node('taskItem', {}, wrapped)),
|
||||||
|
node('taskList', {}, node('blockTaskItem', {}, said('x'))),
|
||||||
|
node('panel', {}, said('x')),
|
||||||
|
node('expand', {}, said('x')),
|
||||||
|
node('decisionList', {}, node('decisionItem', {}, text('x'))),
|
||||||
|
node('table', {}, row(cell('tableCell', said('x')))),
|
||||||
|
node('mediaSingle', {}, node('caption', {}, text('x'))),
|
||||||
|
node('heading', { level: 1 }, wrapped),
|
||||||
|
node('codeBlock', {}, wrapped),
|
||||||
|
paragraph(wrapped),
|
||||||
|
]
|
||||||
|
for (const bottom of bottoms) assert.equal(lowest(bottom), 'unsupported-nesting-depth', bottom.type)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('spells a panel as an alert in the GitHub word for its colour', () => {
|
||||||
|
const panel = (panelType: string | undefined): string =>
|
||||||
|
plain(node('panel', panelType === undefined ? {} : { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType }, said('Check it.')))
|
||||||
|
assert.equal(panel('info'), '> [!NOTE]\n>\n> Check it.\n')
|
||||||
|
assert.equal(panel('note'), '> [!IMPORTANT]\n>\n> Check it.\n')
|
||||||
|
assert.equal(panel('tip'), '> [!TIP]\n>\n> Check it.\n')
|
||||||
|
assert.equal(panel('success'), '> [!TIP]\n>\n> Check it.\n')
|
||||||
|
assert.equal(panel('warning'), '> [!WARNING]\n>\n> Check it.\n')
|
||||||
|
assert.equal(panel('error'), '> [!CAUTION]\n>\n> Check it.\n')
|
||||||
|
assert.equal(panel('custom'), '> [!NOTE]\n>\n> Check it.\n')
|
||||||
|
assert.equal(panel(undefined), '> [!NOTE]\n>\n> Check it.\n')
|
||||||
|
assert.equal(plain(node('panel', { panelType: 'warning' })), '> [!WARNING]\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('spells an expand and a nested expand as a folded callout titled by the marker line', () => {
|
||||||
|
const nested = node('nestedExpand', { title: 'Inner' }, said('Deep.'))
|
||||||
|
assert.equal(
|
||||||
|
plain(node('expand', { localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de', title: 'Build log' }, said('Line.'), nested)),
|
||||||
|
'> [!NOTE]- Build log\n>\n> Line.\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n',
|
||||||
|
)
|
||||||
|
assert.equal(plain(node('expand', {}, said('Line.'))), '> [!NOTE]-\n>\n> Line.\n')
|
||||||
|
assert.equal(plain(node('expand', { title: ' *Two*\nlines ' })), '> [!NOTE]- \\*Two\\* lines\n')
|
||||||
|
assert.equal(plain(node('expand', { title: '\ta \t b\t ' })), '> [!NOTE]- a \t b\n')
|
||||||
|
assert.equal(plain(node('expand', { title: '**x** [y](z) ==w==' }, said('b'))), '> [!NOTE]- \\*\\*x\\*\\* \\[y](z) ==w==\n>\n> b\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('spells a task list as a bullet list whose items lead with their state', () => {
|
||||||
|
const task = (state: string, value: string): AdfNode => node('taskItem', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', state }, text(value))
|
||||||
|
const nested = node('taskList', {}, task('TODO', 'Review'))
|
||||||
|
assert.equal(plain(node('taskList', {}, task('DONE', 'Write the spec'), nested, task('TODO', 'Ship it'))), '- [x] Write the spec\n - [ ] Review\n- [ ] Ship it\n')
|
||||||
|
assert.equal(plain(node('taskList', {}, nested, task('DONE', ''), said('Stray'))), '- - [ ] Review\n- \\[x]\n- Stray\n')
|
||||||
|
assert.equal(plain(node('taskList', {}, node('taskList', {}), task('DONE', 'a'), said('b'), node('taskList', {}, task('TODO', 'c')))), '- \\[x] a\n- b\n - [ ] c\n')
|
||||||
|
assert.equal(plain(node('taskList', {}, paragraph(), task('DONE', 'a'))), '- [x] a\n')
|
||||||
|
assert.equal(plain(bulletList(item(said('x'))), node('taskList', {}, task('DONE', 'a'), node('taskList', {}, task('TODO', 'c')))), '- x\n- \\[x] a\n - [ ] c\n')
|
||||||
|
assert.equal(plain(node('taskList', {}), task('TODO', 'Loose')), 'Loose\n')
|
||||||
|
const blockTask = node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.'))
|
||||||
|
const codeTask = node('blockTaskItem', { state: 'TODO' }, { content: [text('x')], type: 'codeBlock' })
|
||||||
|
assert.equal(plain(node('taskList', {}, blockTask, codeTask)), '- [x] First.\n\n Second.\n- [ ]\n\n ```\n x\n ```\n')
|
||||||
|
const listTask = node('blockTaskItem', { state: 'DONE' }, bulletList(item(said('a'))))
|
||||||
|
assert.equal(plain(node('taskList', {}, task('TODO', ''), listTask, node('taskList', {}, task('TODO', 'b')))), '- [ ]\n- [x]\n - a\n - \\[ ] b\n')
|
||||||
|
assert.equal(plain(node('taskList', {}, task('DONE', 'a'), node('taskList', {}, task('TODO', '')))), '- [x] a\n - [ ]\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('spells a decision list as a plain bullet list', () => {
|
||||||
|
assert.equal(plain(node('decisionList', {}, node('decisionItem', { state: 'DECIDED' }, text('Ship')), said('Stray'))), '- Ship\n- Stray\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('spells a highlight as a == pair around the run, whatever its colour', () => {
|
||||||
|
const highlight = (color: string): AdfMark => ({ attrs: { color }, type: 'backgroundColor' })
|
||||||
|
assert.equal(plain(paragraph(text('a '), text('hi', highlight('#fff')), text(' there', highlight('#000')), text(' b'))), 'a ==hi there== b\n')
|
||||||
|
assert.equal(plain(paragraph(text('hi ', strong, highlight('#fff')), text('b'))), '**==hi==** b\n')
|
||||||
|
assert.equal(plain(paragraph(text('a', strong, highlight('#fff')), text('b', highlight('#fff'), em))), '==**a**_b_==\n')
|
||||||
|
assert.equal(plain(paragraph(text('a', highlight('#fff'), code), text('b', highlight('#fff')))), '`a`==b==\n')
|
||||||
|
assert.equal(plain(paragraph(text('=', highlight('#fff')), text(' '), text('a==b', highlight('#fff')))), '==\\=== ==a==b==\n')
|
||||||
|
assert.equal(plain(paragraph(text('x'), text('y', highlight('#fff')), text(' z'))), 'xy z\n')
|
||||||
|
assert.equal(plain(paragraph(text('この機能は'), text('日本語', highlight('#fff')), text('でのみ'))), 'この機能は==日本語==でのみ\n')
|
||||||
|
assert.equal(plain(paragraph(text('サーバー'), text('停止', highlight('#fff')), text('中 iPhone'), text('専用', highlight('#fff')), text('アプリ 기능은 '), text('한국어', highlight('#fff')), text('에서만'))), 'サーバー==停止==中 iPhone==専用==アプリ 기능은 ==한국어==에서만\n')
|
||||||
|
assert.equal(plain(paragraph(text('日==本==語'))), '日\\==本\\==語\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('escapes text a renderer would take as a flavour marker, and only there', () => {
|
||||||
|
assert.equal(plain(said('==x== a == b a==b ===')), '\\==x\\== a == b a==b \\=\\==\n')
|
||||||
|
assert.equal(plain({ content: [said('[!NOTE] x'), said('[!TIP]')], type: 'blockquote' }), '> \\[!NOTE] x\n>\n> [!TIP]\n')
|
||||||
|
assert.equal(plain({ content: [said('[!NOTE]x')], type: 'blockquote' }, said('[!NOTE]')), '> [!NOTE]x\n\n[!NOTE]\n')
|
||||||
|
assert.equal(plain(bulletList(item(said('[x] a')), item(said('[ ]')))), '- \\[x] a\n- \\[ ]\n')
|
||||||
|
assert.equal(plain(bulletList(item(said('[x] a')), item(said('b'))), node('orderedList', { order: 1 }, item(said('[x] c')))), '- \\[x] a\n- b\n\n1. \\[x] c\n')
|
||||||
|
const task = (state: string, value: string): AdfNode => node('taskItem', { state }, text(value))
|
||||||
|
assert.equal(plain(node('taskList', {}, task('DONE', '[x] a'), task('TODO', '==b=='))), '- [x] [x] a\n- [ ] \\==b\\==\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unwraps the containers plain markdown has no spelling for to their body blocks in order', () => {
|
||||||
|
const column = (value: string): AdfNode => node('layoutColumn', { width: 50 }, said(value))
|
||||||
|
assert.equal(plain(node('layoutSection', {}, column('Left.'), column('Right.'))), 'Left.\n\nRight.\n')
|
||||||
|
assert.equal(plain(node('bodiedExtension', { extensionKey: 'k' }, said('Body.'))), 'Body.\n')
|
||||||
|
assert.equal(plain(node('bodiedSyncBlock', { resourceId: 'r' }, said('Synced.'))), 'Synced.\n')
|
||||||
|
const frame = (value: string): AdfNode => node('extensionFrame', {}, said(value))
|
||||||
|
assert.equal(plain(node('multiBodiedExtension', { extensionKey: 'k' }, frame('One.'), frame('Two.'))), 'One.\n\nTwo.\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the CommonMark blocks in their spelling and drops their attributes and marks', () => {
|
||||||
|
const localId = { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05' }
|
||||||
|
assert.equal(plain(node('paragraph', localId, text('x')), node('heading', { level: 2, localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de' }, text('h'))), 'x\n\n## h\n')
|
||||||
|
assert.equal(plain({ attrs: localId, content: [said('q')], marks: [{ type: 'breakout' }], type: 'blockquote' }), '> q\n')
|
||||||
|
assert.equal(plain(node('codeBlock', { language: 'ts', wrap: true }, text('a\r\nb\u0000'))), '```ts\na\nb\n```\n')
|
||||||
|
assert.equal(plain(node('codeBlock', { language: 'carry' }, text('a'), { type: 'hardBreak' }, text('b', strong))), '```\na\nb\n```\n')
|
||||||
|
assert.equal(plain(node('codeBlock', {})), '```\n```\n')
|
||||||
|
assert.equal(plain(node('rule', { color: '#000' })), '---\n')
|
||||||
|
assert.equal(plain(node('orderedList', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', order: 3 }, item(said('c')))), '3. c\n')
|
||||||
|
assert.equal(plain(node('orderedList', {}, item(said('a')))), '1. a\n')
|
||||||
|
assert.equal(plain(node('orderedList', { order: -1 }, item(said('a')))), '1. a\n')
|
||||||
|
const code: AdfNode = { content: [text('x')], type: 'codeBlock' }
|
||||||
|
assert.equal(plain(node('orderedList', { order: 1e10 }, item(said('Alpha')), item(code), item())), '- 10000000000. Alpha\n- 10000000001.\n\n ```\n x\n ```\n- 10000000002.\n')
|
||||||
|
const long = node('orderedList', { order: 1e10 }, item(said('y')))
|
||||||
|
assert.equal(plain(bulletList(item(said('x'))), long, bulletList(item(said('z')))), '- x\n- 10000000000. y\n- z\n')
|
||||||
|
assert.equal(plain(node('taskList', {}, node('taskItem', { state: 'DONE' }, text('t'))), long), '- \\[x] t\n- 10000000000. y\n')
|
||||||
|
assert.equal(plain(bulletList(item(said('a'), bulletList(item(said('x'))), long))), '- a\n - x\n - 10000000000. y\n')
|
||||||
|
const givesWay = node('orderedList', { order: 1e10 }, item(node('rule', {})), item(bulletList(item(bulletList(item())))))
|
||||||
|
assert.equal(plain(givesWay, bulletList(item(said('z')))), '- 10000000000.\n- 10000000001.\n - -\n- z\n')
|
||||||
|
assert.equal(plain(node('orderedList', { order: 999999999 }, item(said('a'))), node('orderedList', { order: 5 }, item(said('b')))), '- 999999999\\. a\n- 5\\. b\n')
|
||||||
|
assert.equal(plain(node('heading', { level: 7 }, text('h'))), 'h\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('spells an inline node as its text', () => {
|
||||||
|
assert.equal(plain(paragraph(node('mention', { id: 'a', text: '@Mikael' }), text(' and '), node('status', { color: 'red', text: 'Blocked' }))), '@Mikael and Blocked\n')
|
||||||
|
assert.equal(plain(paragraph(node('emoji', { shortName: ':tada:', text: '🎉' }), node('emoji', { shortName: ':smile:' }))), '🎉:smile:\n')
|
||||||
|
assert.equal(plain(paragraph(node('date', { timestamp: '1757721600000' }), text(' '), node('date', { timestamp: 'soon' }))), '2025-09-13\n')
|
||||||
|
assert.equal(plain(paragraph({ marks: [strong], ...node('mention', { text: '@Mikael' }) })), '**@Mikael**\n')
|
||||||
|
assert.equal(plain(paragraph(text('by '), node('mention', { id: '5b10a2' }), node('mention', {}))), 'by @5b10a2\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('spells a card as a link to its url, or to its data url named by its data name, else a note', () => {
|
||||||
|
assert.equal(plain(paragraph(node('inlineCard', { url: 'https://example.com' }))), '<https://example.com>\n')
|
||||||
|
assert.equal(plain(paragraph({ ...node('inlineCard', { url: 'https://example.com' }), marks: [strong, link('https://other.com')] })), '**<https://example.com>**\n')
|
||||||
|
assert.equal(plain(paragraph(text('see '), node('inlineCard', { data: {} }))), 'see _(link card not included)_\n')
|
||||||
|
assert.equal(plain(paragraph(node('inlineCard', { data: { name: 'Spec', url: 'https://e.com/s' } }), text(' '), node('inlineCard', { data: { url: 'https://e.com/u' } }))), '[Spec](https://e.com/s) <https://e.com/u>\n')
|
||||||
|
assert.equal(plain(paragraph(node('inlineCard', { data: { name: 'Spec' } }), text(' '), node('inlineCard', { data: ['x'] }))), 'Spec _(link card not included)_\n')
|
||||||
|
assert.equal(plain(node('blockCard', { url: 'https://example.com/a b' })), '[https://example.com/a b](<https://example.com/a b>)\n')
|
||||||
|
assert.equal(plain(node('embedCard', { layout: 'center', url: 'https://example.com' })), '<https://example.com>\n')
|
||||||
|
assert.equal(plain(node('blockCard', { data: {} })), '_(link card not included)_\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps an external image wherever it stands and spells a stored file as its alt text, else a note', () => {
|
||||||
|
const media = (attrs: AdfAttributes): AdfNode => ({ attrs, type: 'media' })
|
||||||
|
const caption: AdfNode = node('caption', {}, text('The moon.'))
|
||||||
|
const external = media({ alt: 'Moon', height: 10, type: 'external', url: 'https://example.com/moon.png' })
|
||||||
|
assert.equal(plain(node('mediaSingle', { layout: 'wide', width: 50 }, external, caption)), '\n\nThe moon.\n')
|
||||||
|
assert.equal(plain(node('mediaSingle', {}, media({ alt: '', type: 'external', url: 'https://example.com/a.png' }))), '\n')
|
||||||
|
assert.equal(plain(node('mediaSingle', {}, media({ alt: ' Two\nlines ', type: 'external', url: 'u' }), media({ type: 'external', url: 'v' }))), '\n\n\n')
|
||||||
|
assert.equal(plain(node('mediaSingle', {}, media({ alt: 'Bad', type: 'external', url: 'a\\b <&>' }))), '\n')
|
||||||
|
assert.equal(plain(node('mediaSingle', {}, media({ alt: 'Photo', collection: 'c', id: 'i', type: 'file' }))), 'Photo\n')
|
||||||
|
assert.equal(plain(node('mediaGroup', {}, media({ alt: 'One', type: 'file' }), media({ type: 'file' }), external)), 'One\n\n_(image not included)_\n\n\n')
|
||||||
|
assert.equal(plain(external), '\n')
|
||||||
|
assert.equal(plain(paragraph(text('a '), node('mediaInline', { alt: 'clip', type: 'file' }), text(' '), node('mediaInline', { type: 'file' }))), 'a clip _(image not included)_\n')
|
||||||
|
assert.equal(plain(paragraph(text('See '), external, text(' for '), node('mediaInline', { type: 'external', url: 'https://e.com/i.png' }))), 'See [Moon](https://example.com/moon.png) for <https://e.com/i.png>\n')
|
||||||
|
assert.equal(plain(caption), 'The moon.\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('spells an extension as its text attribute, else a note naming it, and a placeholder as nothing', () => {
|
||||||
|
assert.equal(plain(node('extension', { extensionKey: 'toc', text: 'Contents' }), node('extension', { extensionKey: 'jira-issues-table' })), 'Contents\n\n_(jira-issues-table not included)_\n')
|
||||||
|
assert.equal(plain(node('syncBlock', { resourceId: 'r' })), '_(synced block not included)_\n')
|
||||||
|
assert.equal(plain(node('extension', { extensionKey: 'jira\r\nissues\u0000' })), '_(jira issues not included)_\n')
|
||||||
|
assert.equal(plain(node('extension', { extensionKey: '\r\u0000' }), node('extension', { extensionKey: '\n' })), '_(extension not included)_\n\n_(extension not included)_\n')
|
||||||
|
const blank = paragraph(node('inlineExtension', { extensionKey: 'k', text: '\r' }), text(' '), node('mediaInline', { alt: '\u0000', type: 'file' }), text(' '), node('mention', { id: '5b10a2', text: '\r' }))
|
||||||
|
assert.equal(plain(blank), '_(k not included)_ _(image not included)_ @5b10a2\n')
|
||||||
|
assert.equal(plain(paragraph(text('a '), node('inlineExtension', { text: 'macro' }), text(' '), node('inlineExtension', {}), node('placeholder', { text: 'Type here' }))), 'a macro _(extension not included)_\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('spells a node no row names, or one standing where no spelling holds it, as its blocks or its text', () => {
|
||||||
|
assert.equal(plain(node('futureBlock', {}, said('Inside.'))), 'Inside.\n')
|
||||||
|
assert.equal(plain(paragraph(text('a '), { content: [text('b')], text: 'c', type: 'futureInline' })), 'a c b\n')
|
||||||
|
assert.equal(plain(text('loose'), node('mention', { text: '@x' }), node('listItem', {}, said('item'))), 'loose@x\n\nitem\n')
|
||||||
|
assert.equal(plain(paragraph(text('a '), node('bulletList', {}, item(said('b')), item(said('c'))))), 'a b c\n')
|
||||||
|
assert.equal(plain(bulletList(said('stray'), item(said('b')), text('loose'))), '- stray\n- b\n- loose\n')
|
||||||
|
assert.equal(plain(bulletList()), '')
|
||||||
|
assert.equal(plain(bulletList(item({ content: [text('a\n \nb')], type: 'codeBlock' }))), '- ```\n a\n\n b\n ```\n')
|
||||||
|
assert.equal(plain(bulletList(item({ content: [text(' ')], type: 'codeBlock' }))), '- ```\n ```\n')
|
||||||
|
assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {}), said('Install')), item(said('Configure')))), '- Install\n- Configure\n')
|
||||||
|
assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {})), item(said('Configure')))), '-\n- Configure\n')
|
||||||
|
assert.equal(plain(bulletList(item(bulletList(item(bulletList(item())))))), '- -\n')
|
||||||
|
assert.equal(plain(node('nestedExpand', {}, node('tableCell', {}, said('c')))), '> [!NOTE]-\n>\n> c\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps a table as a pipe table headed by its first row, one line per cell', () => {
|
||||||
|
const table = node(
|
||||||
|
'table',
|
||||||
|
{ layout: 'wide' },
|
||||||
|
row(cell('tableCell', said('Part')), cell('tableCell', said('Qty'))),
|
||||||
|
row(cell('tableHeader', said('Bolt'), bulletList(item(said('M8')))), { attrs: { background: '#fff' }, content: [paragraph(text('4'), { type: 'hardBreak' }, text('0'))], type: 'tableCell' }),
|
||||||
|
)
|
||||||
|
assert.equal(plain(table), '| Part | Qty |\n| --- | --- |\n| Bolt M8 | 4 0 |\n')
|
||||||
|
const spanned = node(
|
||||||
|
'table',
|
||||||
|
{},
|
||||||
|
row(cell('tableHeader', said('A')), cell('tableHeader', said('B')), cell('tableHeader', said('C'))),
|
||||||
|
row(node('tableCell', { colspan: 2, rowspan: 2 }, said('wide')), cell('tableCell', said('c'))),
|
||||||
|
row(cell('tableCell', said('d'))),
|
||||||
|
row(cell('tableCell')),
|
||||||
|
)
|
||||||
|
assert.equal(plain(spanned), '| A | B | C |\n| --- | --- | --- |\n| wide | | c |\n| | | d |\n| | | |\n')
|
||||||
|
const huge = node('table', {}, row(node('tableHeader', { colspan: 1e9, rowspan: 1e9 }, said('A')), cell('tableHeader', said('B'))), row(cell('tableCell', said('c'))))
|
||||||
|
assert.equal(plain(huge), '| A | | | | B |\n| --- | --- | --- | --- | --- |\n| c | | | | |\n')
|
||||||
|
assert.equal(plain(node('table', {}, row(cell('tableHeader', paragraph(text('a|b', code), text(' '), text('x', link('https://e.com/|'))))))), '| a\\|b [x](https://e.com/%7C) |\n| --- |\n')
|
||||||
|
assert.equal(plain(node('table', {}, said('stray'))), '| stray |\n| --- |\n')
|
||||||
|
const titled = paragraph(text('t', link('https://e.com', 'a|b')))
|
||||||
|
const folded = [node('expand', { title: 'Log' }, said('x')), node('nestedExpand', {}, said('y'))]
|
||||||
|
assert.equal(plain(node('table', {}, row(cell('tableHeader', titled), cell('tableHeader', ...folded)))), '| [t](https://e.com) | Log x y |\n| --- | --- |\n')
|
||||||
|
assert.equal(plain(node('table', {}, row())), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps code, em, link, strike and strong and drops every other mark, keeping its text', () => {
|
||||||
|
const marks: AdfMark[] = [{ type: 'strike' }, { attrs: { type: 'sub' }, type: 'subsup' }, { type: 'underline' }, { attrs: { color: '#f00' }, type: 'textColor' }]
|
||||||
|
assert.equal(plain(paragraph(text('H'), text('2', ...marks), text('O', em, strong), text('!', { attrs: { size: 1 }, type: 'border' }))), 'H~~2~~_**O**_!\n')
|
||||||
|
assert.equal(plain(paragraph(text('x', code, strong), text(' '), text('y', { attrs: { x: 1 }, type: 'strong' }), text('z', em, em))), '**`x`** **y**_z_\n')
|
||||||
|
assert.equal(plain(paragraph(text('site', { attrs: { collection: 'c', href: 'https://e.com', id: 'i' }, type: 'link' }))), '[site](https://e.com)\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('percent-encodes a link href no CommonMark escape writes until one does', () => {
|
||||||
|
assert.equal(plain(paragraph(text('a', link('a\\b')), text(' '), text('b', { attrs: { id: 'i' }, type: 'link' }), text(' '), text('c', link('/&')))), '[a](a%5Cb) b [c](/%26amp;)\n')
|
||||||
|
assert.equal(plain(paragraph(text('t', link('https://e.com', 'two\nlines')), text(' '), text('u', link('https://e.com', 'a\\b')))), '[t](https://e.com "two lines") [u](https://e.com)\n')
|
||||||
|
assert.equal(plain(paragraph(text(']: a', link('/u'), code))), '[\\]: a](/u)\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drops the mark of a run CommonMark flanking or matching cannot spell', () => {
|
||||||
|
assert.equal(plain(paragraph(text('un'), text('-real', strong), text('istic'))), 'un-realistic\n')
|
||||||
|
assert.equal(plain(paragraph(text('a', em), text('b', strong), text('c', em))), '_a_**b**_c_\n')
|
||||||
|
assert.equal(plain(paragraph(text('x'), text('*', em), text('y'))), 'x\\*y\n')
|
||||||
|
const highlight: AdfMark = { type: 'backgroundColor' }
|
||||||
|
assert.equal(plain(paragraph(text('a'), text('b', highlight), text(' c'), text('d', highlight), text(' '), text('e', highlight))), 'ab cd ==e==\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('breaks a line at a newline and trims whitespace at every edge CommonMark strips', () => {
|
||||||
|
assert.equal(plain(paragraph(text(' \n a \n b\n'))), 'a\\\nb\n')
|
||||||
|
assert.equal(plain(paragraph({ type: 'hardBreak' }, text('a'), { attrs: { text: '\n' }, type: 'hardBreak' }, text('b'), { type: 'hardBreak' })), 'a\\\nb\n')
|
||||||
|
assert.equal(plain(paragraph(text('a'), text(' b ', strong), text('c'))), 'a **b** c\n')
|
||||||
|
assert.equal(plain(paragraph(text(' '), text('b', strong), text(' \n'), text('c', strong), text(' '))), '**b**\\\n**c**\n')
|
||||||
|
assert.equal(plain(paragraph(text('a'), text(' b ', em, strong), text(' ', em), text('c', em))), 'a _**b** c_\n')
|
||||||
|
assert.equal(plain(paragraph(text(' x ', code))), '` x `\n')
|
||||||
|
assert.equal(plain(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n')
|
||||||
|
assert.equal(plain(paragraph(text('a\r\u0000b'))), 'ab\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drops the code mark of a span opening a line with backticks that read as a fence', () => {
|
||||||
|
assert.equal(plain(paragraph(text('``` x', code))), '\\`\\`\\` x\n')
|
||||||
|
assert.equal(plain(paragraph(text('a\n'), text('``` x', code))), 'a\\\n\\`\\`\\` x\n')
|
||||||
|
assert.equal(plain(paragraph(text('a '), text('``` x', code))), 'a ```` ``` x ````\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drops an empty paragraph and merges adjacent lists of one type', () => {
|
||||||
|
const ordered = (order: number, value: string): AdfNode => node('orderedList', { order }, item(said(value)))
|
||||||
|
assert.equal(plain(said('a'), paragraph(), paragraph(text(' ')), said('b')), 'a\n\nb\n')
|
||||||
|
assert.equal(plain(bulletList(item(said('a'))), paragraph(), node('decisionList', {}, node('decisionItem', {}, text('b')))), '- a\n- b\n')
|
||||||
|
assert.equal(plain(ordered(2, 'a'), ordered(3, 'b'), bulletList(item(said('c')))), '2. a\n3. b\n\n- c\n')
|
||||||
|
assert.equal(plain(bulletList(item(said('x'))), ordered(1, 'a'), ordered(5, 'b'), ordered(6, 'c')), '- x\n- 1\\. a\n- 5\\. b\n\n6. c\n')
|
||||||
|
assert.equal(plain(ordered(1, 'a'), ordered(1e10, 'y')), '- 1\\. a\n- 10000000000. y\n')
|
||||||
|
assert.equal(plain(node('taskList', {}, ordered(1e10, 'y')), bulletList(ordered(1e10, 'z'))), '- - 10000000000. y\n- - 10000000000. z\n')
|
||||||
|
const column = (list: AdfNode): AdfNode => node('layoutColumn', {}, list)
|
||||||
|
assert.equal(plain(node('layoutSection', {}, column(bulletList(item(said('a')))), column(bulletList(item(said('b')))))), '- a\n- b\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the nodes the plain flavour spells and degrades only what it cannot', () => {
|
||||||
|
const tasks = node('taskList', {}, node('taskItem', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', state: 'DONE' }, text('t')))
|
||||||
|
const reduced = reduceToPlain(document(node('panel', { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType: 'info' }, said('x')), tasks))
|
||||||
|
assert.deepEqual(reduced.ok ? reduced.value : undefined, document(node('panel', { panelType: 'info' }, said('x')), { content: [node('taskItem', { state: 'DONE' }, text('t'))], type: 'taskList' }))
|
||||||
|
})
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||||
|
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||||
|
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from './adf-to-markdown.ts'
|
||||||
|
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||||
|
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
|
import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './plain-inline.ts'
|
||||||
|
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||||
|
import { languageSlot } from '../code-language.ts'
|
||||||
|
import { largestNesting } from '../../nesting.ts'
|
||||||
|
import { taskMarker } from '../plain-conventions.ts'
|
||||||
|
|
||||||
|
// depth: the level the node reduced stands at, counted as the emitter counts it.
|
||||||
|
type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
|
||||||
|
|
||||||
|
type BlockReducer = (node: AdfNode, reduction: Reduction) => Result<AdfNode[]>
|
||||||
|
|
||||||
|
type PlacedCell = { colspan: number; paragraph: AdfNode; rowspan: number }
|
||||||
|
|
||||||
|
type Placed = { index: number; loose: AdfNode[] } | { index: number; loose?: undefined; node: AdfNode }
|
||||||
|
|
||||||
|
const blockReducers: Readonly<Record<string, BlockReducer>> = {
|
||||||
|
blockCard: paragraphOfNode,
|
||||||
|
blockquote: (node, reduction) => contained({ type: 'blockquote' }, node, reduction),
|
||||||
|
bulletList: reduceList,
|
||||||
|
caption: (node, reduction) => paragraphOf(nodeContent(node), reduction),
|
||||||
|
codeBlock: reduceCodeBlock,
|
||||||
|
decisionList: (node, reduction) => reduceItems(node, reduction, (child, at) => (child.type === 'decisionItem' ? paragraphOf(nodeContent(child), at) : reduceStanding(child, at))),
|
||||||
|
embedCard: paragraphOfNode,
|
||||||
|
expand: reduceExpand,
|
||||||
|
extension: paragraphOfNode,
|
||||||
|
heading: reduceHeading,
|
||||||
|
media: reduceMedia,
|
||||||
|
mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index) => reduceStanding(child, childReduction(reduction, index)))),
|
||||||
|
nestedExpand: reduceExpand,
|
||||||
|
orderedList: reduceList,
|
||||||
|
panel: reducePanel,
|
||||||
|
paragraph: (node, reduction) => paragraphOf(nodeContent(node), reduction),
|
||||||
|
rule: () => success([{ type: 'rule' }]),
|
||||||
|
syncBlock: paragraphOfNode,
|
||||||
|
table: reduceTable,
|
||||||
|
taskList: reduceTaskList,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adfToPlainMarkdown(document: AdfDocument): Result<string> {
|
||||||
|
const reduced = reduceToPlain(document)
|
||||||
|
return reduced.ok ? writeMarkdown(reduced.value, 'plain') : reduced
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
|
||||||
|
const fault = adfDocumentFault(document)
|
||||||
|
if (fault !== undefined) return faulted(fault, [])
|
||||||
|
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
|
||||||
|
const blocks = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
|
||||||
|
return blocks.ok ? success({ content: blocks.value, type: 'doc', version: 1 }) : blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
const placed: Placed[] = []
|
||||||
|
for (const [index, node] of nodes.entries()) {
|
||||||
|
const previous = placed[placed.length - 1]
|
||||||
|
if (!standsInline(node)) placed.push({ index, node })
|
||||||
|
else if (previous?.loose !== undefined) previous.loose.push(node)
|
||||||
|
else placed.push({ index, loose: [node] })
|
||||||
|
}
|
||||||
|
const blocks = concatenated(
|
||||||
|
placed.map((entry) => {
|
||||||
|
const at = { ...reduction, path: [...reduction.path, 'content', entry.index] }
|
||||||
|
return entry.loose === undefined ? reduceNode(entry.node, at) : paragraphOf(entry.loose, reduction)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return blocks.ok ? plainSequence(blocks.value, reduction) : blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceNode(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
if (reduction.depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, reduction.path)
|
||||||
|
const reducer = Object.hasOwn(blockReducers, node.type) ? blockReducers[node.type] : undefined
|
||||||
|
return (reducer ?? reduceBody)(node, reduction)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceStanding(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
const blocks = standsInline(node) ? paragraphOf([node], reduction) : reduceNode(node, reduction)
|
||||||
|
return blocks.ok ? plainSequence(blocks.value, reduction) : blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
function standsInline(node: AdfNode): boolean {
|
||||||
|
if (node.type === 'text' || inlineNodeModel(node.type) !== undefined) return true
|
||||||
|
return !isBlockNodeType(node.type) && node.content === undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceBody(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
if (blockNodeModel(node.type)?.contentModel === 'inline') return paragraphOf(nodeContent(node), reduction)
|
||||||
|
return reduceBlocks(nodeContent(node), { ...reduction, depth: reduction.depth + 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function childReduction(reduction: Reduction, index: number): Reduction {
|
||||||
|
return { ...reduction, depth: reduction.depth + 1, path: [...reduction.path, 'content', index] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatenated(results: readonly Result<AdfNode[]>[]): Result<AdfNode[]> {
|
||||||
|
const blocks: AdfNode[] = []
|
||||||
|
for (const result of results) {
|
||||||
|
if (!result.ok) return result
|
||||||
|
for (const block of result.value) blocks.push(block)
|
||||||
|
}
|
||||||
|
return success(blocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A list still taking the directive form gives way to its items' blocks.
|
||||||
|
function plainSequence(blocks: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
let sequence = mergedLists(blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0))
|
||||||
|
for (let index = 0; index < sequence.length; index += 1) {
|
||||||
|
const listed = sequence[index]
|
||||||
|
if (listed === undefined || (listed.type !== 'bulletList' && listed.type !== 'orderedList')) continue
|
||||||
|
const block = numberedPastMarkers(listed)
|
||||||
|
const spelled = block === listed && commonMarkSpelling(block, reduction.path, reduction.depth, { flavour: 'plain', memo: reduction.memo })?.ok === true
|
||||||
|
if (spelled) continue
|
||||||
|
sequence = spliced(sequence, index, block === listed ? nodeContent(block).flatMap(nodeContent) : [block])
|
||||||
|
index = Math.max(0, index - 1) - 1
|
||||||
|
}
|
||||||
|
return success(sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The replacement merges with the lists beside it, so no two lists of one type stand adjacent.
|
||||||
|
function spliced(sequence: readonly AdfNode[], index: number, replacement: readonly AdfNode[]): AdfNode[] {
|
||||||
|
const from = Math.max(0, index - 1)
|
||||||
|
return [...sequence.slice(0, from), ...mergedLists([...sequence.slice(from, index), ...replacement, ...sequence.slice(index + 1, index + 2)]), ...sequence.slice(index + 2)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// A numbered list whose markers run past CommonMark's keeps its numbers as text in a bullet list.
|
||||||
|
function numberedPastMarkers(list: AdfNode): AdfNode {
|
||||||
|
const order = nodeAttrs(list)['order']
|
||||||
|
if (list.type !== 'orderedList' || typeof order !== 'number' || order + nodeContent(list).length - 1 <= largestListMarker) return list
|
||||||
|
return numberedAsText(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberedAsText(list: AdfNode): AdfNode {
|
||||||
|
const order = Number(nodeAttrs(list)['order'])
|
||||||
|
return { content: nodeContent(list).map((item, offset) => itemOf(marked(nodeContent(item), `${order + offset}.`))), type: 'bulletList' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjacent lists of one marker read back as one list.
|
||||||
|
function mergedLists(blocks: readonly AdfNode[]): AdfNode[] {
|
||||||
|
const merged: AdfNode[] = []
|
||||||
|
for (const block of blocks) {
|
||||||
|
let next = block
|
||||||
|
for (let previous = merged.at(-1); previous !== undefined && listMarker(next) !== undefined && listMarker(previous) === listMarker(next); previous = merged.at(-1)) {
|
||||||
|
merged.pop()
|
||||||
|
next = joinedLists(previous, next)
|
||||||
|
}
|
||||||
|
merged.push(next)
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
function listMarker(block: AdfNode): string | undefined {
|
||||||
|
if (block.type === 'orderedList') return '.'
|
||||||
|
return block.type === 'bulletList' || block.type === 'taskList' ? '-' : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two numbered lists whose numbering breaks between them keep their numbers as text in one bullet list, and a task list joining a bullet list its markers.
|
||||||
|
function joinedLists(first: AdfNode, second: AdfNode): AdfNode {
|
||||||
|
const breaks = first.type === 'orderedList' && nodeAttrs(second)['order'] !== Number(nodeAttrs(first)['order']) + nodeContent(first).length
|
||||||
|
const [head, tail] = breaks ? [numberedAsText(first), numberedAsText(second)] : first.type === second.type ? [first, second] : [tasksAsText(first), tasksAsText(second)]
|
||||||
|
return { ...head, content: [...nodeContent(head), ...nodeContent(tail)] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A task keeps its marker as text; a list item stands as one, and anything else nests in the item before it.
|
||||||
|
function tasksAsText(list: AdfNode): AdfNode {
|
||||||
|
if (list.type !== 'taskList') return list
|
||||||
|
const items: AdfNode[] = []
|
||||||
|
for (const child of nodeContent(list)) {
|
||||||
|
const previous = isTask(child) || child.type === 'listItem' ? undefined : items.pop()
|
||||||
|
items.push(itemOf(previous === undefined ? taskAsText(child) : mergedLists([...nodeContent(previous), child])))
|
||||||
|
}
|
||||||
|
return { content: items, type: 'bulletList' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskAsText(child: AdfNode): readonly AdfNode[] {
|
||||||
|
const marker = taskMarker(nodeAttrs(child)['state'])
|
||||||
|
if (child.type === 'taskItem') return [paragraph(nodeContent(child).length === 0 ? [text(marker)] : [text(`${marker} `), ...nodeContent(child)])]
|
||||||
|
if (child.type === 'blockTaskItem') return marked(nodeContent(child), marker)
|
||||||
|
return child.type === 'listItem' ? nodeContent(child) : [child]
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraph(content: readonly AdfNode[]): AdfNode {
|
||||||
|
return { content: [...content], type: 'paragraph' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: string): AdfNode {
|
||||||
|
return { text: value, type: 'text' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function listOf(items: readonly AdfNode[], type: string): AdfNode[] {
|
||||||
|
return items.length === 0 ? [] : [{ content: [...items], type }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphOf(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
const content = reduceInline(nodes, 'paragraph', reduction.path, reduction.depth)
|
||||||
|
return content.ok ? success(content.value.length === 0 ? [] : [paragraph(content.value)]) : content
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphOfNode(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
return paragraphOf([node], reduction)
|
||||||
|
}
|
||||||
|
|
||||||
|
function contained(shell: AdfNode, node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
const content = reduceBlocks(nodeContent(node), { ...reduction, depth: reduction.depth + 1 })
|
||||||
|
return content.ok ? success([{ ...shell, content: content.value }]) : content
|
||||||
|
}
|
||||||
|
|
||||||
|
function reducePanel(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
const panelType = nodeAttrs(node)['panelType']
|
||||||
|
return contained(typeof panelType === 'string' ? { attrs: { panelType }, type: 'panel' } : { type: 'panel' }, node, reduction)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceExpand(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
const held = nodeAttrs(node)['title']
|
||||||
|
const title = typeof held === 'string' ? withoutTrailingBlanks(oneLine(held).replace(/^[ \t]+/, '')) : ''
|
||||||
|
return contained(title === '' ? { type: node.type } : { attrs: { title }, type: node.type }, node, reduction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A backward scan: an unanchored-end regex retries from every blank in a long run.
|
||||||
|
function withoutTrailingBlanks(text: string): string {
|
||||||
|
let end = text.length
|
||||||
|
while (end > 0 && (text.charAt(end - 1) === ' ' || text.charAt(end - 1) === '\t')) end -= 1
|
||||||
|
return text.slice(0, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceHeading(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
const level = nodeAttrs(node)['level']
|
||||||
|
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return paragraphOf(nodeContent(node), reduction)
|
||||||
|
const content = reduceInline(nodeContent(node), 'heading', reduction.path, reduction.depth)
|
||||||
|
return content.ok ? success([{ attrs: { level }, content: content.value, type: 'heading' }]) : content
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceCodeBlock(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
const leaves = inlineLeaves(nodeContent(node), 'paragraph', reduction.path, reduction.depth)
|
||||||
|
if (!leaves.ok) return leaves
|
||||||
|
const code = leaves.value.map((leaf) => leaf.text ?? '\n').join('')
|
||||||
|
const slot = languageSlot(nodeAttrs(node)['language'])
|
||||||
|
const block: AdfNode = { content: code === '' ? [] : [text(code)], type: 'codeBlock' }
|
||||||
|
return success([slot.kind === 'fence' ? { ...block, attrs: { language: slot.info } } : block])
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
const listed = reduceItems(node, reduction, (child, at) => (child.type === 'listItem' ? reduceBlocks(nodeContent(child), at) : reduceStanding(child, at)))
|
||||||
|
if (!listed.ok || node.type !== 'orderedList') return listed
|
||||||
|
const order = nodeAttrs(node)['order']
|
||||||
|
const start = typeof order === 'number' && Number.isInteger(order) && order >= 0 ? order : 1
|
||||||
|
return success(listed.value.map((list) => ({ ...list, attrs: { order: start } })))
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceItems(node: AdfNode, reduction: Reduction, itemBlocks: (child: AdfNode, at: Reduction) => Result<AdfNode[]>): Result<AdfNode[]> {
|
||||||
|
const items = concatenated(nodeContent(node).map((child, index) => listItem(itemBlocks(child, childReduction(reduction, index)))))
|
||||||
|
return items.ok ? success(listOf(items.value, node.type === 'orderedList' ? 'orderedList' : 'bulletList')) : items
|
||||||
|
}
|
||||||
|
|
||||||
|
function listItem(blocks: Result<AdfNode[]>): Result<AdfNode[]> {
|
||||||
|
return blocks.ok ? success([itemOf(blocks.value)]) : blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
// A list item's first line reads as no rule and holds no line of spaces alone: the rule and the spaces give way.
|
||||||
|
function itemOf(blocks: readonly AdfNode[]): AdfNode {
|
||||||
|
const rules = blocks.findIndex((block) => block.type !== 'rule')
|
||||||
|
return { content: blankedCode(blocks.slice(rules === -1 ? blocks.length : rules)), type: 'listItem' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankedCode(blocks: readonly AdfNode[]): AdfNode[] {
|
||||||
|
return blocks.map((block) => (block.type === 'codeBlock' ? { ...block, content: blankedLines(nodeContent(block)) } : block))
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankedLines(code: readonly AdfNode[]): AdfNode[] {
|
||||||
|
const blanked = code.map((leaf) => leaf.text ?? '').join('').replace(/^[ \t]+$/gm, '')
|
||||||
|
return blanked === '' ? [] : [text(blanked)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// A task list opening with a task and holding tasks and task lists alone keeps its spelling, a list nesting in the task before it; any other keeps its markers as text. A child reducing to nothing counts for neither.
|
||||||
|
function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
const kept: { blocks: AdfNode[]; child: AdfNode }[] = []
|
||||||
|
for (const [index, child] of nodeContent(node).entries()) {
|
||||||
|
const at = childReduction(reduction, index)
|
||||||
|
const reduced = isTask(child) ? reduceTask(child, at) : reduceStanding(child, at)
|
||||||
|
if (!reduced.ok) return reduced
|
||||||
|
if (reduced.value.length > 0) kept.push({ blocks: reduced.value, child })
|
||||||
|
}
|
||||||
|
const regular = isTask(kept[0]?.child) && kept.every(({ child }) => isTask(child) || child.type === 'taskList')
|
||||||
|
const tasks: AdfNode[] = []
|
||||||
|
let nested: AdfNode[] = []
|
||||||
|
for (const { blocks, child } of kept) {
|
||||||
|
if (!regular) {
|
||||||
|
const standsAlone = !isTask(child) && child.type !== 'taskList'
|
||||||
|
for (const block of standsAlone ? [{ content: blocks, type: 'listItem' }] : blocks) tasks.push(block)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isTask(child)) {
|
||||||
|
nestIn(tasks, nested)
|
||||||
|
nested = []
|
||||||
|
}
|
||||||
|
for (const block of blocks) (isTask(child) ? tasks : nested).push(block)
|
||||||
|
}
|
||||||
|
nestIn(tasks, nested)
|
||||||
|
if (regular) return success([{ content: tasks, type: 'taskList' }])
|
||||||
|
return success(listOf(nodeContent(tasksAsText({ content: tasks, type: 'taskList' })), 'bulletList'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The writer nests a list in the task before it, so one closing a block task item's blocks merges with it.
|
||||||
|
function nestIn(tasks: AdfNode[], nested: readonly AdfNode[]): void {
|
||||||
|
const previous = tasks.at(-1)
|
||||||
|
if (previous?.type === 'blockTaskItem') tasks[tasks.length - 1] = { ...previous, content: mergedLists([...nodeContent(previous), ...nested]) }
|
||||||
|
else for (const block of mergedLists(nested)) tasks.push(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTask(node: AdfNode | undefined): boolean {
|
||||||
|
return node?.type === 'taskItem' || node?.type === 'blockTaskItem'
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceTask(task: AdfNode, at: Reduction): Result<AdfNode[]> {
|
||||||
|
const attrs = { state: nodeAttrs(task)['state'] === 'DONE' ? 'DONE' : 'TODO' }
|
||||||
|
if (task.type === 'taskItem') {
|
||||||
|
const content = reduceInline(nodeContent(task), 'paragraph', at.path, at.depth)
|
||||||
|
return content.ok ? success([{ attrs, content: content.value, type: 'taskItem' }]) : content
|
||||||
|
}
|
||||||
|
const blocks = reduceBlocks(nodeContent(task), at)
|
||||||
|
return blocks.ok ? success([{ attrs, content: blankedCode(blocks.value), type: 'blockTaskItem' }]) : blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
// The marker leads the first paragraph, or stands as one where the blocks open with another.
|
||||||
|
function marked(blocks: readonly AdfNode[], marker: string): AdfNode[] {
|
||||||
|
const [first, ...rest] = blocks
|
||||||
|
if (first?.type === 'paragraph') return [paragraph([text(`${marker} `), ...nodeContent(first)]), ...rest]
|
||||||
|
return [paragraph([text(marker)]), ...blocks]
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceTable(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
const rows: PlacedCell[][] = []
|
||||||
|
for (const [rowIndex, row] of nodeContent(node).entries()) {
|
||||||
|
const rowReduction = childReduction(reduction, rowIndex)
|
||||||
|
const cells: PlacedCell[] = []
|
||||||
|
for (const [cellIndex, cell] of (row.type === 'tableRow' ? nodeContent(row) : [row]).entries()) {
|
||||||
|
const paragraph = cellParagraph(cell, childReduction(rowReduction, cellIndex))
|
||||||
|
if (!paragraph.ok) return paragraph
|
||||||
|
cells.push({ colspan: span(nodeAttrs(cell)['colspan']), paragraph: paragraph.value, rowspan: span(nodeAttrs(cell)['rowspan']) })
|
||||||
|
}
|
||||||
|
rows.push(cells)
|
||||||
|
}
|
||||||
|
const grid = spannedGrid(rows)
|
||||||
|
const width = grid.reduce((widest, cells) => Math.max(widest, cells.length), 0)
|
||||||
|
const tableRows = grid.map((cells, rowIndex) => ({
|
||||||
|
content: Array.from({ length: width }, (_, column): AdfNode => ({ content: [cells[column] ?? { type: 'paragraph' }], type: rowIndex === 0 ? 'tableHeader' : 'tableCell' })),
|
||||||
|
type: 'tableRow',
|
||||||
|
}))
|
||||||
|
return success(width === 0 ? [] : [{ content: tableRows, type: 'table' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
function span(value: unknown): number {
|
||||||
|
return typeof value === 'number' && Number.isInteger(value) && value > 1 ? value : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// A span keeps its cell under its header by empty cells where it covered; they number no more than the table's cells.
|
||||||
|
function spannedGrid(rows: readonly PlacedCell[][]): (AdfNode | undefined)[][] {
|
||||||
|
const grid: (AdfNode | undefined)[][] = rows.map(() => [])
|
||||||
|
const covered = rows.map(() => new Set<number>())
|
||||||
|
let padding = rows.reduce((count, cells) => count + cells.length, 0)
|
||||||
|
for (const [rowIndex, cells] of rows.entries()) {
|
||||||
|
let column = 0
|
||||||
|
for (const cell of cells) {
|
||||||
|
while (covered[rowIndex]?.has(column) === true) column += 1
|
||||||
|
setCell(grid, rowIndex, column, cell.paragraph)
|
||||||
|
for (let row = rowIndex; row < Math.min(rows.length, rowIndex + cell.rowspan) && padding > 0; row += 1) {
|
||||||
|
for (let spanned = row === rowIndex ? 1 : 0; spanned < cell.colspan && padding > 0; spanned += 1) {
|
||||||
|
covered[row]?.add(column + spanned)
|
||||||
|
setCell(grid, row, column + spanned, { type: 'paragraph' })
|
||||||
|
padding -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
column += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return grid
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCell(grid: (AdfNode | undefined)[][], row: number, column: number, cell: AdfNode): void {
|
||||||
|
const cells = grid[row]
|
||||||
|
if (cells !== undefined && cells[column] === undefined) cells[column] = cell
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellParagraph(cell: AdfNode, reduction: Reduction): Result<AdfNode> {
|
||||||
|
const blocks = cell.type === 'tableCell' || cell.type === 'tableHeader' ? nodeContent(cell) : [cell]
|
||||||
|
const content = reduceInline(blocks, 'table-cell', reduction.path, reduction.depth)
|
||||||
|
return content.ok ? success(content.value.length === 0 ? { type: 'paragraph' } : paragraph(content.value)) : content
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceMedia(media: AdfNode, reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
const attrs = nodeAttrs(media)
|
||||||
|
const url = attrs['url']
|
||||||
|
if (attrs['type'] !== 'external' || typeof url !== 'string') return paragraphOfNode(media, reduction)
|
||||||
|
const held = attrs['alt']
|
||||||
|
const alt = typeof held === 'string' ? oneLine(held).trim() : ''
|
||||||
|
const external: AdfNode = { attrs: alt === '' ? { type: 'external', url: writableHref(url) } : { alt, type: 'external', url: writableHref(url) }, type: 'media' }
|
||||||
|
const image: AdfNode = { attrs: { layout: 'center' }, content: [external], type: 'mediaSingle' }
|
||||||
|
return success([image])
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type LineContainer = 'heading' | 'paragraph' | 'table-cell'
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { spellDirectiveOpener } from './directive-syntax.ts'
|
|
||||||
|
|
||||||
export const listBreakName = 'listBreak'
|
|
||||||
|
|
||||||
export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts'
|
import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts'
|
||||||
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||||
import type { MarkType } from '../adf/mark-attributes.ts'
|
import type { MarkType } from '../adf/mark-attributes.ts'
|
||||||
import { escapeUnbalanced, spellLinkTarget } from './link-syntax.ts'
|
import { escapeUnbalanced, spellLinkTarget } from './commonmark/link-syntax.ts'
|
||||||
import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive-syntax.ts'
|
import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive-syntax.ts'
|
||||||
import { holdsEntityReference } from './entity-references.ts'
|
import { holdsEntityReference } from './commonmark/entity-references.ts'
|
||||||
import { isAutolink } from './commonmark-grammar.ts'
|
import { isAutolink } from './commonmark/grammar.ts'
|
||||||
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
|
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
|
||||||
import { nodeAttrs, nodeMarks } from '../adf/document.ts'
|
import { nodeAttrs, nodeMarks } from '../adf/document.ts'
|
||||||
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
|
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
|
||||||
@@ -35,6 +35,11 @@ export function markSpelling(type: string): MarkSpelling | undefined {
|
|||||||
return { attributes, kind: spelling.kind }
|
return { attributes, kind: spelling.kind }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function linkHref(attrs: AdfAttributes): string | undefined {
|
||||||
|
const href = attrs['href']
|
||||||
|
return typeof href === 'string' ? href : undefined
|
||||||
|
}
|
||||||
|
|
||||||
// spec/flavour.md, Marks. `marksInside` counts the marks the link's nodes carry within it: the emitter's depth + 1, the parser's 0.
|
// spec/flavour.md, Marks. `marksInside` counts the marks the link's nodes carry within it: the emitter's depth + 1, the parser's 0.
|
||||||
export function commonMarkLink(attrs: AdfAttributes, href: string, nodes: readonly AdfNode[], marksInside: number, bracketed: boolean): CommonMarkLink | undefined {
|
export function commonMarkLink(attrs: AdfAttributes, href: string, nodes: readonly AdfNode[], marksInside: number, bracketed: boolean): CommonMarkLink | undefined {
|
||||||
if (Object.keys(attrs).some((key) => key !== 'href' && key !== 'title')) return undefined
|
if (Object.keys(attrs).some((key) => key !== 'href' && key !== 'title')) return undefined
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import type { AdfNode } from '../adf/document.ts'
|
|||||||
import type { DirectiveSpan, Read } from './directive-syntax.ts'
|
import type { DirectiveSpan, Read } from './directive-syntax.ts'
|
||||||
import type { JsonSpelling } from '../canonical-json.ts'
|
import type { JsonSpelling } from '../canonical-json.ts'
|
||||||
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
|
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
|
||||||
|
import { fencedCodeBlock } from './commonmark/backtick-runs.ts'
|
||||||
import { isAdfNode } from '../adf/document.ts'
|
import { isAdfNode } from '../adf/document.ts'
|
||||||
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
|
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
|
||||||
import { fencedCodeBlock } from './backtick-runs.ts'
|
|
||||||
import { largestNesting } from '../nesting.ts'
|
import { largestNesting } from '../nesting.ts'
|
||||||
import { malformedDirective, readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
|
import { malformedDirective, readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
|
||||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import type { Block } from './blocks.ts'
|
import type { Block } from './blocks.ts'
|
||||||
import type { LinkDefinition } from '../link-syntax.ts'
|
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
|
||||||
import { parseBlocks } from './blocks.ts'
|
import { parseBlocks } from './blocks.ts'
|
||||||
|
|
||||||
function definitions(markdown: string): [string, LinkDefinition][] {
|
function definitions(markdown: string): [string, LinkDefinition][] {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ConvertFault, SourcePosition } from '../../result.ts'
|
import type { ConvertFault, SourcePosition } from '../../result.ts'
|
||||||
import type { DirectiveAttributes, DirectiveLine } from '../directive-syntax.ts'
|
import type { DirectiveAttributes, DirectiveLine } from '../directive-syntax.ts'
|
||||||
import type { LinkDefinition } from '../link-syntax.ts'
|
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
|
||||||
import {
|
import {
|
||||||
atxHeading,
|
atxHeading,
|
||||||
claimsPipeLine,
|
claimsPipeLine,
|
||||||
@@ -17,11 +17,11 @@ import {
|
|||||||
setextHeadingLevel,
|
setextHeadingLevel,
|
||||||
thematicBreakTail,
|
thematicBreakTail,
|
||||||
type ThematicBreakTail,
|
type ThematicBreakTail,
|
||||||
} from '../commonmark-grammar.ts'
|
} from '../commonmark/grammar.ts'
|
||||||
import { blockDirectiveForm } from '../block-directive-forms.ts'
|
|
||||||
import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts'
|
|
||||||
import { barePipeCells, isDelimiterRow, isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
|
import { barePipeCells, isDelimiterRow, isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
|
||||||
import { readLinkDefinitions } from '../link-reference-definitions.ts'
|
import { blockDirectiveForm } from '../block-directive.ts'
|
||||||
|
import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts'
|
||||||
|
import { readLinkDefinitions } from '../commonmark/link-reference-definitions.ts'
|
||||||
|
|
||||||
export type Block = { position: SourcePosition } & (
|
export type Block = { position: SourcePosition } & (
|
||||||
| { argument: string | undefined; attributes: DirectiveAttributes; blocks: Block[] | undefined; kind: 'directive'; name: string }
|
| { argument: string | undefined; attributes: DirectiveAttributes; blocks: Block[] | undefined; kind: 'directive'; name: string }
|
||||||
@@ -43,7 +43,7 @@ export type DirectiveBlock = Extract<Block, { kind: 'directive' }>
|
|||||||
|
|
||||||
type ListBlock = Extract<Block, { items: Block[][] }>
|
type ListBlock = Extract<Block, { items: Block[][] }>
|
||||||
|
|
||||||
type OpenDirective = { blocks: Block[]; depths: number[]; index: number; kind: 'directive'; name: string; parent: Block[]; position: SourcePosition }
|
type OpenDirective = { blocks: Block[]; index: number; kind: 'directive'; name: string; parent: Block[]; position: SourcePosition }
|
||||||
|
|
||||||
type EdgeContainer = Extract<Block, { kind: 'blockquote' }> | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock }
|
type EdgeContainer = Extract<Block, { kind: 'blockquote' }> | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock }
|
||||||
|
|
||||||
@@ -64,13 +64,19 @@ type Line = { column: number; text: string }
|
|||||||
|
|
||||||
type LeafOpener = { index: number; position: SourcePosition }
|
type LeafOpener = { index: number; position: SourcePosition }
|
||||||
|
|
||||||
|
type ContainerStack = {
|
||||||
|
directiveDepth: (name: string) => number | undefined
|
||||||
|
drop: (depth: number) => OpenContainer[]
|
||||||
|
edges: readonly { container: EdgeContainer; depth: number }[]
|
||||||
|
open: readonly OpenContainer[]
|
||||||
|
push: (container: OpenContainer) => void
|
||||||
|
}
|
||||||
|
|
||||||
type Walk = ParsedBlocks & {
|
type Walk = ParsedBlocks & {
|
||||||
directiveDepths: Map<string, number[]>
|
|
||||||
edges: { container: EdgeContainer; depth: number }[]
|
|
||||||
leaf: OpenLeaf | undefined
|
leaf: OpenLeaf | undefined
|
||||||
leafOpeners: Map<Block[], Map<string, LeafOpener>>
|
leafOpeners: Map<Block[], Map<string, LeafOpener>>
|
||||||
position: SourcePosition
|
position: SourcePosition
|
||||||
stack: OpenContainer[]
|
stack: ContainerStack
|
||||||
}
|
}
|
||||||
|
|
||||||
const indentedCodeColumns = 4
|
const indentedCodeColumns = 4
|
||||||
@@ -81,12 +87,10 @@ export function parseBlocks(markdown: string): ParsedBlocks {
|
|||||||
const walk: Walk = {
|
const walk: Walk = {
|
||||||
blocks: [],
|
blocks: [],
|
||||||
definitions: new Map(),
|
definitions: new Map(),
|
||||||
directiveDepths: new Map(),
|
|
||||||
edges: [],
|
|
||||||
leaf: undefined,
|
leaf: undefined,
|
||||||
leafOpeners: new Map(),
|
leafOpeners: new Map(),
|
||||||
position: { line: 1, offset: 0 },
|
position: { line: 1, offset: 0 },
|
||||||
stack: [],
|
stack: containerStack(),
|
||||||
}
|
}
|
||||||
for (const line of sourceLines(markdown)) {
|
for (const line of sourceLines(markdown)) {
|
||||||
walk.position = line.position
|
walk.position = line.position
|
||||||
@@ -96,16 +100,41 @@ export function parseBlocks(markdown: string): ParsedBlocks {
|
|||||||
return { blocks: walk.blocks, definitions: walk.definitions }
|
return { blocks: walk.blocks, definitions: walk.definitions }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function containerStack(): ContainerStack {
|
||||||
|
const directiveDepths = new Map<string, number[]>()
|
||||||
|
const depthsOf = (name: string): number[] => entryOf(directiveDepths, name, () => [])
|
||||||
|
const edges: { container: EdgeContainer; depth: number }[] = []
|
||||||
|
const open: OpenContainer[] = []
|
||||||
|
return {
|
||||||
|
directiveDepth: (name) => directiveDepths.get(name)?.at(-1),
|
||||||
|
drop: (depth) => {
|
||||||
|
const dropped = open.splice(depth)
|
||||||
|
for (const container of dropped) {
|
||||||
|
if (container.kind === 'directive') depthsOf(container.name).pop()
|
||||||
|
else edges.pop()
|
||||||
|
}
|
||||||
|
return dropped
|
||||||
|
},
|
||||||
|
edges,
|
||||||
|
open,
|
||||||
|
push: (container) => {
|
||||||
|
const depth = open.push(container) - 1
|
||||||
|
if (container.kind === 'directive') depthsOf(container.name).push(depth)
|
||||||
|
else edges.push({ container, depth })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readLine(walk: Walk, line: Line): void {
|
function readLine(walk: Walk, line: Line): void {
|
||||||
const matched = matchContainers(walk, line)
|
const matched = matchContainers(walk, line)
|
||||||
// CommonMark: no container opens inside an open code or HTML block.
|
// CommonMark: no container opens inside an open code or HTML block.
|
||||||
if (matched.depth === walk.stack.length && swallowsLines(walk.leaf)) {
|
if (matched.depth === walk.stack.open.length && swallowsLines(walk.leaf)) {
|
||||||
readBlockLine(walk, matched.rest)
|
readBlockLine(walk, matched.rest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const paragraphOpen = matched.depth === walk.stack.length && walk.leaf?.kind === 'paragraph'
|
const paragraphOpen = matched.depth === walk.stack.open.length && walk.leaf?.kind === 'paragraph'
|
||||||
const opened = openContainers(walk, matched.rest, paragraphOpen, matched.depth)
|
const opened = openContainers(walk, matched.rest, paragraphOpen, matched.depth)
|
||||||
if (!opened.opened && matched.depth < walk.stack.length) {
|
if (!opened.opened && matched.depth < walk.stack.open.length) {
|
||||||
if (continuesLazily(walk, opened.rest)) {
|
if (continuesLazily(walk, opened.rest)) {
|
||||||
appendParagraph(walk, opened.rest.text)
|
appendParagraph(walk, opened.rest.text)
|
||||||
return
|
return
|
||||||
@@ -122,12 +151,12 @@ function swallowsLines(leaf: OpenLeaf | undefined): boolean {
|
|||||||
// A directive container has no continuation marker, so every line continues it.
|
// A directive container has no continuation marker, so every line continues it.
|
||||||
function matchContainers(walk: Walk, line: Line): { depth: number; rest: Line } {
|
function matchContainers(walk: Walk, line: Line): { depth: number; rest: Line } {
|
||||||
let rest = line
|
let rest = line
|
||||||
for (const { container, depth } of walk.edges) {
|
for (const { container, depth } of walk.stack.edges) {
|
||||||
const next = continuesContainer(walk, container, rest)
|
const next = continuesContainer(walk, container, rest)
|
||||||
if (next === undefined) return { depth, rest }
|
if (next === undefined) return { depth, rest }
|
||||||
rest = next
|
rest = next
|
||||||
}
|
}
|
||||||
return { depth: walk.stack.length, rest }
|
return { depth: walk.stack.open.length, rest }
|
||||||
}
|
}
|
||||||
|
|
||||||
function continuesContainer(walk: Walk, container: EdgeContainer, line: Line): Line | undefined {
|
function continuesContainer(walk: Walk, container: EdgeContainer, line: Line): Line | undefined {
|
||||||
@@ -145,7 +174,7 @@ function blockquoteRest(opener: Line): Line | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: number): { opened: boolean; rest: Line } {
|
function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: number): { opened: boolean; rest: Line } {
|
||||||
const unmatched = walk.stack[depth]
|
const unmatched = walk.stack.open[depth]
|
||||||
const tail = thematicBreakTail(line.text)
|
const tail = thematicBreakTail(line.text)
|
||||||
let opened = false
|
let opened = false
|
||||||
let rest = line
|
let rest = line
|
||||||
@@ -202,16 +231,12 @@ function openContainer(walk: Walk, start: ContainerStart): void {
|
|||||||
if (start.kind === 'blockquote') {
|
if (start.kind === 'blockquote') {
|
||||||
const blockquote: EdgeContainer = { blocks, kind: 'blockquote', position: walk.position }
|
const blockquote: EdgeContainer = { blocks, kind: 'blockquote', position: walk.position }
|
||||||
currentBlocks(walk).push(blockquote)
|
currentBlocks(walk).push(blockquote)
|
||||||
pushEdge(walk, blockquote)
|
walk.stack.push(blockquote)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const list = openedList(walk, start)
|
const list = openedList(walk, start)
|
||||||
list.items.push(blocks)
|
list.items.push(blocks)
|
||||||
pushEdge(walk, { blocks, indentation: start.indentation, kind: 'item', list })
|
walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list })
|
||||||
}
|
|
||||||
|
|
||||||
function pushEdge(walk: Walk, container: EdgeContainer): void {
|
|
||||||
walk.edges.push({ container, depth: walk.stack.push(container) - 1 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md).
|
// Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md).
|
||||||
@@ -226,7 +251,7 @@ function openedList(walk: Walk, start: Extract<ContainerStart, { kind: 'item' }>
|
|||||||
|
|
||||||
function closeContainers(walk: Walk, depth: number): void {
|
function closeContainers(walk: Walk, depth: number): void {
|
||||||
closeLeaf(walk)
|
closeLeaf(walk)
|
||||||
for (const container of dropContainers(walk, depth)) {
|
for (const container of walk.stack.drop(depth)) {
|
||||||
if (container.kind !== 'directive') continue
|
if (container.kind !== 'directive') continue
|
||||||
container.parent[container.index] = {
|
container.parent[container.index] = {
|
||||||
fault: malformedDirective(`the ${container.name} container is unclosed: no ${spellDirectiveCloser(container.name)} follows inside the block holding it; ${directiveEscape}`),
|
fault: malformedDirective(`the ${container.name} container is unclosed: no ${spellDirectiveCloser(container.name)} follows inside the block holding it; ${directiveEscape}`),
|
||||||
@@ -236,15 +261,6 @@ function closeContainers(walk: Walk, depth: number): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dropContainers(walk: Walk, depth: number): OpenContainer[] {
|
|
||||||
const dropped = walk.stack.splice(depth)
|
|
||||||
for (const container of dropped) {
|
|
||||||
if (container.kind === 'directive') container.depths.pop()
|
|
||||||
else walk.edges.pop()
|
|
||||||
}
|
|
||||||
return dropped
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void {
|
function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void {
|
||||||
if (directive.kind === 'closer') closeDirective(walk, directive.name)
|
if (directive.kind === 'closer') closeDirective(walk, directive.name)
|
||||||
else openDirective(walk, directive)
|
else openDirective(walk, directive)
|
||||||
@@ -267,8 +283,7 @@ function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'op
|
|||||||
entryOf(walk.leafOpeners, parent, () => new Map<string, LeafOpener>()).set(name, { index, position })
|
entryOf(walk.leafOpeners, parent, () => new Map<string, LeafOpener>()).set(name, { index, position })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const depths = entryOf(walk.directiveDepths, name, (): number[] => [])
|
walk.stack.push({ blocks: block.blocks, index, kind: 'directive', name, parent, position })
|
||||||
depths.push(walk.stack.push({ blocks: block.blocks, depths, index, kind: 'directive', name, parent, position }) - 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDirective(walk: Walk, name: string): void {
|
function closeDirective(walk: Walk, name: string): void {
|
||||||
@@ -283,13 +298,13 @@ function closeDirective(walk: Walk, name: string): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
closeContainers(walk, depth + 1)
|
closeContainers(walk, depth + 1)
|
||||||
dropContainers(walk, depth)
|
walk.stack.drop(depth)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A closer crosses no list item or blockquote edge.
|
// A closer crosses no list item or blockquote edge.
|
||||||
function openDirectiveDepth(walk: Walk, name: string): number | undefined {
|
function openDirectiveDepth(walk: Walk, name: string): number | undefined {
|
||||||
const depth = walk.directiveDepths.get(name)?.at(-1)
|
const depth = walk.stack.directiveDepth(name)
|
||||||
return depth === undefined || depth < (walk.edges.at(-1)?.depth ?? -1) ? undefined : depth
|
return depth === undefined || depth < (walk.stack.edges.at(-1)?.depth ?? -1) ? undefined : depth
|
||||||
}
|
}
|
||||||
|
|
||||||
function faultLeafOpener(walk: Walk, name: string, fault: ConvertFault): void {
|
function faultLeafOpener(walk: Walk, name: string, fault: ConvertFault): void {
|
||||||
@@ -497,7 +512,7 @@ function takeParagraph(walk: Walk): Extract<Block, { kind: 'paragraph' }> | unde
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentBlocks(walk: Walk): Block[] {
|
function currentBlocks(walk: Walk): Block[] {
|
||||||
return walk.stack.at(-1)?.blocks ?? walk.blocks
|
return walk.stack.open.at(-1)?.blocks ?? walk.blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
function* sourceLines(markdown: string): Generator<{ position: SourcePosition; text: string }> {
|
function* sourceLines(markdown: string): Generator<{ position: SourcePosition; text: string }> {
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
|
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
import type { BlockDirective } from '../../adf/block-directives.ts'
|
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
||||||
import type { ConvertFault } from '../../result.ts'
|
import type { ConvertFault } from '../../result.ts'
|
||||||
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
|
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
|
||||||
import type { Elsewhere } from './directive-attributes.ts'
|
import type { Elsewhere } from './directive-attributes.ts'
|
||||||
import { attributeNestingMessage, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
import { attributeNestingMessage, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||||
import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
|
import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
|
||||||
import { blockArgument } from '../block-directive-arguments.ts'
|
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts'
|
||||||
import { blockDirective } from '../../adf/block-directives.ts'
|
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||||
import { blockDirectiveForm } from '../block-directive-forms.ts'
|
|
||||||
import { carryName } from '../opaque-carry.ts'
|
import { carryName } from '../opaque-carry.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 { inlineMarkSpellingFault } from './directive-marks.ts'
|
import { inlineMarkSpellingFault } from './directive-marks.ts'
|
||||||
import { marksAttribute, readMarkValues } from '../block-directive-marks.ts'
|
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||||
import { readVocabulary } from './directive-attributes.ts'
|
import { readVocabulary } from './directive-attributes.ts'
|
||||||
import { slotLineEndingFault } from '../directive-syntax.ts'
|
import { slotLineEndingFault } from '../directive-syntax.ts'
|
||||||
import { textDirectiveName } from '../text-directive.ts'
|
import { textDirectiveName } from '../text-directive.ts'
|
||||||
|
|
||||||
export type BlockDirectiveNode = { contentModel: BlockDirective['contentModel']; node: AdfNode }
|
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; node: AdfNode }
|
||||||
|
|
||||||
export function readBlockDirectiveNode(
|
export function readBlockDirectiveNode(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -28,13 +26,13 @@ export function readBlockDirectiveNode(
|
|||||||
if (name === carryName) {
|
if (name === carryName) {
|
||||||
return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryName} fence`, path)
|
return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryName} fence`, path)
|
||||||
}
|
}
|
||||||
const directive = blockDirective(name)
|
const model = blockNodeModel(name)
|
||||||
if (directive === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path)
|
if (model === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path)
|
||||||
const argumentKey = blockArgument(name)
|
const argumentKey = blockArgument(name)
|
||||||
const rest = new Map(attributes)
|
const rest = new Map(attributes)
|
||||||
rest.delete(marksAttribute)
|
rest.delete(marksAttribute)
|
||||||
const elsewhere: Elsewhere | undefined = argumentKey === undefined ? undefined : { key: argumentKey, slot: 'argument' }
|
const elsewhere: Elsewhere | undefined = argumentKey === undefined ? undefined : { key: argumentKey, slot: 'argument' }
|
||||||
const attrs = readVocabulary(name, rest, directive.attributes, elsewhere, path)
|
const attrs = readVocabulary(name, rest, model.attributes, elsewhere, path)
|
||||||
if (!attrs.ok) return attrs
|
if (!attrs.ok) return attrs
|
||||||
if (argument !== undefined) {
|
if (argument !== undefined) {
|
||||||
if (argumentKey === undefined) return failure('unsupported-node-shape', `${name} takes no argument: this one spells one`, path)
|
if (argumentKey === undefined) return failure('unsupported-node-shape', `${name} takes no argument: this one spells one`, path)
|
||||||
@@ -43,7 +41,7 @@ export function readBlockDirectiveNode(
|
|||||||
const spelled = attributes.get(marksAttribute)
|
const spelled = attributes.get(marksAttribute)
|
||||||
const marks: Result<AdfMark[] | undefined> = spelled === undefined ? success(undefined) : readMarks(name, spelled, path)
|
const marks: Result<AdfMark[] | undefined> = spelled === undefined ? success(undefined) : readMarks(name, spelled, path)
|
||||||
if (!marks.ok) return marks
|
if (!marks.ok) return marks
|
||||||
return success({ contentModel: directive.contentModel, node: namedNode(name, attrs.value, marks.value) })
|
return success({ contentModel: model.contentModel, node: namedNode(name, attrs.value, marks.value) })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readInlineDirectiveNode(
|
export function readInlineDirectiveNode(
|
||||||
@@ -52,12 +50,12 @@ export function readInlineDirectiveNode(
|
|||||||
content: readonly AdfNode[] | undefined,
|
content: readonly AdfNode[] | undefined,
|
||||||
path: ConvertErrorPath,
|
path: ConvertErrorPath,
|
||||||
): Result<AdfNode> {
|
): Result<AdfNode> {
|
||||||
const directive = inlineDirective(name)
|
const model = inlineNodeModel(name)
|
||||||
if (directive === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name), path)
|
if (model === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name), path)
|
||||||
const slot = directive.textAttribute
|
const slot = model.textAttribute
|
||||||
if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path)
|
if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path)
|
||||||
const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' }
|
const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' }
|
||||||
const attrs = readVocabulary(name, attributes, directive.attributes, elsewhere, path)
|
const attrs = readVocabulary(name, attributes, model.attributes, elsewhere, path)
|
||||||
if (!attrs.ok) return attrs
|
if (!attrs.ok) return attrs
|
||||||
if (slot !== undefined && content !== undefined) {
|
if (slot !== undefined && content !== undefined) {
|
||||||
const text = slotText(content)
|
const text = slotText(content)
|
||||||
@@ -71,11 +69,11 @@ export function readInlineDirectiveNode(
|
|||||||
return success(namedNode(name, attrs.value, undefined))
|
return success(namedNode(name, attrs.value, undefined))
|
||||||
}
|
}
|
||||||
|
|
||||||
// A name the other position spells names that spelling, never the code a later MINOR may fill (AGENTS.md §8).
|
// A name the other position spells names that spelling, never the code a later MINOR may fill (docs/decisions.md §Which code a cause takes).
|
||||||
function inlineSpellingFault(name: string): ConvertFault | undefined {
|
function inlineSpellingFault(name: string): ConvertFault | undefined {
|
||||||
const mark = inlineMarkSpellingFault(name)
|
const mark = inlineMarkSpellingFault(name)
|
||||||
if (mark !== undefined) return mark
|
if (mark !== undefined) return mark
|
||||||
if (inlineDirective(name) === undefined && name !== textDirectiveName) return undefined
|
if (inlineNodeModel(name) === undefined && name !== textDirectiveName) return undefined
|
||||||
return { code: 'unsupported-node-shape', message: `${name} takes the inline form, ${directivePrefix}${name}{…}, never the block form` }
|
return { code: 'unsupported-node-shape', message: `${name} takes the inline form, ${directivePrefix}${name}{…}, never the block form` }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts'
|
import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts'
|
||||||
import type { EmphasisPairing } from '../emphasis-matching.ts'
|
import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
|
||||||
import type { LineContainer } from '../emit/line-escaping.ts'
|
import type { Flavour } from '../plain-conventions.ts'
|
||||||
import type { LinkDefinition } from '../link-syntax.ts'
|
import type { LineContainer } from '../line-container.ts'
|
||||||
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark-grammar.ts'
|
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
|
||||||
import { backtickRun, closingBacktickRun } from '../backtick-runs.ts'
|
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||||
import { commonMarkLink } from '../mark-spellings.ts'
|
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||||
import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts'
|
import { commonMarkLink, linkHref } from '../mark-spellings.ts'
|
||||||
|
import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
|
||||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
import { inlineDirective } from '../../adf/inline-directives.ts'
|
import { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts'
|
||||||
import { mergeAdjacentText } from '../../adf/editor-normal.ts'
|
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||||
|
import { mergeAdjacentText, sameMarks } from '../../adf/editor-normal.ts'
|
||||||
|
import { noSpans, readInlineDirective } from '../directive-syntax.ts'
|
||||||
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||||
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts'
|
import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts'
|
||||||
import { openingLinkTakesDirective } from '../emit/inline-line.ts'
|
import { openingLinkTakesDirective } from '../emit/inline-line.ts'
|
||||||
import { readCarriedInline } from '../opaque-carry.ts'
|
import { readCarriedInline } from '../opaque-carry.ts'
|
||||||
import { readDirectiveMark } from './directive-marks.ts'
|
import { readDirectiveMark } from './directive-marks.ts'
|
||||||
import { noSpans, readInlineDirective } from '../directive-syntax.ts'
|
|
||||||
import { readInlineDirectiveNode } from './directive-nodes.ts'
|
import { readInlineDirectiveNode } from './directive-nodes.ts'
|
||||||
import { readTextDirective } from '../text-directive.ts'
|
import { readTextDirective } from '../text-directive.ts'
|
||||||
|
|
||||||
@@ -25,6 +27,8 @@ export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
|
|||||||
|
|
||||||
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
|
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
|
||||||
|
|
||||||
|
type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; line: number; opens: boolean; position: number }
|
||||||
|
|
||||||
type Pairing = EmphasisPairing<Run>
|
type Pairing = EmphasisPairing<Run>
|
||||||
|
|
||||||
type Piece =
|
type Piece =
|
||||||
@@ -33,13 +37,17 @@ type Piece =
|
|||||||
| { alt: string; kind: 'image'; node: AdfNode }
|
| { alt: string; kind: 'image'; node: AdfNode }
|
||||||
| { kind: 'nodes'; nodes: AdfNode[] }
|
| { kind: 'nodes'; nodes: AdfNode[] }
|
||||||
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
|
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
|
||||||
|
| { closes: boolean; kind: 'highlight'; opens: boolean }
|
||||||
|
|
||||||
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number }
|
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number }
|
||||||
|
|
||||||
// `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`.
|
// `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`.
|
||||||
type Scan = {
|
type Scan = {
|
||||||
container: LineContainer | undefined
|
container: LineContainer | undefined
|
||||||
|
// Pieces below this have been walked for openers to deactivate: an image close folds the link-marked piece into alt text, leaving this the only record that the brackets around it are doomed.
|
||||||
|
deactivatedBefore: number
|
||||||
definitions: LinkDefinitions
|
definitions: LinkDefinitions
|
||||||
|
highlights: boolean
|
||||||
openingSpellableLink: boolean
|
openingSpellableLink: boolean
|
||||||
path: ConvertErrorPath
|
path: ConvertErrorPath
|
||||||
pending: string
|
pending: string
|
||||||
@@ -51,27 +59,21 @@ type Scan = {
|
|||||||
type SlotContent = { carry: boolean; nodes: AdfNode[] }
|
type SlotContent = { carry: boolean; nodes: AdfNode[] }
|
||||||
|
|
||||||
const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
|
const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
|
||||||
|
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
|
||||||
|
const hreflessLink = 'the link mark spells its href: this one spells none'
|
||||||
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
|
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
|
||||||
|
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one'
|
||||||
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
|
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
|
||||||
|
|
||||||
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> {
|
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
|
||||||
return parseInline(source, definitions, path, container, noSpans)
|
return parseInline(source, definitions, path, container, noSpans, flavour === 'plain')
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans): Result<InlineContent> {
|
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans, highlights: boolean): Result<InlineContent> {
|
||||||
const scan: Scan = { container, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
|
const scan: Scan = { container, deactivatedBefore: 0, definitions, highlights, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
|
||||||
let index = 0
|
let index = 0
|
||||||
while (index < source.length) {
|
while (index < source.length) {
|
||||||
switch (source.charAt(index)) {
|
switch (source.charAt(index)) {
|
||||||
case '\\':
|
|
||||||
index = readBackslash(scan, index)
|
|
||||||
break
|
|
||||||
case '\n':
|
|
||||||
index = readLineEnding(scan, index)
|
|
||||||
break
|
|
||||||
case '`':
|
|
||||||
index = readBackticks(scan, index)
|
|
||||||
break
|
|
||||||
case '<': {
|
case '<': {
|
||||||
const angle = readAngle(scan, index)
|
const angle = readAngle(scan, index)
|
||||||
if (!angle.ok) return angle
|
if (!angle.ok) return angle
|
||||||
@@ -97,20 +99,34 @@ function parseInline(source: string, definitions: LinkDefinitions, path: Convert
|
|||||||
index = closed.value
|
index = closed.value
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case '*':
|
|
||||||
case '_':
|
|
||||||
case '~':
|
|
||||||
index = readDelimiterRun(scan, index)
|
|
||||||
break
|
|
||||||
default:
|
default:
|
||||||
scan.pending += source.charAt(index)
|
index = readCharacter(scan, index)
|
||||||
index += 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flush(scan, container !== undefined)
|
flush(scan, container !== undefined)
|
||||||
return assemble(scan)
|
return assemble(scan)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readCharacter(scan: Scan, index: number): number {
|
||||||
|
switch (scan.source.charAt(index)) {
|
||||||
|
case '\\':
|
||||||
|
return readBackslash(scan, index)
|
||||||
|
case '\n':
|
||||||
|
return readLineEnding(scan, index)
|
||||||
|
case '`':
|
||||||
|
return readBackticks(scan, index)
|
||||||
|
case '*':
|
||||||
|
case '_':
|
||||||
|
case '~':
|
||||||
|
return readDelimiterRun(scan, index)
|
||||||
|
case '=':
|
||||||
|
return readEquals(scan, index)
|
||||||
|
default:
|
||||||
|
scan.pending += scan.source.charAt(index)
|
||||||
|
return index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readBackslash(scan: Scan, index: number): number {
|
function readBackslash(scan: Scan, index: number): number {
|
||||||
if (scan.source.charAt(index + 1) === '\n') {
|
if (scan.source.charAt(index + 1) === '\n') {
|
||||||
// CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash.
|
// CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash.
|
||||||
@@ -208,15 +224,16 @@ function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotC
|
|||||||
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path)
|
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path)
|
||||||
}
|
}
|
||||||
if (slot.carry) return failure('unsupported-node-shape', carriedInMark, scan.path)
|
if (slot.carry) return failure('unsupported-node-shape', carriedInMark, scan.path)
|
||||||
const refused = mark.type === 'link' ? refuseSpellableLink(scan, mark, slot.nodes, index) : undefined
|
const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, slot.nodes, index) : undefined
|
||||||
if (refused !== undefined) return refused
|
if (refused !== undefined) return refused
|
||||||
return success({ kind: 'nodes', nodes: applyMark(slot.nodes, mark) })
|
return success({ kind: 'nodes', nodes: applyMark(slot.nodes, mark) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `assemble` asks the emitter.
|
// spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `assemble` asks the emitter.
|
||||||
function refuseSpellableLink(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[], index: number): Result<Piece> | undefined {
|
function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[], index: number): Result<Piece> | undefined {
|
||||||
const href = nodeAttrs(mark)['href']
|
const href = linkHref(nodeAttrs(mark))
|
||||||
if (typeof href !== 'string') return undefined
|
if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path)
|
||||||
|
if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.path)
|
||||||
if (commonMarkLink(nodeAttrs(mark), href, nodes, 0, scan.container === undefined) === undefined) return undefined
|
if (commonMarkLink(nodeAttrs(mark), href, nodes, 0, scan.container === undefined) === undefined) return undefined
|
||||||
if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path)
|
if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path)
|
||||||
scan.openingSpellableLink = true
|
scan.openingSpellableLink = true
|
||||||
@@ -225,7 +242,7 @@ function refuseSpellableLink(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[
|
|||||||
|
|
||||||
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
|
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
|
||||||
if (span.content === undefined) return success(undefined)
|
if (span.content === undefined) return success(undefined)
|
||||||
const parsed = parseInline(span.content, scan.definitions, scan.path, undefined, span.spans)
|
const parsed = parseInline(span.content, scan.definitions, scan.path, undefined, span.spans, false)
|
||||||
if (!parsed.ok) return parsed
|
if (!parsed.ok) return parsed
|
||||||
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
|
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
|
||||||
return success(parsed.value)
|
return success(parsed.value)
|
||||||
@@ -245,7 +262,7 @@ function assemble(scan: Scan): Result<InlineContent> {
|
|||||||
const only = scan.pieces[0]
|
const only = scan.pieces[0]
|
||||||
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
|
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
|
||||||
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
|
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
|
||||||
const nodes = resolveNodes(scan.pieces, scan.path)
|
const nodes = resolveNodes(scan.pieces, scan.path, true)
|
||||||
if (!nodes.ok) return nodes
|
if (!nodes.ok) return nodes
|
||||||
if (scan.openingSpellableLink) {
|
if (scan.openingSpellableLink) {
|
||||||
const takesDirective = openingLinkTakesDirective(nodes.value, scan.path)
|
const takesDirective = openingLinkTakesDirective(nodes.value, scan.path)
|
||||||
@@ -263,6 +280,15 @@ function holdsImage(pieces: readonly Piece[]): boolean {
|
|||||||
return pieces.some((piece) => piece.kind === 'image')
|
return pieces.some((piece) => piece.kind === 'image')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A carry rides its own piece: the carried node's marks restore with it rather than riding a spelling, so the guard below answers for it.
|
||||||
|
function holdsLink(pieces: readonly Piece[]): boolean {
|
||||||
|
return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
function marksLink(nodes: readonly AdfNode[]): boolean {
|
||||||
|
return nodes.some((node) => nodeMarks(node).some((mark) => mark.type === 'link'))
|
||||||
|
}
|
||||||
|
|
||||||
function readDelimiterRun(scan: Scan, index: number): number {
|
function readDelimiterRun(scan: Scan, index: number): number {
|
||||||
const character = scan.source.charAt(index)
|
const character = scan.source.charAt(index)
|
||||||
const length = runLength(scan.source, index)
|
const length = runLength(scan.source, index)
|
||||||
@@ -275,6 +301,16 @@ function readDelimiterRun(scan: Scan, index: number): number {
|
|||||||
return index + length
|
return index + length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readEquals(scan: Scan, index: number): number {
|
||||||
|
if (!scan.highlights || !scan.source.startsWith(highlightDelimiter, index)) {
|
||||||
|
scan.pending += '='
|
||||||
|
return index + 1
|
||||||
|
}
|
||||||
|
flush(scan, false)
|
||||||
|
scan.pieces.push({ ...highlightFlanking(scan.source, index), kind: 'highlight' })
|
||||||
|
return index + highlightDelimiter.length
|
||||||
|
}
|
||||||
|
|
||||||
function readAutolink(source: string, index: number): { length: number; node: AdfNode } | undefined {
|
function readAutolink(source: string, index: number): { length: number; node: AdfNode } | undefined {
|
||||||
const bracketed = readBracketedAutolink(source, index)
|
const bracketed = readBracketedAutolink(source, index)
|
||||||
if (bracketed !== undefined) return { length: bracketed, node: linkedText(source.slice(index + 1, index + bracketed - 1), '') }
|
if (bracketed !== undefined) return { length: bracketed, node: linkedText(source.slice(index + 1, index + bracketed - 1), '') }
|
||||||
@@ -339,35 +375,53 @@ function resolveTarget(scan: Scan, bracket: Bracket, index: number): { definitio
|
|||||||
return { definition, length: label?.length ?? 0 }
|
return { definition, length: label?.length ?? 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// `false` where the link text is empty: the mark has no node to ride, so the brackets stay text.
|
|
||||||
function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<boolean> {
|
function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<boolean> {
|
||||||
|
// Ahead of the guards below: brackets going literal put the image and the carry inside no mark for either to refuse.
|
||||||
|
if (holdsLink(inner)) {
|
||||||
|
deactivateOpeners(scan, at)
|
||||||
|
return success(false)
|
||||||
|
}
|
||||||
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
|
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
|
||||||
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path)
|
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path)
|
||||||
const resolved = resolveNodes(inner, scan.path)
|
const resolved = resolveNodes(inner, scan.path, true)
|
||||||
if (!resolved.ok) return resolved
|
if (!resolved.ok) return resolved
|
||||||
const nodes = resolved.value
|
const nodes = resolved.value
|
||||||
|
// An empty link text gives the mark no node to ride, so the brackets stay text.
|
||||||
if (nodes.length === 0) return success(false)
|
if (nodes.length === 0) return success(false)
|
||||||
const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title }
|
const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title }
|
||||||
scan.pieces.length = at
|
truncatePieces(scan, at)
|
||||||
// CommonMark: no link nests inside another, though an image's description holds one.
|
deactivateOpeners(scan, at)
|
||||||
for (const piece of scan.pieces) if (piece.kind === 'open' && !piece.image) piece.active = false
|
|
||||||
scan.pieces.push({ kind: 'nodes', nodes: applyMark(nodes, { attrs, type: 'link' }) })
|
scan.pieces.push({ kind: 'nodes', nodes: applyMark(nodes, { attrs, type: 'link' }) })
|
||||||
return success(true)
|
return success(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommonMark: no link nests inside another, though an image's description holds one.
|
||||||
|
function deactivateOpeners(scan: Scan, before: number): void {
|
||||||
|
for (let index = scan.deactivatedBefore; index < before; index += 1) {
|
||||||
|
const piece = scan.pieces[index]
|
||||||
|
if (piece?.kind === 'open' && !piece.image) piece.active = false
|
||||||
|
}
|
||||||
|
scan.deactivatedBefore = before
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncatePieces(scan: Scan, to: number): void {
|
||||||
|
scan.pieces.length = to
|
||||||
|
scan.deactivatedBefore = Math.min(scan.deactivatedBefore, to)
|
||||||
|
}
|
||||||
|
|
||||||
function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<null> {
|
function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<null> {
|
||||||
if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path)
|
if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path)
|
||||||
const resolved = imageAlt(inner, scan.path)
|
const resolved = imageAlt(inner, scan.path)
|
||||||
if (!resolved.ok) return resolved
|
if (!resolved.ok) return resolved
|
||||||
const alt = resolved.value
|
const alt = resolved.value
|
||||||
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
|
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
|
||||||
scan.pieces.length = at
|
truncatePieces(scan, at)
|
||||||
scan.pieces.push({ alt, kind: 'image', node: { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' } })
|
scan.pieces.push({ alt, kind: 'image', node: { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' } })
|
||||||
return success(null)
|
return success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<string> {
|
function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<string> {
|
||||||
const nodes = resolveNodes(inner, path)
|
const nodes = resolveNodes(inner, path, false)
|
||||||
if (!nodes.ok) return nodes
|
if (!nodes.ok) return nodes
|
||||||
return success(nodes.value.map(altText).join(''))
|
return success(nodes.value.map(altText).join(''))
|
||||||
}
|
}
|
||||||
@@ -375,25 +429,30 @@ function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<strin
|
|||||||
// spec/flavour.md, The CommonMark image: the description's plain text, the content slot included.
|
// spec/flavour.md, The CommonMark image: the description's plain text, the content slot included.
|
||||||
function altText(node: AdfNode): string {
|
function altText(node: AdfNode): string {
|
||||||
if (node.type === 'hardBreak') return ' '
|
if (node.type === 'hardBreak') return ' '
|
||||||
const slot = inlineDirective(node.type)?.textAttribute
|
const slot = inlineNodeModel(node.type)?.textAttribute
|
||||||
const spelled = slot === undefined ? undefined : nodeAttrs(node)[slot]
|
const spelled = slot === undefined ? undefined : nodeAttrs(node)[slot]
|
||||||
return typeof spelled === 'string' ? spelled : (node.text ?? '')
|
return typeof spelled === 'string' ? spelled : (node.text ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath): Result<AdfNode[]> {
|
// An image's alt text is plain, so `highlights` is off there and every `==` stays text.
|
||||||
|
function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath, highlights: boolean): Result<AdfNode[]> {
|
||||||
const nodes = pieces.map(pieceNodes)
|
const nodes = pieces.map(pieceNodes)
|
||||||
const runs = delimiterRuns(pieces)
|
const runs = delimiterRuns(pieces)
|
||||||
const pairings = matchEmphasis(runs)
|
const pairings = matchEmphasis(runs)
|
||||||
writeUnpaired(nodes, runs, pairings)
|
writeUnpaired(nodes, runs, pairings)
|
||||||
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, path)
|
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, path)
|
||||||
|
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
|
||||||
return success(mergeAdjacentText(nodes.flat()))
|
return success(mergeAdjacentText(nodes.flat()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first.
|
// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first.
|
||||||
|
// A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it.
|
||||||
function pieceNodes(piece: Piece): AdfNode[] {
|
function pieceNodes(piece: Piece): AdfNode[] {
|
||||||
switch (piece.kind) {
|
switch (piece.kind) {
|
||||||
case 'carry':
|
case 'carry':
|
||||||
return [piece.node]
|
return [piece.node]
|
||||||
|
case 'highlight':
|
||||||
|
return [{ text: '', type: 'text' }]
|
||||||
case 'image':
|
case 'image':
|
||||||
return piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }]
|
return piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }]
|
||||||
case 'nodes':
|
case 'nodes':
|
||||||
@@ -440,12 +499,69 @@ function markPairings(pieces: readonly Piece[], nodes: AdfNode[][], pairings: re
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function highlightDelimiters(pieces: readonly Piece[], nodes: readonly AdfNode[][]): HighlightDelimiter[] {
|
||||||
|
const found: HighlightDelimiter[] = []
|
||||||
|
let line = 0
|
||||||
|
let position = 0
|
||||||
|
for (const [index, piece] of pieces.entries()) {
|
||||||
|
const held = nodes[index] ?? []
|
||||||
|
const [holder] = held
|
||||||
|
if (piece.kind === 'highlight' && holder !== undefined) {
|
||||||
|
found.push({ closes: piece.closes, holder, index, line, opens: piece.opens, position })
|
||||||
|
position += highlightDelimiter.length
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const node of held) {
|
||||||
|
if (node.type === 'text') position += node.text?.length ?? 0
|
||||||
|
else line += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each opener takes the next closer holding at least one character after it, both in one line and under the same marks.
|
||||||
|
function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][]): { closer: number; opener: number }[] {
|
||||||
|
const found = highlightDelimiters(pieces, nodes)
|
||||||
|
const paired: { closer: number; opener: number }[] = []
|
||||||
|
let closer = 0
|
||||||
|
let resume = 0
|
||||||
|
for (const opener of found) {
|
||||||
|
if (!opener.opens || opener.position < resume) continue
|
||||||
|
const earliest = opener.position + highlightDelimiter.length + 1
|
||||||
|
let candidate = found[closer]
|
||||||
|
while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)]
|
||||||
|
if (candidate === undefined) break
|
||||||
|
if (candidate.line !== opener.line || !sameMarks(opener.holder, candidate.holder)) continue
|
||||||
|
paired.push({ closer: candidate.index, opener: opener.index })
|
||||||
|
resume = candidate.position + highlightDelimiter.length
|
||||||
|
}
|
||||||
|
return paired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atlassian's schema refuses a highlight on code, a node holds one highlight, and a rebuilt node would lose its attributes.
|
||||||
|
function markHighlights(pieces: readonly Piece[], nodes: AdfNode[][], paired: readonly { closer: number; opener: number }[]): void {
|
||||||
|
for (const [index, piece] of pieces.entries()) {
|
||||||
|
if (piece.kind === 'highlight') nodes[index] = (nodes[index] ?? []).map((holder) => ({ ...holder, text: highlightDelimiter }))
|
||||||
|
}
|
||||||
|
for (const { closer, opener } of paired) {
|
||||||
|
nodes[opener] = []
|
||||||
|
nodes[closer] = []
|
||||||
|
for (let index = opener + 1; index < closer; index += 1) nodes[index] = (nodes[index] ?? []).map(highlighted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlighted(node: AdfNode): AdfNode {
|
||||||
|
const marks = nodeMarks(node)
|
||||||
|
if (node.type !== 'text' || Object.keys(nodeAttrs(node)).length > 0 || marks.some((mark) => mark.type === 'code' || mark.type === 'backgroundColor')) return node
|
||||||
|
return { ...node, marks: [editorHighlight, ...marks] }
|
||||||
|
}
|
||||||
|
|
||||||
function markType(character: string, used: number): string {
|
function markType(character: string, used: number): string {
|
||||||
if (character === '~') return 'strike'
|
if (character === '~') return 'strike'
|
||||||
return used === 2 ? 'strong' : 'em'
|
return used === 2 ? 'strong' : 'em'
|
||||||
}
|
}
|
||||||
|
|
||||||
// A node cannot carry one mark type twice (AGENTS.md §14).
|
// A node cannot carry one mark type twice (docs/decisions.md §No schema validation).
|
||||||
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
|
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
|
||||||
return nodes.map((node) => {
|
return nodes.map((node) => {
|
||||||
const marks = nodeMarks(node)
|
const marks = nodeMarks(node)
|
||||||
|
|||||||
@@ -416,6 +416,7 @@ test('names the mark spelling no opaque carry sits inside', () => {
|
|||||||
assert.equal(content(markdownToAdf(`**${carried}**\n`)), named)
|
assert.equal(content(markdownToAdf(`**${carried}**\n`)), named)
|
||||||
assert.equal(content(markdownToAdf(`~~a ${carried}~~\n`)), named)
|
assert.equal(content(markdownToAdf(`~~a ${carried}~~\n`)), named)
|
||||||
assert.equal(content(markdownToAdf(`[a ${carried} b](https://example.com/x)\n`)), named)
|
assert.equal(content(markdownToAdf(`[a ${carried} b](https://example.com/x)\n`)), named)
|
||||||
|
assert.equal(content(markdownToAdf(`[*<http://x/>${carried}*](/w)\n`)), named)
|
||||||
assert.equal(content(markdownToAdf(`!adf:underline[${carried}]\n`)), named)
|
assert.equal(content(markdownToAdf(`!adf:underline[${carried}]\n`)), named)
|
||||||
assert.equal(content(markdownToAdf(`!adf:textColor[a ${carried}]{color="#ae2e24"}\n`)), named)
|
assert.equal(content(markdownToAdf(`!adf:textColor[a ${carried}]{color="#ae2e24"}\n`)), named)
|
||||||
assert.equal(content(markdownToAdf(`\n`)), named)
|
assert.equal(content(markdownToAdf(`\n`)), named)
|
||||||
@@ -671,7 +672,7 @@ test('refuses input nested deeper than the parser carries', () => {
|
|||||||
assert.equal(code(markdownToAdf(marks(largestNesting + 1))), 'unsupported-nesting-depth')
|
assert.equal(code(markdownToAdf(marks(largestNesting + 1))), 'unsupported-nesting-depth')
|
||||||
assert.deepEqual(content(markdownToAdf(marks(largestNesting))), [{ content: [marked('a', underline)], type: 'paragraph' }])
|
assert.deepEqual(content(markdownToAdf(marks(largestNesting))), [{ content: [marked('a', underline)], type: 'paragraph' }])
|
||||||
const nest = (names: readonly string[], body: string): string => [...names.map((name) => `!adf:${name}\n`), body, ...names.map((name) => `!adf:/${name}\n`).reverse()].join('')
|
const nest = (names: readonly string[], body: string): string => [...names.map((name) => `!adf:${name}\n`), body, ...names.map((name) => `!adf:/${name}\n`).reverse()].join('')
|
||||||
const repeated = (name: string): string[] => Array.from({ length: largestNesting }, () => name)
|
const repeated = (name: string, levels: number = largestNesting): string[] => Array.from({ length: levels }, () => name)
|
||||||
assert.ok(markdownToAdf(nest(repeated('panel'), '!adf:paragraph {localId=a-1}\nPart.\n!adf:/paragraph\n')).ok)
|
assert.ok(markdownToAdf(nest(repeated('panel'), '!adf:paragraph {localId=a-1}\nPart.\n!adf:/paragraph\n')).ok)
|
||||||
assert.deepEqual(position(markdownToAdf(nest(['expand', ...repeated('panel'), 'expand'], 'Part.\n'))), { line: 501, offset: 5501 })
|
assert.deepEqual(position(markdownToAdf(nest(['expand', ...repeated('panel'), 'expand'], 'Part.\n'))), { line: 501, offset: 5501 })
|
||||||
assert.equal(code(markdownToAdf(nest(['panel', ...repeated('expand'), 'panel'], 'Part.\n'))), 'unsupported-nesting-depth')
|
assert.equal(code(markdownToAdf(nest(['panel', ...repeated('expand'), 'panel'], 'Part.\n'))), 'unsupported-nesting-depth')
|
||||||
@@ -679,6 +680,19 @@ test('refuses input nested deeper than the parser carries', () => {
|
|||||||
const directiveLists = largestNesting / 2
|
const directiveLists = largestNesting / 2
|
||||||
assert.ok(markdownToAdf(listed(directiveLists)).ok)
|
assert.ok(markdownToAdf(listed(directiveLists)).ok)
|
||||||
assert.equal(code(markdownToAdf(listed(directiveLists + 1))), 'unsupported-nesting-depth')
|
assert.equal(code(markdownToAdf(listed(directiveLists + 1))), 'unsupported-nesting-depth')
|
||||||
|
const asking = (body: string): string => `!adf:bulletList\n!adf:listItem\n${body}!adf:/listItem\n!adf:/bulletList\n`
|
||||||
|
// Two items past the largest list marker leave the list no readable spelling, so the emitter
|
||||||
|
// walks it as list plus item where the parser counted one level.
|
||||||
|
const overflowing = (body: string): string => {
|
||||||
|
const marker = '999999999. '
|
||||||
|
return `${marker}${body.replace(/^(?!$)/gm, ' '.repeat(marker.length)).slice(marker.length)}\n${marker}z\n`
|
||||||
|
}
|
||||||
|
const overflowed = (panels: number): string => asking(overflowing(overflowing(asking(`---\n${nest(repeated('panel', panels), 'Part.\n')}`))))
|
||||||
|
assert.equal(code(markdownToAdf(overflowed(493))), 'unsupported-node-shape')
|
||||||
|
assert.equal(code(markdownToAdf(overflowed(494))), 'unsupported-nesting-depth')
|
||||||
|
const rebasing = (panels: number): string => asking(`---\n${overflowing(asking(`---\n${nest(repeated('panel', panels), 'Part.\n')}`))}`)
|
||||||
|
assert.ok(markdownToAdf(rebasing(494)).ok)
|
||||||
|
assert.equal(code(markdownToAdf(rebasing(495))), 'unsupported-nesting-depth')
|
||||||
assert.ok(markdownToAdf(`${'- '.repeat(largestNesting)}a\n`).ok)
|
assert.ok(markdownToAdf(`${'- '.repeat(largestNesting)}a\n`).ok)
|
||||||
assert.equal(code(markdownToAdf(`${'- '.repeat(largestNesting + 1)}a\n`)), 'unsupported-nesting-depth')
|
assert.equal(code(markdownToAdf(`${'- '.repeat(largestNesting + 1)}a\n`)), 'unsupported-nesting-depth')
|
||||||
})
|
})
|
||||||
@@ -825,6 +839,33 @@ test('leaves the bracket pair no link parses as the text it holds', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('leaves the brackets of a link whose text already holds one the text they are', () => {
|
||||||
|
const held: AdfMark = { attrs: { collection: 'c', href: '/u' }, type: 'link' }
|
||||||
|
assert.deepEqual(content(markdownToAdf('[<http://x/>](/v)\n')), [
|
||||||
|
{ content: [text('['), marked('http://x/', link('http://x/')), text('](/v)')], type: 'paragraph' },
|
||||||
|
])
|
||||||
|
assert.deepEqual(content(markdownToAdf('[a<http://x/>b](/v)\n')), [
|
||||||
|
{ content: [text('[a'), marked('http://x/', link('http://x/')), text('b](/v)')], type: 'paragraph' },
|
||||||
|
])
|
||||||
|
assert.deepEqual(content(markdownToAdf('[!adf:link[a]{collection=c href="/u"}](/v)\n')), [
|
||||||
|
{ content: [text('['), marked('a', held), text('](/v)')], type: 'paragraph' },
|
||||||
|
])
|
||||||
|
assert.deepEqual(content(markdownToAdf('[<http://x/>][r]\n\n[r]: /v\n')), [
|
||||||
|
{ content: [text('['), marked('http://x/', link('http://x/')), text(']'), marked('r', link('/v'))], type: 'paragraph' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps the carry and the image the brackets a nested link leaves literal hold', () => {
|
||||||
|
assert.deepEqual(content(markdownToAdf(`[<http://x/>${carried}](/w)\n`)), [
|
||||||
|
{ content: [text('['), marked('http://x/', link('http://x/')), { type: 'placeholder' }, text('](/w)')], type: 'paragraph' },
|
||||||
|
])
|
||||||
|
assert.deepEqual(content(markdownToAdf(`[[<http://x/>](/c)${carried}](/w)\n`)), [
|
||||||
|
{ content: [text('[['), marked('http://x/', link('http://x/')), text('](/c)'), { type: 'placeholder' }, text('](/w)')], type: 'paragraph' },
|
||||||
|
])
|
||||||
|
// The failing bracket sits inside the image, not beside it: beside it the link-marked piece survives, and the deactivation stops being what the assertion pins.
|
||||||
|
assert.deepEqual(content(markdownToAdf(' ](/i)[](/y)](/w)](/v)\n')), [image('/v', 'a b [[http://x/](/c)](/w)')])
|
||||||
|
})
|
||||||
|
|
||||||
test('reads the reference links a definition resolves, and leaves the rest literal', () => {
|
test('reads the reference links a definition resolves, and leaves the rest literal', () => {
|
||||||
assert.deepEqual(content(markdownToAdf('[a][r]\n\n[r]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }])
|
assert.deepEqual(content(markdownToAdf('[a][r]\n\n[r]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }])
|
||||||
assert.deepEqual(content(markdownToAdf('[a][]\n\n[a]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }])
|
assert.deepEqual(content(markdownToAdf('[a][]\n\n[a]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }])
|
||||||
@@ -984,8 +1025,6 @@ test('refuses the directive link CommonMark could spell, and reads the one it co
|
|||||||
assert.equal(content(markdownToAdf('| !adf:link[`]: a`]{href="/u"} |\n| --- |\n')), refused)
|
assert.equal(content(markdownToAdf('| !adf:link[`]: a`]{href="/u"} |\n| --- |\n')), refused)
|
||||||
assert.equal(content(markdownToAdf('!adf:underline[!adf:link[a]{href="/u"}]\n')), refused)
|
assert.equal(content(markdownToAdf('!adf:underline[!adf:link[a]{href="/u"}]\n')), refused)
|
||||||
assert.deepEqual(path(markdownToAdf('Part.\n\nSee !adf:link[a]{href="/u"}.\n')), ['content', 1])
|
assert.deepEqual(path(markdownToAdf('Part.\n\nSee !adf:link[a]{href="/u"}.\n')), ['content', 1])
|
||||||
const titled: AdfNode = { marks: [{ attrs: { title: 't' }, type: 'link' }], text: 'a', type: 'text' }
|
|
||||||
assert.deepEqual(content(markdownToAdf('!adf:link[a]{title=t}\n')), [{ content: [titled], type: 'paragraph' }])
|
|
||||||
const opening: AdfNode = { marks: [{ attrs: { href: '/u' }, type: 'link' }, { type: 'code' }], text: ']: a', type: 'text' }
|
const opening: AdfNode = { marks: [{ attrs: { href: '/u' }, type: 'link' }, { type: 'code' }], text: ']: a', type: 'text' }
|
||||||
assert.deepEqual(content(markdownToAdf('!adf:link[`]: a`]{href="/u"}\n')), [{ content: [opening], type: 'paragraph' }])
|
assert.deepEqual(content(markdownToAdf('!adf:link[`]: a`]{href="/u"}\n')), [{ content: [opening], type: 'paragraph' }])
|
||||||
assert.deepEqual(content(markdownToAdf('!adf:heading {level=1 localId=h}\n!adf:link[`]: a`]{href="/u"}\n!adf:/heading\n')), [
|
assert.deepEqual(content(markdownToAdf('!adf:heading {level=1 localId=h}\n!adf:link[`]: a`]{href="/u"}\n!adf:/heading\n')), [
|
||||||
@@ -993,6 +1032,22 @@ test('refuses the directive link CommonMark could spell, and reads the one it co
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('names the href the directive link spells no value for', () => {
|
||||||
|
const named = 'unsupported-node-shape: the link mark spells its href: this one spells none'
|
||||||
|
assert.equal(content(markdownToAdf('!adf:link[a]\n')), named)
|
||||||
|
assert.equal(content(markdownToAdf('!adf:link[a]{title=t}\n')), named)
|
||||||
|
assert.equal(content(markdownToAdf('See !adf:link[a]{id=01a032c3-7a90-70c9-88f6-c60f710eda07}.\n')), named)
|
||||||
|
assert.equal(content(markdownToAdf('!adf:link[<http://x/>]{collection=c}\n')), named)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('names the link a directive link wraps, no link holding another', () => {
|
||||||
|
const named = 'unsupported-node-shape: no link wraps a link: the [content] this one marks already holds one'
|
||||||
|
assert.equal(content(markdownToAdf('!adf:link[<http://x/>]{collection=c href="/u"}\n')), named)
|
||||||
|
assert.equal(content(markdownToAdf('!adf:link[[a](/v)]{collection=c href="/u"}\n')), named)
|
||||||
|
assert.equal(content(markdownToAdf('!adf:link[a <http://x/> b]{collection=c href="/u"}\n')), named)
|
||||||
|
assert.equal(content(markdownToAdf('!adf:link[<http://x/>]{href="/u"}\n')), named)
|
||||||
|
})
|
||||||
|
|
||||||
test('names the directive mark left without the content it wraps', () => {
|
test('names the directive mark left without the content it wraps', () => {
|
||||||
const named = 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps none'
|
const named = 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps none'
|
||||||
assert.equal(content(markdownToAdf('!adf:underline[]\n')), named)
|
assert.equal(content(markdownToAdf('!adf:underline[]\n')), named)
|
||||||
|
|||||||
@@ -2,30 +2,52 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
|||||||
import type { Block, DirectiveBlock } from './blocks.ts'
|
import type { Block, DirectiveBlock } from './blocks.ts'
|
||||||
import type { BlockDirectiveNode } from './directive-nodes.ts'
|
import type { BlockDirectiveNode } from './directive-nodes.ts'
|
||||||
import type { ConvertFault } from '../../result.ts'
|
import type { ConvertFault } from '../../result.ts'
|
||||||
import type { LineContainer } from '../emit/line-escaping.ts'
|
import type { Flavour } from '../plain-conventions.ts'
|
||||||
|
import type { LineContainer } from '../line-container.ts'
|
||||||
import type { LinkDefinitions } from './inline-content.ts'
|
import type { LinkDefinitions } from './inline-content.ts'
|
||||||
import { carryName, readCarriedBlock } from '../opaque-carry.ts'
|
import { carryName, readCarriedBlock } from '../opaque-carry.ts'
|
||||||
import { commonMarkSpelling } from '../emit/adf-to-markdown.ts'
|
import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts'
|
||||||
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
|
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
|
||||||
|
import { inlineLeaves } from '../emit/plain-inline.ts'
|
||||||
import { languageSlot } from '../code-language.ts'
|
import { languageSlot } from '../code-language.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { listBreakName, listBreakSpelling } from '../list-break.ts'
|
import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain-conventions.ts'
|
||||||
import { nodeAttrs } from '../../adf/document.ts'
|
import { listBreakName, listBreakSpelling } from '../block-directive.ts'
|
||||||
|
import { mintTaskIds } from './task-ids.ts'
|
||||||
|
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||||
import { parseBlocks } from './blocks.ts'
|
import { parseBlocks } from './blocks.ts'
|
||||||
import { parseInlineContent } from './inline-content.ts'
|
import { parseInlineContent } from './inline-content.ts'
|
||||||
import { readBlockDirectiveNode } from './directive-nodes.ts'
|
import { readBlockDirectiveNode } from './directive-nodes.ts'
|
||||||
import { unsupportedNodeShape } from '../directive-syntax.ts'
|
import { unsupportedNodeShape } from '../directive-syntax.ts'
|
||||||
|
|
||||||
|
type Paragraph = Extract<Block, { kind: 'paragraph' }>
|
||||||
|
|
||||||
|
// `inExpand` is whether an expand holds the blocks, which makes a folded callout a nestedExpand.
|
||||||
|
type Reading = { carried: Set<AdfNode>; definitions: LinkDefinitions; flavour: Flavour; inExpand: boolean; memo: SpellingMemo }
|
||||||
|
|
||||||
const documentStart: SourcePosition = { line: 1, offset: 0 }
|
const documentStart: SourcePosition = { line: 1, offset: 0 }
|
||||||
|
const imageAfterMarker = 'an image fits only as a paragraph of its own: this one continues the paragraph a marker opens, which a blank line before it ends'
|
||||||
|
const imageOnMarkerLine = 'an image fits only as a paragraph of its own: this one shares a line with a marker'
|
||||||
|
|
||||||
export function markdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
export function markdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||||
const parsed = parseBlocks(markdown)
|
return readDocument(markdown, 'lossless')
|
||||||
const content = positioned(blockNodes(parsed.blocks, parsed.definitions, [], 0), documentStart)
|
|
||||||
if (!content.ok) return content
|
|
||||||
return success(content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function blockNodes(blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
export function plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
|
||||||
|
return readDocument(markdown, 'plain')
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDocument(markdown: string, flavour: Flavour): Result<AdfDocument, ParseError> {
|
||||||
|
const parsed = parseBlocks(markdown)
|
||||||
|
const reading: Reading = { carried: new Set(), definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() }
|
||||||
|
const content = positioned(readBlocks(parsed.blocks, reading, [], 0), documentStart)
|
||||||
|
if (!content.ok) return content
|
||||||
|
const document: AdfDocument = content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 }
|
||||||
|
if (flavour === 'plain') mintTaskIds(document, markdown, reading.carried)
|
||||||
|
return success(document)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||||
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path)
|
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path)
|
||||||
const content: AdfNode[] = []
|
const content: AdfNode[] = []
|
||||||
for (const [index, block] of blocks.entries()) {
|
for (const [index, block] of blocks.entries()) {
|
||||||
@@ -35,7 +57,7 @@ function blockNodes(blocks: readonly Block[], definitions: LinkDefinitions, path
|
|||||||
if (fault !== undefined) return positioned(faulted(fault, nodePath), block.position)
|
if (fault !== undefined) return positioned(faulted(fault, nodePath), block.position)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const node = positioned(blockNode(block, definitions, nodePath, depth), block.position)
|
const node = positioned(readBlock(block, reading, nodePath, depth), block.position)
|
||||||
if (!node.ok) return node
|
if (!node.ok) return node
|
||||||
content.push(node.value)
|
content.push(node.value)
|
||||||
}
|
}
|
||||||
@@ -55,50 +77,148 @@ function partsFault(): ConvertFault {
|
|||||||
return unsupportedNodeShape(`${listBreakName} parts two adjacent lists of one type: this one parts something else`)
|
return unsupportedNodeShape(`${listBreakName} parts two adjacent lists of one type: this one parts something else`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||||
switch (block.kind) {
|
switch (block.kind) {
|
||||||
case 'blockquote':
|
case 'blockquote':
|
||||||
return containerNode({ type: 'blockquote' }, block.blocks, definitions, path, depth)
|
return reading.flavour === 'plain' ? quoteNode(block.blocks, reading, path, depth) : containerNode({ type: 'blockquote' }, block.blocks, reading, path, depth)
|
||||||
case 'bulletList':
|
case 'bulletList':
|
||||||
return listNode({ type: 'bulletList' }, block.items, definitions, path, depth)
|
return reading.flavour === 'plain' ? bulletNode(block.items, reading, path, depth) : listNode({ type: 'bulletList' }, block.items, reading, path, depth)
|
||||||
case 'code':
|
case 'code':
|
||||||
return codeBlockNode(block.language, block.text, path, depth)
|
return codeBlockNode(block.language, block.text, reading, path, depth)
|
||||||
case 'directive':
|
case 'directive':
|
||||||
return directiveNode(block, definitions, path, depth)
|
return directiveNode(block, reading, path, depth)
|
||||||
case 'fault':
|
case 'fault':
|
||||||
return faulted(block.fault, path)
|
return faulted(block.fault, path)
|
||||||
case 'heading':
|
case 'heading':
|
||||||
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path, 'heading')
|
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, reading, path, 'heading')
|
||||||
case 'html':
|
case 'html':
|
||||||
return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path)
|
return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path)
|
||||||
case 'orderedList':
|
case 'orderedList':
|
||||||
return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, definitions, path, depth)
|
return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, reading, path, depth)
|
||||||
case 'paragraph':
|
case 'paragraph':
|
||||||
return paragraphNode(block.text, definitions, path)
|
return paragraphNode(block.text, reading, path)
|
||||||
case 'rule':
|
case 'rule':
|
||||||
return success({ type: 'rule' })
|
return success({ type: 'rule' })
|
||||||
case 'table':
|
case 'table':
|
||||||
return tableNode(block.rows, definitions, path)
|
return tableNode(block.rows, reading, path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function directiveNode(block: DirectiveBlock, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
function markerLed<T extends { length: number }>(block: Block | undefined, read: (text: string) => T | undefined): { marker: T; position: SourcePosition; text: string } | undefined {
|
||||||
|
if (block?.kind !== 'paragraph') return undefined
|
||||||
|
const marker = leadingMarker(block.text, read)
|
||||||
|
return marker === undefined ? undefined : { marker, position: block.position, text: block.text.slice(marker.length) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerLine(text: string): { line: string; rest: string } {
|
||||||
|
const lineEnd = text.indexOf('\n')
|
||||||
|
const line = lineEnd === -1 ? text : text.slice(0, lineEnd)
|
||||||
|
const hardBreak = lineEnd !== -1 && /(?:^|[^\\])(?:\\\\)*\\$/.test(line)
|
||||||
|
return { line: (hardBreak ? line.slice(0, -1) : line).replace(/^[ \t]+/, ''), rest: lineEnd === -1 ? '' : text.slice(lineEnd + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphsOf(position: SourcePosition, ...texts: string[]): Paragraph[] {
|
||||||
|
return texts.filter((text) => text !== '').map((text) => ({ kind: 'paragraph', position, text }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||||
|
const [first, ...body] = blocks
|
||||||
|
const led = markerLed(first, readAlertMarker)
|
||||||
|
if (led === undefined) return containerNode({ type: 'blockquote' }, blocks, reading, path, depth)
|
||||||
|
const { folded, panelType } = led.marker
|
||||||
|
const { line, rest } = markerLine(led.text)
|
||||||
|
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readMarked(paragraphsOf(led.position, line, rest), line !== '', body, reading, path, depth))
|
||||||
|
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', 'lossless')
|
||||||
|
if (!title.ok) return title
|
||||||
|
if (title.value.image !== undefined) return failure('unmappable-image', imageOnMarkerLine, path)
|
||||||
|
const leaves: AdfNode[] = []
|
||||||
|
for (const [index, node] of title.value.nodes.entries()) {
|
||||||
|
const held = node.text === undefined ? inlineLeaves([node], 'paragraph', [...path, 'content', index], depth) : success([node])
|
||||||
|
if (!held.ok) return held
|
||||||
|
leaves.push(...held.value)
|
||||||
|
}
|
||||||
|
const text = titleText(leaves)
|
||||||
|
const type = reading.inExpand ? 'nestedExpand' : 'expand'
|
||||||
|
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readMarked(paragraphsOf(led.position, rest), false, body, { ...reading, inExpand: true }, path, depth))
|
||||||
|
}
|
||||||
|
|
||||||
|
// docs/decisions.md, A callout title keeps its link targets.
|
||||||
|
function titleText(nodes: readonly AdfNode[]): string {
|
||||||
|
let text = ''
|
||||||
|
let linked = ''
|
||||||
|
for (const [index, node] of nodes.entries()) {
|
||||||
|
const href = linkTarget(node)
|
||||||
|
text += node.text ?? ''
|
||||||
|
linked += href === undefined ? '' : node.text ?? ''
|
||||||
|
if (href === undefined || linkTarget(nodes[index + 1]) === href) continue
|
||||||
|
if (href !== linked && href !== `mailto:${linked}`) text += ` (${href})`
|
||||||
|
linked = ''
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkTarget(node: AdfNode | undefined): string | undefined {
|
||||||
|
const href = node?.marks?.find((mark) => mark.type === 'link')?.attrs?.href
|
||||||
|
return typeof href === 'string' ? href : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atlassian's schema requires a panel and an expand to hold a block.
|
||||||
|
function filledNode(node: AdfNode, content: Result<AdfNode[]>): Result<AdfNode> {
|
||||||
|
if (!content.ok) return content
|
||||||
|
return success({ ...node, content: content.value.length === 0 ? [{ type: 'paragraph' }] : content.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
// A paragraph split off a marker still refuses the image it held beside it; `onMarkerLine` is whether the first one opens on the marker's line.
|
||||||
|
function readMarked(marked: readonly Paragraph[], onMarkerLine: boolean, others: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
|
||||||
|
const read = readBlocks([...marked, ...others], reading, path, depth + 1)
|
||||||
|
if (!read.ok) return read
|
||||||
|
const image = read.value.slice(0, marked.length).findIndex((node) => node.type === 'mediaSingle')
|
||||||
|
if (image === -1) return read
|
||||||
|
return failure('unmappable-image', image === 0 && onMarkerLine ? imageOnMarkerLine : imageAfterMarker, [...path, 'content', image])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A task list trailing an item's blocks stands beside it, as ADF nests one.
|
||||||
|
function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||||
|
const led = []
|
||||||
|
for (const [first, ...others] of items) {
|
||||||
|
const marked = markerLed(first, readTaskMarker)
|
||||||
|
if (marked === undefined) return listNode({ type: 'bulletList' }, items, reading, path, depth)
|
||||||
|
led.push({ ...marked, others })
|
||||||
|
}
|
||||||
|
const tasks: AdfNode[] = []
|
||||||
|
for (const [index, { marker, others, position, text }] of led.entries()) {
|
||||||
|
const read = readMarked(paragraphsOf(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), markerLine(text).line !== '', others, reading, [...path, 'content', index], depth)
|
||||||
|
if (!read.ok) return read
|
||||||
|
let beside = read.value.length
|
||||||
|
while (read.value[beside - 1]?.type === 'taskList') beside -= 1
|
||||||
|
const kept = read.value.slice(0, beside)
|
||||||
|
const [only] = kept
|
||||||
|
const attrs = { state: marker.state }
|
||||||
|
const inline = kept.length <= 1 && (only === undefined || only.type === 'paragraph')
|
||||||
|
tasks.push(inline ? { attrs, content: nodeContent(only ?? {}).slice(), type: 'taskItem' } : { attrs, content: kept, type: 'blockTaskItem' })
|
||||||
|
for (const nested of read.value.slice(beside)) tasks.push(nested)
|
||||||
|
}
|
||||||
|
return success({ content: tasks, type: 'taskList' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||||
const read = readBlockDirectiveNode(block.name, block.argument, block.attributes, path)
|
const read = readBlockDirectiveNode(block.name, block.argument, block.attributes, path)
|
||||||
if (!read.ok) return read
|
if (!read.ok) return read
|
||||||
const built = directiveBody(read.value, block.blocks, definitions, path, depth)
|
const inExpand = reading.inExpand || read.value.node.type === 'expand' || read.value.node.type === 'nestedExpand'
|
||||||
|
const built = directiveBody(read.value, block.blocks, { ...reading, inExpand }, path, depth)
|
||||||
if (!built.ok) return built
|
if (!built.ok) return built
|
||||||
const readable = commonMarkSpelling(built.value, path, depth)
|
const readable = commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', memo: reading.memo })
|
||||||
if (readable === undefined) return built
|
if (readable === undefined) return built
|
||||||
if (!readable.ok) return readable
|
if (!readable.ok) return readable
|
||||||
return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path)
|
return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||||
const { contentModel, node } = read
|
const { contentModel, node } = read
|
||||||
if (blocks === undefined) return success(node)
|
if (blocks === undefined) return success(node)
|
||||||
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
|
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
|
||||||
if (contentModel === 'inline') return inlineBodyNode(node, blocks, definitions, path)
|
if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path)
|
||||||
return containerNode(node, blocks, definitions, path, depth)
|
return containerNode(node, blocks, reading, path, depth)
|
||||||
}
|
}
|
||||||
|
|
||||||
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
|
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
|
||||||
@@ -114,13 +234,13 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver
|
|||||||
return success(withContent(spelled, only.text === '' ? [] : [{ text: only.text, type: 'text' }]))
|
return success(withContent(spelled, only.text === '' ? [] : [{ text: only.text, type: 'text' }]))
|
||||||
}
|
}
|
||||||
|
|
||||||
function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
|
function tableNode(rows: readonly string[][], reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
|
||||||
const content: AdfNode[] = []
|
const content: AdfNode[] = []
|
||||||
for (const [rowIndex, cells] of rows.entries()) {
|
for (const [rowIndex, cells] of rows.entries()) {
|
||||||
const type = rowIndex === 0 ? 'tableHeader' : 'tableCell'
|
const type = rowIndex === 0 ? 'tableHeader' : 'tableCell'
|
||||||
const row: AdfNode[] = []
|
const row: AdfNode[] = []
|
||||||
for (const [cellIndex, cell] of cells.entries()) {
|
for (const [cellIndex, cell] of cells.entries()) {
|
||||||
const paragraph = contentNode({ type: 'paragraph' }, cell, definitions, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], 'table-cell')
|
const paragraph = contentNode({ type: 'paragraph' }, cell, reading, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], 'table-cell')
|
||||||
if (!paragraph.ok) return paragraph
|
if (!paragraph.ok) return paragraph
|
||||||
row.push({ content: [paragraph.value], type })
|
row.push({ content: [paragraph.value], type })
|
||||||
}
|
}
|
||||||
@@ -129,16 +249,16 @@ function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path
|
|||||||
return success({ content, type: 'table' })
|
return success({ content, type: 'table' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
|
function inlineBodyNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
|
||||||
if (blocks.length === 0) return success(node)
|
if (blocks.length === 0) return success(node)
|
||||||
const only = blocks.length === 1 ? blocks[0] : undefined
|
const only = blocks.length === 1 ? blocks[0] : undefined
|
||||||
if (only?.kind === 'fault') return positioned(faulted(only.fault, path), only.position)
|
if (only?.kind === 'fault') return positioned(faulted(only.fault, path), only.position)
|
||||||
if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body: this body is not one`, path)
|
if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body: this body is not one`, path)
|
||||||
return positioned(contentNode(node, only.text, definitions, path, 'paragraph'), only.position)
|
return positioned(contentNode(node, only.text, reading, path, 'paragraph'), only.position)
|
||||||
}
|
}
|
||||||
|
|
||||||
function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
function containerNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||||
const content = blockNodes(blocks, definitions, path, depth + 1)
|
const content = readBlocks(blocks, reading, path, depth + 1)
|
||||||
if (!content.ok) return content
|
if (!content.ok) return content
|
||||||
return success(withContent(node, content.value))
|
return success(withContent(node, content.value))
|
||||||
}
|
}
|
||||||
@@ -147,20 +267,21 @@ function withContent(node: AdfNode, content: readonly AdfNode[]): AdfNode {
|
|||||||
return content.length === 0 ? node : { ...node, content: [...content] }
|
return content.length === 0 ? node : { ...node, content: [...content] }
|
||||||
}
|
}
|
||||||
|
|
||||||
function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||||
const content: AdfNode[] = []
|
const content: AdfNode[] = []
|
||||||
for (const [index, blocks] of items.entries()) {
|
for (const [index, blocks] of items.entries()) {
|
||||||
const item = containerNode({ type: 'listItem' }, blocks, definitions, [...path, 'content', index], depth)
|
const item = containerNode({ type: 'listItem' }, blocks, reading, [...path, 'content', index], depth)
|
||||||
if (!item.ok) return item
|
if (!item.ok) return item
|
||||||
content.push(item.value)
|
content.push(item.value)
|
||||||
}
|
}
|
||||||
return success({ ...node, content })
|
return success({ ...node, content })
|
||||||
}
|
}
|
||||||
|
|
||||||
function codeBlockNode(language: string, text: string, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
function codeBlockNode(language: string, text: string, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
|
||||||
if (language === carryName) {
|
if (language === carryName) {
|
||||||
const carried = readCarriedBlock(text, depth)
|
const carried = readCarriedBlock(text, depth)
|
||||||
if (carried.fault !== undefined) return faulted(carried.fault, path)
|
if (carried.fault !== undefined) return faulted(carried.fault, path)
|
||||||
|
reading.carried.add(carried.value)
|
||||||
return success(carried.value)
|
return success(carried.value)
|
||||||
}
|
}
|
||||||
const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' }
|
const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' }
|
||||||
@@ -168,15 +289,15 @@ function codeBlockNode(language: string, text: string, path: ConvertErrorPath, d
|
|||||||
}
|
}
|
||||||
|
|
||||||
// spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs.
|
// spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs.
|
||||||
function paragraphNode(text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
|
function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
|
||||||
const content = parseInlineContent(text, definitions, path, 'paragraph')
|
const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.flavour)
|
||||||
if (!content.ok) return content
|
if (!content.ok) return content
|
||||||
const image = content.value.image
|
const image = content.value.image
|
||||||
return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image)
|
return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image)
|
||||||
}
|
}
|
||||||
|
|
||||||
function contentNode(node: AdfNode, text: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
|
function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
|
||||||
const content = parseInlineContent(text, definitions, path, container)
|
const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour)
|
||||||
if (!content.ok) return content
|
if (!content.ok) return content
|
||||||
if (content.value.image !== undefined) return failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path)
|
if (content.value.image !== undefined) return failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path)
|
||||||
return success(withContent(node, content.value.nodes))
|
return success(withContent(node, content.value.nodes))
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
|
import { adfToMarkdown } from '../emit/adf-to-markdown.ts'
|
||||||
|
import { adfToPlainMarkdown } from '../emit/plain-reduction.ts'
|
||||||
|
import { largestNesting } from '../../nesting.ts'
|
||||||
|
import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
|
||||||
|
import { toEditorNormal } from '../../adf/editor-normal.ts'
|
||||||
|
|
||||||
|
const code: AdfMark = { type: 'code' }
|
||||||
|
const em: AdfMark = { type: 'em' }
|
||||||
|
const highlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
|
||||||
|
const strong: AdfMark = { type: 'strong' }
|
||||||
|
|
||||||
|
const taskTypes = ['blockTaskItem', 'taskItem', 'taskList']
|
||||||
|
|
||||||
|
function read(markdown: string): readonly AdfNode[] | string {
|
||||||
|
const parsed = plainMarkdownToAdf(markdown)
|
||||||
|
if (!parsed.ok) return parsed.error.code
|
||||||
|
const blocks = toEditorNormal(parsed.value).content ?? []
|
||||||
|
const pending = [...blocks]
|
||||||
|
for (let block = pending.pop(); block !== undefined; block = pending.pop()) {
|
||||||
|
for (const child of block.content ?? []) pending.push(child)
|
||||||
|
if (!taskTypes.includes(block.type)) continue
|
||||||
|
const { localId, ...attrs } = block.attrs ?? {}
|
||||||
|
assert.equal(typeof localId, 'string', `${block.type} in ${JSON.stringify(markdown)}`)
|
||||||
|
if (Object.keys(attrs).length === 0) delete block.attrs
|
||||||
|
else block.attrs = attrs
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
function normal(...blocks: AdfNode[]): readonly AdfNode[] {
|
||||||
|
return toEditorNormal(document(...blocks)).content ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function document(...content: AdfNode[]): AdfDocument {
|
||||||
|
return { content, type: 'doc', version: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundTripped(...content: AdfNode[]): readonly AdfNode[] | string {
|
||||||
|
const markdown = adfToPlainMarkdown(document(...content))
|
||||||
|
return markdown.ok ? read(markdown.value) : markdown.error.code
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: string, ...marks: AdfMark[]): AdfNode {
|
||||||
|
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(type: string, attrs: AdfAttributes, ...content: AdfNode[]): AdfNode {
|
||||||
|
return { attrs, content, type }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bare(type: string, ...content: AdfNode[]): AdfNode {
|
||||||
|
return { content, type }
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraph(...content: AdfNode[]): AdfNode {
|
||||||
|
return bare('paragraph', ...content)
|
||||||
|
}
|
||||||
|
|
||||||
|
function said(value: string): AdfNode {
|
||||||
|
return paragraph(text(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function panel(panelType: string, ...content: AdfNode[]): AdfNode {
|
||||||
|
return node('panel', { panelType }, ...content)
|
||||||
|
}
|
||||||
|
|
||||||
|
function task(state: string, ...content: AdfNode[]): AdfNode {
|
||||||
|
return node('taskItem', { state }, ...content)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('reads an alert to a panel by its GitHub word, in any case', () => {
|
||||||
|
const alert = (word: string): readonly AdfNode[] | string => read(`> [!${word}]\n>\n> Check it.\n`)
|
||||||
|
assert.deepEqual(alert('NOTE'), [panel('info', said('Check it.'))])
|
||||||
|
assert.deepEqual(alert('IMPORTANT'), [panel('note', said('Check it.'))])
|
||||||
|
assert.deepEqual(alert('TIP'), [panel('tip', said('Check it.'))])
|
||||||
|
assert.deepEqual(alert('WARNING'), [panel('warning', said('Check it.'))])
|
||||||
|
assert.deepEqual(alert('CAUTION'), [panel('error', said('Check it.'))])
|
||||||
|
assert.deepEqual(alert('Warning'), [panel('warning', said('Check it.'))])
|
||||||
|
assert.deepEqual(alert('caution'), [panel('error', said('Check it.'))])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads an Obsidian callout to a panel by what its word means, any other word info', () => {
|
||||||
|
const alert = (word: string): unknown => {
|
||||||
|
const blocks = read(`> [!${word}]\n> Body.\n`)
|
||||||
|
return typeof blocks === 'string' ? blocks : blocks[0]?.attrs
|
||||||
|
}
|
||||||
|
assert.deepEqual(alert('hint'), { panelType: 'tip' })
|
||||||
|
for (const word of ['success', 'check', 'Done']) assert.deepEqual(alert(word), { panelType: 'success' }, word)
|
||||||
|
assert.deepEqual(alert('attention'), { panelType: 'warning' })
|
||||||
|
for (const word of ['danger', 'error', 'failure', 'fail', 'missing', 'BUG']) assert.deepEqual(alert(word), { panelType: 'error' }, word)
|
||||||
|
for (const word of ['info', 'note', 'question', 'my-type']) assert.deepEqual(alert(word), { panelType: 'info' }, word)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads the rest of an alert marker line as the panel first body paragraph, the lines after as the next', () => {
|
||||||
|
assert.deepEqual(read('> [!NOTE]\n> Line **one**.\n>\n> Two.\n'), [panel('info', paragraph(text('Line '), text('one', strong), text('.')), said('Two.'))])
|
||||||
|
assert.deepEqual(read('> [!tip] Title\n'), [panel('tip', said('Title'))])
|
||||||
|
assert.deepEqual(read('> [!tip] Title\n> body\n'), [panel('tip', said('Title'), said('body'))])
|
||||||
|
assert.deepEqual(read('> [!tip] Title\\\n> body\n'), [panel('tip', said('Title'), said('body'))])
|
||||||
|
assert.deepEqual(read('> [!NOTE]\\\n> Broken.\n'), [panel('info', said('Broken.'))])
|
||||||
|
assert.deepEqual(read('> [!NOTE]\n'), normal(panel('info', paragraph())))
|
||||||
|
assert.deepEqual(read('> > [!WARNING]\n> > Inner.\n'), [bare('blockquote', panel('warning', said('Inner.')))])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaves a quote plain where its first line is no alert marker', () => {
|
||||||
|
for (const markdown of ['> \\[!NOTE]\n> x\n', '> [!NOTE]x\n', '> **[!NOTE]**\n', '> See [!NOTE]\n', '> [!NOTE]**x**\n', '> [!]\n', '> ```\n> [!NOTE]\n> ```\n']) {
|
||||||
|
const blocks = read(markdown)
|
||||||
|
assert.equal(typeof blocks !== 'string' && blocks[0]?.type, 'blockquote', markdown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads a folded callout to an expand titled by the rest of its marker line, whatever the word', () => {
|
||||||
|
assert.deepEqual(read('> [!NOTE]- Build log\n>\n> Line.\n'), [node('expand', { title: 'Build log' }, said('Line.'))])
|
||||||
|
assert.deepEqual(read('> [!bug]+ Open **by** default\n> body\n>\n> Line.\n'), [node('expand', { title: 'Open by default' }, said('body'), said('Line.'))])
|
||||||
|
const link: AdfMark = { attrs: { href: 'https://example.com' }, type: 'link' }
|
||||||
|
assert.deepEqual(read('> [!faq]- Why?\n> See [the docs](https://example.com), **now**.\n'), [
|
||||||
|
node('expand', { title: 'Why?' }, paragraph(text('See '), text('the docs', link), text(', '), text('now', strong), text('.'))),
|
||||||
|
])
|
||||||
|
assert.deepEqual(read('> [!NOTE]- Two\\\n> lines\n'), [node('expand', { title: 'Two' }, said('lines'))])
|
||||||
|
assert.deepEqual(read('> [!faq]- See [x](http://y)\n'), normal(node('expand', { title: 'See x (http://y)' }, paragraph())))
|
||||||
|
assert.deepEqual(read('> [!faq]- [a **b**](u "t")[c](u) and [d](v)\n'), normal(node('expand', { title: 'a bc (u) and d (v)' }, paragraph())))
|
||||||
|
assert.deepEqual(read('> [!faq]- <http://y> or <a@b.c> or <http://a\\b>\n'), normal(node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph())))
|
||||||
|
assert.deepEqual(read('> [!faq]- [a b](u)\n'), normal(node('expand', { title: 'a\nb (u)' }, paragraph())))
|
||||||
|
assert.deepEqual(read('> [!faq]- [!adf:mention[@M]{id=5}](u) !adf:inlineCard{url="http://y"}\n'), normal(node('expand', { title: '@M (u) http://y' }, paragraph())))
|
||||||
|
assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), normal(node('expand', { title: 'Set ==x== here' }, paragraph())))
|
||||||
|
assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads a folded callout inside an expand to a nested expand', () => {
|
||||||
|
const markdown = '> [!NOTE]- Outer\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n>\n> > [!TIP]\n> >\n> > > [!NOTE]-\n'
|
||||||
|
assert.deepEqual(read(markdown), normal(node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph())))))
|
||||||
|
assert.deepEqual(read('- > [!NOTE]-\n'), normal(bare('bulletList', bare('listItem', bare('expand', paragraph())))))
|
||||||
|
assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), normal(bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph()))))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads a bullet list whose every item leads with a task marker to a task list', () => {
|
||||||
|
assert.deepEqual(read('- [x] Write the spec\n- [ ] Ship **it**\n- [X] Tell\n'), [
|
||||||
|
bare('taskList', task('DONE', text('Write the spec')), task('TODO', text('Ship '), text('it', strong)), task('DONE', text('Tell'))),
|
||||||
|
])
|
||||||
|
assert.deepEqual(read('- [x]\n- [ ]\\\n after\n'), normal(bare('taskList', task('DONE'), task('TODO', text('after')))))
|
||||||
|
const minted = plainMarkdownToAdf('- [x] Parent\n - [ ] Child\n')
|
||||||
|
assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [
|
||||||
|
node(
|
||||||
|
'taskList',
|
||||||
|
{ localId: '51470556-7c91-46cb-b140-16e225a9b1f2' },
|
||||||
|
node('taskItem', { localId: 'e04cbd87-eb04-4737-ae72-6d56d7799874', state: 'DONE' }, text('Parent')),
|
||||||
|
node('taskList', { localId: '9ab33f3f-8c58-428f-b4eb-343a32a177d6' }, node('taskItem', { localId: 'b16a2f09-9543-499b-8a97-b88fc342b918', state: 'TODO' }, text('Child'))),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('moves a nested task list beside its item and makes an item holding more than one block a block task item', () => {
|
||||||
|
assert.deepEqual(read('- [x] Parent\n - [ ] Child\n- [ ] Next\n'), [bare('taskList', task('DONE', text('Parent')), bare('taskList', task('TODO', text('Child'))), task('TODO', text('Next')))])
|
||||||
|
assert.deepEqual(read('- [x] First.\n\n Second.\n- [ ]\n\n ```\n x\n ```\n'), [
|
||||||
|
bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.')), node('blockTaskItem', { state: 'TODO' }, bare('codeBlock', text('x')))),
|
||||||
|
])
|
||||||
|
assert.deepEqual(read('- [x] A\n - plain\n'), [bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('A'), bare('bulletList', bare('listItem', said('plain')))))])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaves mixed, ordered and unmarked lists plain', () => {
|
||||||
|
for (const markdown of ['- \\[x] a\n', '- [x] a\n- \\[ ] b\n', '- [x] a\n- b\n', '1. [x] a\n', '- [x]a\n', '- **[x]** a\n', '- [x]**a**\n', '- [-] a\n', '- > [x] a\n']) {
|
||||||
|
const blocks = read(markdown)
|
||||||
|
assert.notEqual(typeof blocks !== 'string' && blocks[0]?.type, 'taskList', markdown)
|
||||||
|
assert.equal(JSON.stringify(blocks).includes('taskItem'), false, markdown)
|
||||||
|
}
|
||||||
|
assert.deepEqual(read('- plain\n - [ ] nested\n'), [bare('bulletList', bare('listItem', said('plain'), bare('taskList', task('TODO', text('nested')))))])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads a == pair to the editor default highlight, Yellow200 #f8e6a0 in @atlaskit/adf-schema 57.6.8', () => {
|
||||||
|
assert.deepEqual(read('a ==hi there== b\n'), [paragraph(text('a '), text('hi there', highlight), text(' b'))])
|
||||||
|
assert.deepEqual(read('**==hi==** b\n'), [paragraph(text('hi', highlight, strong), text(' b'))])
|
||||||
|
assert.deepEqual(read('==**a**_b_ `c`==\n'), [paragraph(text('a', highlight, strong), text('b', highlight, em), text(' ', highlight), text('c', code))])
|
||||||
|
assert.deepEqual(read('==`a`==\n'), [paragraph(text('a', code))])
|
||||||
|
assert.deepEqual(read('x==y==z ==a == b==, (==c==) _d_==e==\n'), [paragraph(text('x==y==z '), text('a == b', highlight), text(', ('), text('c', highlight), text(') '), text('d', em), text('e', highlight))])
|
||||||
|
assert.deepEqual(read('😀==b== ==c==😀 é==d==\n'), [paragraph(text('😀'), text('b', highlight), text(' '), text('c', highlight), text('😀 é==d=='))])
|
||||||
|
assert.deepEqual(read('この機能は==日本語==でのみ、中文==重点==内容、ภาษา==ไทย==ดี 𠀀==𠀁==𠀂 이 기능은 ==한국어==에서만 サーバー==停止==中 このiPhone==専用==アプリ\n'), [
|
||||||
|
paragraph(
|
||||||
|
text('この機能は'), text('日本語', highlight), text('でのみ、中文'), text('重点', highlight), text('内容、ภาษา'), text('ไทย', highlight), text('ดี 𠀀'), text('𠀁', highlight),
|
||||||
|
text('𠀂 이 기능은 '), text('한국어', highlight), text('에서만 サーバー'), text('停止', highlight), text('中 このiPhone'), text('専用', highlight), text('アプリ'),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
assert.deepEqual(read('# ==h==\n\n| ==c== |\n| --- |\n'), [
|
||||||
|
node('heading', { level: 1 }, text('h', highlight)),
|
||||||
|
bare('table', bare('tableRow', bare('tableHeader', paragraph(text('c', highlight))))),
|
||||||
|
])
|
||||||
|
assert.deepEqual(read('> [!NOTE]\n> ==x==\n'), [panel('info', paragraph(text('x', highlight)))])
|
||||||
|
assert.deepEqual(read('==a==\\\n==b==\n'), [paragraph(text('a', highlight), { type: 'hardBreak' }, text('b', highlight))])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaves a == no pair flanks as text', () => {
|
||||||
|
for (const markdown of ['\\==x==\n', '==x\\==\n', 'a == b == c\n', 'if a==b and c==d then\n', 'a==b== c\n', '==a==b\n', '====\n', '`==x==`\n', '==a\\\nb==\n', '**==a**==\n', '==a', '== a==\n', '==a ==\n']) {
|
||||||
|
assert.equal(JSON.stringify(read(markdown)).includes('backgroundColor'), false, markdown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads what the reduction wrote back to the node it reduced, less the attributes it drops', () => {
|
||||||
|
const localId = '01a0d99b-1f59-7e2c-a3d4-62c1f0b8e7a1'
|
||||||
|
for (const panelType of ['info', 'note', 'tip', 'warning', 'error']) {
|
||||||
|
assert.deepEqual(roundTripped(node('panel', { localId, panelType }, said('Check.'))), [panel(panelType, said('Check.'))], panelType)
|
||||||
|
}
|
||||||
|
const expand = node('expand', { localId, title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))
|
||||||
|
assert.deepEqual(roundTripped(node('panel', { panelType: 'tip' }, paragraph()), node('expand', { title: 'Empty' }, paragraph())), normal(panel('tip', paragraph()), node('expand', { title: 'Empty' }, paragraph())))
|
||||||
|
assert.deepEqual(roundTripped(expand), [node('expand', { title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))])
|
||||||
|
const tasks = bare(
|
||||||
|
'taskList',
|
||||||
|
node('taskItem', { localId, state: 'DONE' }, text('Write')),
|
||||||
|
bare('taskList', task('TODO', text('Review'))),
|
||||||
|
node('blockTaskItem', { state: 'TODO' }, said('First.'), said('Second.')),
|
||||||
|
node('blockTaskItem', { state: 'DONE' }, bare('codeBlock', text('x'))),
|
||||||
|
)
|
||||||
|
const plainTasks = bare(
|
||||||
|
'taskList',
|
||||||
|
task('DONE', text('Write')),
|
||||||
|
bare('taskList', task('TODO', text('Review'))),
|
||||||
|
node('blockTaskItem', { state: 'TODO' }, said('First.'), said('Second.')),
|
||||||
|
node('blockTaskItem', { state: 'DONE' }, bare('codeBlock', text('x'))),
|
||||||
|
)
|
||||||
|
assert.deepEqual(roundTripped(tasks), [plainTasks])
|
||||||
|
const colour: AdfMark = { attrs: { color: '#c6edfb' }, type: 'backgroundColor' }
|
||||||
|
assert.deepEqual(roundTripped(paragraph(text('a '), text('hi', colour, strong), text(' b'))), [paragraph(text('a '), text('hi', highlight, strong), text(' b'))])
|
||||||
|
assert.deepEqual(roundTripped(paragraph(text('=', colour), text(' '), text('a==b', colour))), [paragraph(text('=', highlight), text(' '), text('a==b', highlight))])
|
||||||
|
assert.deepEqual(roundTripped(paragraph(text('x'), text('y', colour))), [said('xy')])
|
||||||
|
assert.deepEqual(roundTripped(node('expand', { title: '**x** [y](z)' }, said('b'))), [node('expand', { title: '**x** [y](z)' }, said('b'))])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reads text the writer kept from reading as a marker back as text', () => {
|
||||||
|
const quote = bare('blockquote', said('[!NOTE] x'))
|
||||||
|
const list = bare('bulletList', bare('listItem', said('[x] a')), bare('listItem', said('[ ] b')))
|
||||||
|
assert.deepEqual(roundTripped(said('==x== a==b 日==本==語'), quote, list), [said('==x== a==b 日==本==語'), quote, list])
|
||||||
|
assert.deepEqual(roundTripped(bare('taskList', task('DONE', text('[x] ==a==')))), [bare('taskList', task('DONE', text('[x] ==a==')))])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps what markdownToAdf reads that no row reads, and refuses only what it refuses', () => {
|
||||||
|
assert.deepEqual(read('!adf:panel warning\n- [x] a\n!adf:/panel\n'), [panel('warning', bare('taskList', task('DONE', text('a'))))])
|
||||||
|
assert.deepEqual(read('!adf:taskList\n!adf:taskItem TODO\nb\n!adf:/taskItem\n!adf:/taskList\n'), [bare('taskList', task('TODO', text('b')))])
|
||||||
|
const listed = plainMarkdownToAdf('!adf:taskList {localId=01a0eeb2-be48-7ea7-8587-db5e013c374a}\n- [ ] b\n!adf:/taskList\n')
|
||||||
|
assert.deepEqual(listed.ok ? listed.value.content?.[0]?.attrs : listed.error.code, { localId: '01a0eeb2-be48-7ea7-8587-db5e013c374a' })
|
||||||
|
const future = bare('futureBlock', text('==x=='))
|
||||||
|
const carried = adfToMarkdown(document(future))
|
||||||
|
assert.deepEqual(carried.ok ? read(carried.value) : carried.error.code, [future])
|
||||||
|
const uncarriable = bare('taskList', node('taskItem', { extra: { a: 1 }, state: 'TODO' }, text('a')))
|
||||||
|
const carriedPanel = adfToMarkdown(document(node('panel', { extra: true, panelType: 'info' }, uncarriable)))
|
||||||
|
const restored = carriedPanel.ok ? plainMarkdownToAdf(carriedPanel.value) : carriedPanel
|
||||||
|
assert.deepEqual(restored.ok ? restored.value.content : restored.error.code, [node('panel', { extra: true, panelType: 'info' }, uncarriable)])
|
||||||
|
const red: AdfMark = { attrs: { color: '#ff0000' }, type: 'backgroundColor' }
|
||||||
|
const held = paragraph(text('a ==b== c', red), text(' ==d '), { attrs: { note: 'x' }, text: 'e==f', type: 'text' }, text(' g=='))
|
||||||
|
const spelled = adfToMarkdown(document(held))
|
||||||
|
assert.deepEqual(spelled.ok ? read(spelled.value) : spelled.error.code, [paragraph(text('a ==b== c', red), text(' '), text('d ', highlight), { attrs: { note: 'x' }, text: 'e==f', type: 'text' }, text(' g', highlight))])
|
||||||
|
const lossless = markdownToAdf('> [!NOTE]\n\n- [x] ==a==\n')
|
||||||
|
assert.deepEqual(lossless.ok ? lossless.value.content : lossless.error.code, [bare('blockquote', said('[!NOTE]')), bare('bulletList', bare('listItem', said('[x] ==a==')))])
|
||||||
|
assert.equal(read('!adf:panel\n'), 'malformed-directive')
|
||||||
|
const refusal = (markdown: string): unknown => {
|
||||||
|
const parsed = plainMarkdownToAdf(markdown)
|
||||||
|
return parsed.ok ? parsed.value : [parsed.error.code, parsed.error.message]
|
||||||
|
}
|
||||||
|
for (const markdown of ['> [!tip] \n', '> [!NOTE]- \n', '- [x] \n']) {
|
||||||
|
assert.deepEqual(refusal(markdown), ['unmappable-image', 'an image fits only as a paragraph of its own: this one shares a line with a marker'], markdown)
|
||||||
|
}
|
||||||
|
for (const markdown of ['> [!tip]\n> \n', '> [!tip] t\n> \n', '> [!NOTE]- t\n> \n', '- [x]\n \n']) {
|
||||||
|
assert.deepEqual(refusal(markdown), ['unmappable-image', 'an image fits only as a paragraph of its own: this one continues the paragraph a marker opens, which a blank line before it ends'], markdown)
|
||||||
|
}
|
||||||
|
let deep = 'x\n'
|
||||||
|
for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}`
|
||||||
|
assert.equal(typeof read(deep), 'object')
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||||
|
import { mintTaskIds } from './task-ids.ts'
|
||||||
|
|
||||||
|
const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||||
|
|
||||||
|
function taskIds(document: AdfDocument): unknown[] {
|
||||||
|
const ids: unknown[] = []
|
||||||
|
const pending: AdfNode[] = [...(document.content ?? [])].reverse()
|
||||||
|
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
||||||
|
if (['blockTaskItem', 'taskItem', 'taskList'].includes(node.type)) ids.push(node.attrs?.['localId'])
|
||||||
|
for (const child of [...(node.content ?? [])].reverse()) pending.push(child)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
function tasks(...content: AdfNode[]): AdfDocument {
|
||||||
|
return { content, type: 'doc', version: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('mints each task node lacking a localId a UUID v4 in document order, unique and the same every run', () => {
|
||||||
|
const unminted = (): AdfDocument => tasks({ content: [{ attrs: { state: 'TODO' }, type: 'taskItem' }, { content: [{ attrs: { state: 'DONE' }, type: 'blockTaskItem' }], type: 'taskList' }], type: 'taskList' })
|
||||||
|
const minted = unminted()
|
||||||
|
mintTaskIds(minted, '- [ ] a\n', new Set())
|
||||||
|
const ids = taskIds(minted)
|
||||||
|
assert.equal(ids.length, 4)
|
||||||
|
for (const id of ids) assert.match(String(id), uuidV4)
|
||||||
|
assert.equal(new Set(ids).size, 4)
|
||||||
|
const again = unminted()
|
||||||
|
mintTaskIds(again, '- [ ] a\n', new Set())
|
||||||
|
assert.deepEqual(taskIds(again), ids)
|
||||||
|
const other = unminted()
|
||||||
|
mintTaskIds(other, '- [ ] b\n', new Set())
|
||||||
|
assert.equal(taskIds(other).some((id) => ids.includes(id)), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps a localId the document spells and skips it when minting, a carried one too', () => {
|
||||||
|
const first = tasks({ type: 'taskList' })
|
||||||
|
mintTaskIds(first, 'x', new Set())
|
||||||
|
const [taken] = taskIds(first)
|
||||||
|
const carried: AdfNode = { content: [{ attrs: { localId: String(taken) }, type: 'taskList' }], type: 'futureBlock' }
|
||||||
|
const spelled = tasks({ type: 'taskList' }, carried)
|
||||||
|
mintTaskIds(spelled, 'x', new Set([carried]))
|
||||||
|
const [minted, kept] = taskIds(spelled)
|
||||||
|
assert.equal(kept, taken)
|
||||||
|
assert.notEqual(minted, taken)
|
||||||
|
assert.match(String(minted), uuidV4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaves a node the carry restores as carried, minting neither it nor what it holds', () => {
|
||||||
|
const list = (): AdfNode => ({ content: [{ attrs: { state: 'TODO' }, type: 'taskItem' }], type: 'taskList' })
|
||||||
|
const carriedList = list()
|
||||||
|
const carriedPanel: AdfNode = { attrs: { panelType: 'info' }, content: [list()], type: 'panel' }
|
||||||
|
const future: AdfNode = { content: [list()], type: 'futureBlock' }
|
||||||
|
const document = tasks(carriedList, carriedPanel, future)
|
||||||
|
mintTaskIds(document, 'x', new Set([carriedList, carriedPanel]))
|
||||||
|
assert.deepEqual(document, tasks(list(), { attrs: { panelType: 'info' }, content: [list()], type: 'panel' }, { content: [list()], type: 'futureBlock' }))
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||||
|
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||||
|
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||||
|
|
||||||
|
const taskTypes = new Set(['blockTaskItem', 'taskItem', 'taskList'])
|
||||||
|
|
||||||
|
export function mintTaskIds(document: AdfDocument, markdown: string, carried: ReadonlySet<AdfNode>): void {
|
||||||
|
const taken = new Set<string>()
|
||||||
|
for (const node of preorder(document, () => true)) {
|
||||||
|
const localId = nodeAttrs(node)['localId']
|
||||||
|
if (typeof localId === 'string') taken.add(localId)
|
||||||
|
}
|
||||||
|
const seed = hash128(markdown).join(' ')
|
||||||
|
let count = 0
|
||||||
|
for (const node of preorder(document, (held) => !carried.has(held) && blockNodeModel(held.type)?.contentModel === 'block')) {
|
||||||
|
if (!taskTypes.has(node.type) || carried.has(node) || typeof nodeAttrs(node)['localId'] === 'string') continue
|
||||||
|
let localId = ''
|
||||||
|
do {
|
||||||
|
count += 1
|
||||||
|
localId = uuidV4(hash128(`${seed} ${count}`))
|
||||||
|
} while (taken.has(localId))
|
||||||
|
taken.add(localId)
|
||||||
|
node.attrs = { ...node.attrs, localId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function* preorder(document: AdfDocument, entered: (node: AdfNode) => boolean): Generator<AdfNode> {
|
||||||
|
const pending: AdfNode[] = []
|
||||||
|
const pushReversed = (nodes: readonly AdfNode[]): void => {
|
||||||
|
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
||||||
|
const node = nodes[index]
|
||||||
|
if (node !== undefined) pending.push(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pushReversed(document.content ?? [])
|
||||||
|
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
|
||||||
|
yield node
|
||||||
|
if (entered(node)) pushReversed(nodeContent(node))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cyrb128, public domain.
|
||||||
|
function hash128(text: string): number[] {
|
||||||
|
let h1 = 1779033703
|
||||||
|
let h2 = 3144134277
|
||||||
|
let h3 = 1013904242
|
||||||
|
let h4 = 2773480762
|
||||||
|
for (let index = 0; index < text.length; index += 1) {
|
||||||
|
const unit = text.charCodeAt(index)
|
||||||
|
h1 = h2 ^ Math.imul(h1 ^ unit, 597399067)
|
||||||
|
h2 = h3 ^ Math.imul(h2 ^ unit, 2869860233)
|
||||||
|
h3 = h4 ^ Math.imul(h3 ^ unit, 951274213)
|
||||||
|
h4 = h1 ^ Math.imul(h4 ^ unit, 2716044179)
|
||||||
|
}
|
||||||
|
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067)
|
||||||
|
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233)
|
||||||
|
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213)
|
||||||
|
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179)
|
||||||
|
h1 ^= h2 ^ h3 ^ h4
|
||||||
|
h2 ^= h1
|
||||||
|
h3 ^= h1
|
||||||
|
h4 ^= h1
|
||||||
|
return [h1 >>> 0, h2 >>> 0, h3 >>> 0, h4 >>> 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuidV4(lanes: readonly number[]): string {
|
||||||
|
const hex = lanes.map((lane) => lane.toString(16).padStart(8, '0')).join('')
|
||||||
|
const variant = ((Number.parseInt(hex.charAt(16), 16) & 3) | 8).toString(16)
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ConvertFault } from '../result.ts'
|
import type { ConvertFault } from '../result.ts'
|
||||||
import { backslashEscape, claimsPipeLine, trimSpace } from './commonmark-grammar.ts'
|
import { backslashEscape, claimsPipeLine, trimSpace } from './commonmark/grammar.ts'
|
||||||
|
|
||||||
const alignmentCell = /^:-+:?$|^-+:$/
|
const alignmentCell = /^:-+:?$|^-+:$/
|
||||||
const delimiterCell = /^-+$/
|
const delimiterCell = /^-+$/
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { isWordCharacter } from './commonmark/emphasis-matching.ts'
|
||||||
|
|
||||||
|
export type Flavour = 'lossless' | 'plain'
|
||||||
|
|
||||||
|
type AlertMarker = { folded: boolean; length: number; panelType: string }
|
||||||
|
|
||||||
|
export const foldedAlertMarker = '[!NOTE]-'
|
||||||
|
export const highlightDelimiter = '=='
|
||||||
|
|
||||||
|
const alertWords: Readonly<Record<string, string>> = {
|
||||||
|
error: 'CAUTION',
|
||||||
|
info: 'NOTE',
|
||||||
|
note: 'IMPORTANT',
|
||||||
|
success: 'TIP',
|
||||||
|
tip: 'TIP',
|
||||||
|
warning: 'WARNING',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ー, ー and the kana voicing marks sit outside the kana scripts; Script_Extensions would also take Latin combining marks.
|
||||||
|
const boundingScript = /^[\u3099\u309a\u30fc\uff70\uff9e\uff9f\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Khmer}\p{Script=Lao}\p{Script=Myanmar}\p{Script=Thai}]$/u
|
||||||
|
|
||||||
|
const panelTypesByWord: Readonly<Record<string, string>> = {
|
||||||
|
attention: 'warning',
|
||||||
|
bug: 'error',
|
||||||
|
caution: 'error',
|
||||||
|
check: 'success',
|
||||||
|
danger: 'error',
|
||||||
|
done: 'success',
|
||||||
|
error: 'error',
|
||||||
|
fail: 'error',
|
||||||
|
failure: 'error',
|
||||||
|
hint: 'tip',
|
||||||
|
important: 'note',
|
||||||
|
missing: 'error',
|
||||||
|
success: 'success',
|
||||||
|
tip: 'tip',
|
||||||
|
warning: 'warning',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function alertMarker(panelType: unknown): string {
|
||||||
|
const word = typeof panelType === 'string' && Object.hasOwn(alertWords, panelType) ? alertWords[panelType] : undefined
|
||||||
|
return `[!${word ?? 'NOTE'}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAlertMarker(text: string): AlertMarker | undefined {
|
||||||
|
const marker = /^\[!([\w-]+)\]([+-]?)/.exec(text)
|
||||||
|
if (marker === null) return undefined
|
||||||
|
const word = (marker[1] ?? '').toLowerCase()
|
||||||
|
const panelType = Object.hasOwn(panelTypesByWord, word) ? panelTypesByWord[word] : undefined
|
||||||
|
return { folded: marker[2] !== '', length: marker[0].length, panelType: panelType ?? 'info' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A marker leads text that ends at it or goes on past whitespace or a hard break.
|
||||||
|
export function leadingMarker<T extends { length: number }>(text: string, read: (text: string) => T | undefined): T | undefined {
|
||||||
|
const marker = read(text)
|
||||||
|
if (marker === undefined) return undefined
|
||||||
|
const rest = text.slice(marker.length, marker.length + 2)
|
||||||
|
return rest === '' || /^(?:[ \t\n]|\\\n)/.test(rest) ? marker : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// A delimiter is bounded outside by the code point beyond it, and flanks by the character inside it.
|
||||||
|
export function highlightFlanking(source: string, index: number): { closes: boolean; opens: boolean } {
|
||||||
|
const end = index + highlightDelimiter.length
|
||||||
|
const before = Array.from(source.slice(Math.max(0, index - 2), index)).at(-1) ?? ''
|
||||||
|
const after = Array.from(source.slice(end, end + 2))[0] ?? ''
|
||||||
|
return { closes: flanks(before) && bounds(after, before), opens: flanks(after) && bounds(before, after) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bounds(outside: string, inside: string): boolean {
|
||||||
|
return !isWordCharacter(outside) || boundingScript.test(outside) || boundingScript.test(inside)
|
||||||
|
}
|
||||||
|
|
||||||
|
function flanks(character: string): boolean {
|
||||||
|
return character !== '' && !/\s/.test(character)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taskMarker(state: unknown): string {
|
||||||
|
return state === 'DONE' ? '[x]' : '[ ]'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTaskMarker(text: string): { length: number; state: 'DONE' | 'TODO' } | undefined {
|
||||||
|
const marker = /^\[([ xX])\]/.exec(text)
|
||||||
|
if (marker === null) return undefined
|
||||||
|
return { length: marker[0].length, state: marker[1] === ' ' ? 'TODO' : 'DONE' }
|
||||||
|
}
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
// Levels count per AGENTS.md §11.
|
// A level is one block-list recursion in either direction: a readable list's items sit one below it, its directive spelling's two.
|
||||||
export const largestNesting = 500
|
export const largestNesting = 500
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { readFileSync, readdirSync } from 'node:fs'
|
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import test from 'node:test'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { readFileSync, readdirSync } from 'node:fs'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
const sourceRoot = dirname(fileURLToPath(import.meta.url))
|
const sourceRoot = dirname(fileURLToPath(import.meta.url))
|
||||||
const union = /export type ConvertErrorCode =\n((?:\s+\| '[a-z-]+'\n)+)/
|
const union = /export type ConvertErrorCode =\n((?:\s+\| '[a-z-]+'\n)+)/
|
||||||
@@ -25,7 +25,7 @@ function calledCodes(): string[] {
|
|||||||
return [...called].sort()
|
return [...called].sort()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The list is frozen at 0.1.0 (AGENTS.md §8), so a code outliving its cause is a removal that costs a MAJOR.
|
// Removing a code is breaking (docs/decisions.md §The code list), so a code must not outlive its cause.
|
||||||
test('every ConvertErrorCode is the code of a production call site, and every call site names a declared one', () => {
|
test('every ConvertErrorCode is the code of a production call site, and every call site names a declared one', () => {
|
||||||
assert.deepEqual(calledCodes(), declaredCodes())
|
assert.deepEqual(calledCodes(), declaredCodes())
|
||||||
})
|
})
|
||||||
|
|||||||
-823
@@ -1,823 +0,0 @@
|
|||||||
# Todo history
|
|
||||||
|
|
||||||
The done `todo.md` items in full, as they were written. `todo.md` keeps a one-line summary of each.
|
|
||||||
|
|
||||||
## Milestones
|
|
||||||
|
|
||||||
- [x] **0 — Scaffold.** `package.json` per §6, `tsconfig.json`, `.npmrc` (`save-exact=true`), the
|
|
||||||
Docker tooling, `renovate.json` (§9), and `.gitea/workflows/ci.yml` gating branches:
|
|
||||||
`runs-on: docker-host`, actions pinned to semver tags.
|
|
||||||
- [x] **1a — The directive grammar** (`spec/flavour.md`): inline/block/leaf directive forms,
|
|
||||||
attributes, escaping, nesting, canonical form, the opaque-carry spelling, the raw-HTML
|
|
||||||
input policy.
|
|
||||||
- [x] **1b — Block node syntaxes** in `spec/flavour.md`: panel, expand/nestedExpand, the media
|
|
||||||
family, the pipe-vs-directive table rule and the directive table form, task and decision
|
|
||||||
lists, layout, extensions, syncBlock.
|
|
||||||
- [x] **1c — Inline node syntaxes and marks** in `spec/flavour.md`: mention, emoji, status, date,
|
|
||||||
inlineCard, mediaInline; underline, subsup, textColor, border; the spelling for text nodes
|
|
||||||
whose whitespace CommonMark cannot hold (literal newlines, leading or trailing spaces) —
|
|
||||||
escape-based, never literal, since pipe cells trim and pad. At `mediaInline`, check real
|
|
||||||
payloads for external-URL support — if it exists, revisit the media section's
|
|
||||||
mid-text-image error and its "no slot" ground.
|
|
||||||
- [x] **1d — Corpus start** (§10): checked-in fixtures per spec'd node, in `corpus/`, one
|
|
||||||
directory per contract kind (`corpus/README.md`).
|
|
||||||
**Settled** (the maintainer, 2026-08-26): the nodes CommonMark spells get directive sections
|
|
||||||
of their own, rather than riding the opaque carry. Per `@atlaskit/adf-schema` 57.1.0 every
|
|
||||||
block node it spells — `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`,
|
|
||||||
`orderedList`, `paragraph`, `rule` — carries a `localId` with no spelling, `codeBlock` also
|
|
||||||
`hideLineNumbers`, `uniqueId` and `wrap`, `blockquote` also marks, and `hardBreak` `text`
|
|
||||||
and `localId`; 2f gives each a place, and the plain spelling stays wherever the attributes
|
|
||||||
are absent. That hands the two collision sites in `corpus/unspellable/` the second spelling
|
|
||||||
they lacked, so each takes the directive form as a `media` with an empty `alt` already does
|
|
||||||
(`spec/flavour.md`, the CommonMark image): a `codeBlock` whose info string is empty, and an
|
|
||||||
`orderedList` starting at 1.
|
|
||||||
**Also blocked**: the link rule covers destination spaces only, so two shapes
|
|
||||||
have no spelling and are refused meanwhile — href `https://example.com/a)b` and title
|
|
||||||
`He said "hi"`, both in `corpus/unspellable/`. Two defensible spellings each — angle
|
|
||||||
brackets or a backslash escape, and for titles `'…'` or `(…)` besides — so §8 leaves
|
|
||||||
the pick here. **Also blocked**: block separation is unstated for a CommonMark block beside a
|
|
||||||
directive block in a container body — an `expand` whose content is `paragraph` "A" then a
|
|
||||||
`panel` (`panelType` `warning`) holding "B" spells `A` and `:::panel warning` either on
|
|
||||||
consecutive lines or with a blank line between. Two defensible spellings, so §8 leaves the
|
|
||||||
pick here; the answer governs every unknown node type too, the block carry counting as a
|
|
||||||
CommonMark block since its spelling is a fenced code block.
|
|
||||||
`unspelled-block-separation` refuses the pair meanwhile, an empty paragraph's
|
|
||||||
`::paragraph` beside a CommonMark block included — and, since a `mediaSingle`'s spelling now
|
|
||||||
follows whether CommonMark can spell its URL, two sibling images differing only by an
|
|
||||||
`&` land in the same refusal.
|
|
||||||
- [x] **1d1 — The CommonMark subset**: blockquote, bulletList, codeBlock, heading, orderedList,
|
|
||||||
paragraph, rule, listItem, hardBreak, text, code spans, and the `code`, `em`, `link`,
|
|
||||||
`strike` and `strong` marks — one mark per text node; nesting is 1d3's.
|
|
||||||
- [x] **1d2 — Block nodes**: panel, expand/nestedExpand, the media family and the CommonMark
|
|
||||||
image shape, both table forms, task and decision lists, layout, extensions, syncBlock —
|
|
||||||
with the reserved `marks` attribute and the fence lengths nesting forces.
|
|
||||||
- [x] **1d3 — Inline nodes and marks**: date, emoji, inlineCard, mediaInline, mention, status;
|
|
||||||
border, subsup, textColor, underline; the content slot's `text` attribute and the
|
|
||||||
`:text{text="…"}` whitespace spelling.
|
|
||||||
- [x] **2 — `adfToMarkdown`.** First real code. Each sub-item turns one corpus directory green;
|
|
||||||
the two that have no fixtures yet write them in the same chunk, tests first (§10).
|
|
||||||
- [x] **2a — The runner and the CommonMark subset.** The corpus runner: walk
|
|
||||||
`corpus/round-trip/`, assert `adfToMarkdown` emits each `.md` byte for byte. Decide here
|
|
||||||
where §10's coverage check lives, and gate that every `corpus/**/*.json` re-serializes to
|
|
||||||
itself under the library's own canonical serializer — one implementation, keys sorted, two
|
|
||||||
spellings: two-space indent for the corpus files and the block carry's body, compact for
|
|
||||||
the inline carry. `commonmark-subset/` green.
|
|
||||||
- [x] **2b — Block nodes.** `block-nodes/` green. A nested list that cannot interrupt the block
|
|
||||||
above it is refused meanwhile, not spelled: the maintainer's answer on tight-versus-blank
|
|
||||||
separation turns that refusal into an emission. The test is broader than the name it
|
|
||||||
carries — `interruptsParagraph` reads the next list alone, so a list after a block no
|
|
||||||
paragraph continues, a code block say, is refused too — and the same answer narrows it.
|
|
||||||
Block separation becomes
|
|
||||||
`separationBetween(previous, next, container)` here — a boolean cannot hold the third case
|
|
||||||
`spec/flavour.md` states for two directive blocks in a container body, and the maintainer's
|
|
||||||
answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give
|
|
||||||
the emitter's refusals a corpus home while the directories grow: `corpus/unspellable/`,
|
|
||||||
a `.json` beside the `ConvertErrorCode` it must return, the emitter half of `corpus/errors/`.
|
|
||||||
- [x] **2c — Inline nodes and marks.** `inline-nodes/` green. `InlineSegment` splits into its
|
|
||||||
two axes — escapability (`backslash`, `bracketed`, `none`) and the emphasis role. A lone
|
|
||||||
surrogate in a text node emits verbatim and becomes U+FFFD on any UTF-8 encode, a §2 break
|
|
||||||
plain text still holds open — attribute values already escape it. The pipe form's fallback
|
|
||||||
reads the emitted segments rather than naming the nodes whose attribute values spell a pipe
|
|
||||||
as syntax, so 2e's `\u007c` narrows it in one place.
|
|
||||||
- [x] **2d — The opaque carry** (§3). Fixtures and emitter together, into
|
|
||||||
`corpus/round-trip/opaque-carry/`: an unknown node in both positions, the reserved `adf`
|
|
||||||
info string, and the `codeBlock` whose language is `adf` — carried whole ahead of the
|
|
||||||
attribute fallback 2e owes, since the reservation leaves that node no other spelling
|
|
||||||
whatever 1d decides for its `localId`.
|
|
||||||
- [x] **2e — Carve-outs and combinations.** Fixtures and emitter together, into
|
|
||||||
`corpus/round-trip/combinations/`.
|
|
||||||
- [x] **2e1 — The carve-outs and the claimed line.** The three carve-outs
|
|
||||||
and their escapes, and a paragraph line inside a container body shaped like a closing
|
|
||||||
fence (`:::`, `::: x`). Guard `fenceNestingFault`'s bare-run pop here too — a run shorter
|
|
||||||
than the open fence is a fault, not a close — which today's emitter cannot reach.
|
|
||||||
- [x] **2e2 — Mark runs and the runs a carry breaks.** The longest-run rule, attributes
|
|
||||||
included, and a mark spelling that cannot open where it sits (`un**-real**istic`; the spec
|
|
||||||
owes the carry a trigger). One mark vocabulary lands here, before 2e3 changes the
|
|
||||||
attribute spelling: `emphasisSpellings`, `linkAttributes` and the `code`/`link` names join
|
|
||||||
the mark table (3a parted it across `adf/mark-attributes.ts` and
|
|
||||||
`markdown/mark-spellings.ts`), which holds four of the nine marks while the rest are
|
|
||||||
branch literals in the emitter — and the parser (3) needs every name to make `:em[x]`
|
|
||||||
the named error `spec/flavour.md` promises.
|
|
||||||
- [x] **2e3 — Attribute canonicalization and the quoted value's escape.**
|
|
||||||
**Settled** (the maintainer, 2026-08-26): a quoted attribute value escapes `` ` ``, `&`,
|
|
||||||
`<` and `|` as `\u0060`, `\u0026`, `\u003c` and `\u007c`, in every directive, block and
|
|
||||||
inline alike — the constructs those four open all bind at or before a directive does, and
|
|
||||||
nothing else reaches into `{attrs}`. Emitted attributes being inert leaves 3 free to keep
|
|
||||||
CommonMark's own precedence between a directive and a code span, and collapsed
|
|
||||||
`escaping: 'attribute'` into `none`. The escaper's link-opener scan skips emitted syntax
|
|
||||||
to match: a `](` inside a directive escapes no text `[`.
|
|
||||||
- [x] **2e4 — The carry's fallback triggers.** `spec/flavour.md` carries a node its section
|
|
||||||
cannot spell — an attrs key no section lists, a value that is not the section's type, an
|
|
||||||
arg slot holding no bare token, marks no nesting spells — where the emitter still refuses,
|
|
||||||
which leaves the refusals a container's own spelling owns. The flanking trigger 2e2
|
|
||||||
added to that list is the odd one out: `unspellableMark` finds it after assembly and
|
|
||||||
names a mark type against the line's path, so the failing run needs identifying before
|
|
||||||
the carry can replace the refusal `mark-inside-word` pinned.
|
|
||||||
- [x] **2e5 — Combined documents and the collision property.** Documents combining nodes rather
|
|
||||||
than isolating one, and the gate's collision property: no two corpus documents may emit
|
|
||||||
the same bytes — one spelling for two documents is a round-trip break no parser can undo,
|
|
||||||
and it is provable without one.
|
|
||||||
**Settled** (the maintainer, 2026-08-27): the approximation this item inherited — flanking
|
|
||||||
exact, CommonMark's *matching* unmodelled — had two round-trip breaks reachable by hand,
|
|
||||||
so the emitter now models the matching. `process_emphasis` runs over the runs the emitter
|
|
||||||
wrote (`emphasis-matching.ts`) and a pair it hands to another delimiter rides the carry,
|
|
||||||
which is what the multiple-of-3 rule did to the em in `un*a**b*****c**istic`. A delimiter
|
|
||||||
run in text now escapes wherever CommonMark could open or close with it, not only open:
|
|
||||||
one that could only close stole the spelling around it (`un*a* b*istic`), and escaping
|
|
||||||
both ways keeps every delimiter the emitter did not write out of the matching. The
|
|
||||||
canonical form gained a backslash where a run only closes — `\*not emphasis\*`, and
|
|
||||||
2e1's `carve-out-strike` a third and fourth.
|
|
||||||
- [x] **2f — The attributes CommonMark cannot hold.** 1d's settled answer: the block nodes
|
|
||||||
CommonMark spells — `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`,
|
|
||||||
`orderedList`, `paragraph`, `rule` — get directive sections in `spec/flavour.md` carrying
|
|
||||||
`localId`, `codeBlock`'s `hideLineNumbers`, `uniqueId` and `wrap`, and `blockquote`'s
|
|
||||||
marks, while `hardBreak`'s `text` and `localId` join the inline directive it already has.
|
|
||||||
The plain spelling stays wherever those attributes are absent, so only a node that carries
|
|
||||||
one takes the directive form — which is what keeps a real payload readable rather than a
|
|
||||||
page of carried JSON. Fixtures and emitter together, and the three documents the answer
|
|
||||||
settles leave `corpus/unspellable/` as round-trip pairs: `block-local-id`,
|
|
||||||
`code-block-empty-language`, `ordered-list-start-one`.
|
|
||||||
**Settled** (the maintainer, 2026-08-27): the `codeBlock` directive's body is one fenced
|
|
||||||
code block, the language staying on the fence line so every renderer still highlights it;
|
|
||||||
a language no info string holds — empty, a backtick, edge whitespace, an entity reference
|
|
||||||
or the reserved `adf` — rides the `language` attribute with the fence bare, which retires
|
|
||||||
2d's carry for the reserved name along with the premise that left it no other spelling.
|
|
||||||
The plain spelling gives way wherever it cannot render what the node carries rather than
|
|
||||||
only where it has no place for it, so a heading level absent or outside 1-6 and an order
|
|
||||||
whose markers would run past 999999999 take the directive form too, and
|
|
||||||
`ambiguous-attribute-spelling`, `unspellable-code-block-language`,
|
|
||||||
`unspellable-list-marker`, `unspelled-block-marks` and `unsupported-heading-level` leave
|
|
||||||
`ConvertErrorCode`; content and placement refusals stay, which leaves the directive form
|
|
||||||
spelling an empty list or a non-`listItem` child that the plain form refuses. `order` is
|
|
||||||
the first marker, so `order: 1` keeps the plain `1.` — what a real payload carries — and a
|
|
||||||
list carrying no `order` has no number to take and takes the directive form.
|
|
||||||
2f raises what 1d's unspelled block separation costs: a single `localId` on a paragraph
|
|
||||||
beside a plain one now refuses every container body that is a directive's — a panel, an
|
|
||||||
expand, a table cell — where before 2f the attribute refused the document anyway.
|
|
||||||
- [x] **3 — `markdownToAdf` (`0.1.0`).** Each sub-item lands the fixtures its own code reads, and
|
|
||||||
the runner grows a parse half as they do: readers for `corpus/normalization/` (setext,
|
|
||||||
indented code, loose lists, `*`/`+`
|
|
||||||
bullets, entity references, soft wraps — one-way, the markdown not canonical) and
|
|
||||||
`corpus/errors/` (a markdown input per named error, the code in a `.error` beside it) with
|
|
||||||
the first fixture each. `commonmark-subset/` cannot be the first to green — `::paragraph`
|
|
||||||
and `:hardBreak{}` sit in it — so 3b through 3f answer to their own tests and the one-way
|
|
||||||
fixtures they land, and 3g is where the first directory reads back. The raw-HTML element
|
|
||||||
mapping is empty until milestone 6, so at `0.1.0` every raw-HTML construct in input — a
|
|
||||||
block, an inline tag, a comment, a processing instruction — is a named error. Input is where
|
|
||||||
unbounded nesting actually arrives, so §11's 500 binds all three of the emitter's guards
|
|
||||||
here: block depth at 3c and again at 3f's container fences, inline and mark depth at 3f and
|
|
||||||
3i, a carried value's JSON at 3j, where `isJsonValue` already bounds it.
|
|
||||||
- [x] **3a — The hierarchy.** Mechanical, ahead of the first parser file: `src/adf/` and
|
|
||||||
`src/markdown/` (`html/` arrives with its first file, 6-7), the grammar module shared
|
|
||||||
inside `markdown/`, and `emphasis-matching.ts` beside it — the parser reuses it whole,
|
|
||||||
`delimiterFlags` and `matchEmphasis` taking CommonMark's own run vocabulary rather than
|
|
||||||
the emitter's, so no second `process_emphasis` exists to drift from the first.
|
|
||||||
`block-directives.ts` and `inline-directives.ts` each part by file, a node table
|
|
||||||
milestones 6-7 need in `adf/` beside a markdown spelling that belongs in `markdown/`.
|
|
||||||
`directive-attributes.ts` cannot: `vocabularyPairs` walks the vocabulary and spells the
|
|
||||||
value in one pass, the type check living inside `spellAttributeValue`, so the check comes
|
|
||||||
out as its own predicate and goes to `adf/` with the walk while the spelling stays in
|
|
||||||
`markdown/`, `isBareToken` with it — only spelling calls it. `markSpellings` is the one
|
|
||||||
table whose keys part rather than its file, so key the markdown half off the ADF half's
|
|
||||||
type: a mark named in one and not the other is then a compile error instead of a false
|
|
||||||
refusal. `spellDestination`, `spellTitle` and `balanced` leave `markdown-inline.ts` here
|
|
||||||
too — CommonMark destination spelling `emitLink` and `tryImageLine` share, and the six
|
|
||||||
concerns that file carries are one fewer for it. `AttributeKind` and `AttributeVocabulary`
|
|
||||||
follow the walk into `adf/`, the vocabulary a string-typed attribute grammar needs and
|
|
||||||
HTML will want too, not a markdown spelling.
|
|
||||||
**Settled** (the maintainer, 2026-08-27): `markdown/` parts here as well, into `emit/` and
|
|
||||||
`parse/` with the shared set at the root — the grammar module, emphasis matching,
|
|
||||||
the tables' markdown halves — and `parse/` arriving with 3b's first
|
|
||||||
file, the rule `html/` already follows. And the node tables, a second copy of
|
|
||||||
`spec/flavour.md`'s prose whose mistyped attribute name degrades into a false refusal no
|
|
||||||
test catches, get their guard: a test reads the spec's node sections, takes each
|
|
||||||
`name (type)` list and asserts it equals the table, leaving the spec the source a human
|
|
||||||
writes with no build step and no generated file. It is built at 3g, where a wrong entry
|
|
||||||
starts refusing documents.
|
|
||||||
- [x] **3b — The leaf blocks.** The line walk that opens and closes a block, ahead of any inline
|
|
||||||
parsing: paragraph, ATX and setext heading, thematic break, fenced and indented code
|
|
||||||
block, the HTML block whose lines it swallows whether or not the construct then errors,
|
|
||||||
the link reference definitions a closing paragraph gives up, and the blank lines between
|
|
||||||
them. The openers are `commonmark-grammar.ts`'s — one table answers both directions, or
|
|
||||||
the emitter under-escapes a line the parser reads as a block and §2 breaks in silence —
|
|
||||||
the HTML block's start conditions excepted, which are new here since the emitter writes
|
|
||||||
none. Block-level claiming lands here too: a colon run or an unescaped leading `|` is
|
|
||||||
claimed, the parse behind it 3f's and 3h's, a claim with nothing yet to parse it the named
|
|
||||||
error the claim promises meanwhile. The runner's parse half comes with it, and the first
|
|
||||||
`normalization/` fixtures, holding inline-trivial content so 3d and 3e add beside them
|
|
||||||
rather than editing them.
|
|
||||||
- [x] **3c — The container blocks.** Blockquote, bullet and ordered list: the continuation a
|
|
||||||
marker's width sets, lazy continuation, and the tightness ADF does not record — `> `
|
|
||||||
repeated being two bytes a level, so this is the cheapest way to reach §11's 500. 3b's leaf
|
|
||||||
readers scan the physical line themselves, so a container re-cuts the walk rather than adding
|
|
||||||
to it: the open containers' prefix comes off the line first and the readers take one line at a
|
|
||||||
time, `LeafBlock` renamed with the union they join and `blockNode`'s chain gaining their
|
|
||||||
branches.
|
|
||||||
**Settled** (the maintainer, 2026-08-27): a claimed line ends lazy continuation, so a
|
|
||||||
closing fence on the line after a blockquote's open paragraph closes its container instead
|
|
||||||
of continuing the paragraph CommonMark would fold it into. Claiming at block level is
|
|
||||||
already absolute, and this binds input alone — 2e1's `closing-fence-line` orders the
|
|
||||||
emitter's blockquote away from the edge either way — so `spec/flavour.md`'s claiming
|
|
||||||
paragraph gains the case here. And 2b's tight-versus-blank, one answer for both
|
|
||||||
directions: the tight spelling stays wherever it parses back, a blank line going in only
|
|
||||||
where the nested list would be swallowed — `interruptsParagraph` inverted from a refusal
|
|
||||||
into the separation it names, and `spec/flavour.md`'s "none between a nested list and a
|
|
||||||
CommonMark block above it" gaining that exception. Every fixture spelled tight today keeps
|
|
||||||
its bytes, and `nested-list-tight` becomes the round-trip pair `nested-list-separation`.
|
|
||||||
- [x] **3d — Inline text.** The inline scanner over a block's content: backslash escapes, entity
|
|
||||||
references decoding to their characters, code spans and the literal they hold — directive
|
|
||||||
syntax and `~~` included — CommonMark's own hard breaks, a trailing backslash and two
|
|
||||||
trailing spaces alike, a soft line break as one space, the fenced info string's own decoding
|
|
||||||
the block walk leaves raw, and the raw inline tag, comment and processing instruction
|
|
||||||
refused by name, recognized by the `commonmark-grammar.ts` predicates the emitter already
|
|
||||||
escapes against, under 3b's one-table rule.
|
|
||||||
**Settled** (the maintainer, 2026-08-30): entity references decode against HTML5's whole
|
|
||||||
named table, checked in packed (§5) — a curated subset leaves 3k an exception class and a
|
|
||||||
cutoff line nobody can defend. And the escape superset the emitter reads for raw HTML
|
|
||||||
tightens into one precise CommonMark inline reader both directions share, the email
|
|
||||||
autolink parting off as 3e's own predicate: refusing on the superset would refuse
|
|
||||||
`1 <b 2`, a fourth carve-out §4 and the README do not list. `holdsEntityReference` reads
|
|
||||||
the table for the same reason, so `¬areference;` is emitted bare.
|
|
||||||
- [x] **3e — Emphasis and links.** `_`, `*` and `~~` runs through `matchEmphasis` to the `em`,
|
|
||||||
`strong` and `strike` marks; links inline and reference, 3b's definitions resolved here,
|
|
||||||
autolinks, and the image gap's named errors — a titled image, and one amid other text.
|
|
||||||
`spec/flavour.md` does not yet pin `~`'s `can_open`/`can_close`, which is transcription
|
|
||||||
rather than a decision: `delimiterFlags` already gives it CommonMark flanking, as for `*`,
|
|
||||||
and §8 fixed that the moment the emitter shipped.
|
|
||||||
**Settled** (the maintainer, 2026-08-27): 1d's deferred pair takes the backslash inside
|
|
||||||
the delimiters it already has — `[a](https://example.com/a\)b)` and
|
|
||||||
`[a](/url "He said \"hi\"")`. `<…>` stays reserved for the destination holding a space,
|
|
||||||
where nothing else works, so each construct keeps one spelling and a destination holding
|
|
||||||
both composes. `link-destination-parenthesis` and `link-title-quote` become round-trip
|
|
||||||
pairs.
|
|
||||||
**Settled** (the maintainer, 2026-08-31): the destination escapes only the parenthesis it
|
|
||||||
leaves unbalanced, so `/wiki/Foo_(bar)` keeps its bytes, and the backslash stays refused in
|
|
||||||
both the destination and the title — which leaves `[a](/a\b)` a §2 hole 3k's exception list
|
|
||||||
answers, as `<http://x?a=1&b=2>` is, autolinks decoding neither escapes nor references.
|
|
||||||
The image gap mints `unmappable-image`, mirroring `unmappable-html` — a construct in input
|
|
||||||
no ADF node carries. And the CommonMark image shape lands here rather than at 3h: once
|
|
||||||
`[…](…)` reads, a lone `` would otherwise misparse as text plus a link, so 3h
|
|
||||||
keeps the rest of the media family and loses only that line.
|
|
||||||
**Settled** (the maintainer, 2026-08-31, on the review): an empty link text — `[](/u)` —
|
|
||||||
leaves the brackets the text they are rather than minting a refusal or dropping the
|
|
||||||
destination, giving the label back the way an unresolved pair does, so the shortcut behind
|
|
||||||
`[][r]` still reads. A description holding an image flattens to that image's own alt, which
|
|
||||||
is what alt text means and what keeps the documented gap to mid-text and titled images; a
|
|
||||||
break of either kind inside one reads as a space. And a destination or title whose entity
|
|
||||||
reference decodes to a control character — `[a](/x y)` — joins 3k's exception list
|
|
||||||
beside the two above: the reader takes cmark's reading, the emitter has no spelling for it.
|
|
||||||
- [x] **3f — The directive grammar.** The three forms — inline `:name[content]{attrs}`,
|
|
||||||
container `:::name arg {attrs}`, leaf `::name arg {attrs}` — the attribute grammar with
|
|
||||||
its quoting and escapes, the fence-length and nesting rules, and the malformed list
|
|
||||||
`spec/flavour.md` spells, each a named error. `corpus.test.ts`'s `fenceNestingFault` stays a
|
|
||||||
second reading of the fence rule over emitted bytes: the double entry is the check.
|
|
||||||
**Settled** (the maintainer, 2026-08-27): the code span, the entity and raw HTML bind
|
|
||||||
first in input, as 2e3 already assumed of the emitted side — a raw `` ` ``, `&`, `<` or
|
|
||||||
`|` inside `{attrs}` breaks the directive and is a named error, the author writing the
|
|
||||||
`\u0060` the emitter writes. One precedence covers both directions, and CommonMark's own
|
|
||||||
ordering stays untouched.
|
|
||||||
**Settled** (the maintainer, 2026-09-01): a directive whose name reads back to no node
|
|
||||||
takes its own code, `unknown-directive-name` — a well-formed spelling the vocabulary does
|
|
||||||
not hold is not a malformed one, and §8's "erroring input gaining meaning later is MINOR"
|
|
||||||
is what a consumer switches the two apart for. And input reads canonical spacing only: one
|
|
||||||
space parting the name, the argument, `{attrs}` and each attribute pair, no padding inside
|
|
||||||
the braces, trailing whitespace on a directive block line tolerated — §8 makes loosening a
|
|
||||||
MINOR, so strict is the reversible direction. `directive-attributes.ts` becomes
|
|
||||||
`directive-syntax.ts` with the readers in it: the whole directive grammar, both
|
|
||||||
directions, beside the escaping regexes and the spellings it must not drift from. And the
|
|
||||||
500-level guards compose here for the first time — a recursive reader stacked on the block
|
|
||||||
walk — so `nesting-depth-composed` pins both axes now rather than waiting for 3i's third.
|
|
||||||
A closing fence closes the innermost open container however long its run, which
|
|
||||||
`spec/flavour.md`'s closing-fence sentence now says: a run reaching past the innermost
|
|
||||||
leaves the fence it did not close a named error, which §2 prefers to closing more than the
|
|
||||||
author wrote.
|
|
||||||
- [x] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first
|
|
||||||
directory to. A parsed directive becomes its node: the name to the type and an unknown one
|
|
||||||
to a named error, the arg to the attribute it names, each value to the type its section
|
|
||||||
assigns, the body to `content`, the reserved `marks` key to the marks array. 3a's drift
|
|
||||||
guard is built here if the answer there was yes.
|
|
||||||
3f leaves two here: `Read<T>` moves to `src/result.ts` once a second reader takes it, and
|
|
||||||
the reserved `adf` name in block position needs an error of its own — 3f reports it as
|
|
||||||
`unknown-directive-name`, which §8 makes the signal that a later MINOR may give the name
|
|
||||||
meaning, and `adf` never will.
|
|
||||||
**Settled** (the maintainer, 2026-09-01): the reserved `adf` name in block position is a
|
|
||||||
`malformed-directive` — the grammar section states the reservation, so it is that spelling
|
|
||||||
the name breaks — and a well-formed directive the tables refuse is `unsupported-node-shape`,
|
|
||||||
the emitter's code for the same mismatch read the other way; AGENTS.md §8 carries the
|
|
||||||
split. And input reads canonical `{attrs}` alone, keys in order and every value spelled as
|
|
||||||
the emitter spells it, the error naming the spelling to write instead: §8 makes loosening a
|
|
||||||
MINOR, so strict is the reversible direction, as 3f already settled for spacing.
|
|
||||||
**Settled** (the maintainer, 2026-09-01, on the review): 2f's plain-versus-directive
|
|
||||||
choice is read back here rather than at 3h — a directive spelling a node CommonMark holds
|
|
||||||
is refused, so `::rule` and `:::blockquote` are errors while `::rule {localId=…}` is not.
|
|
||||||
The parser asks `spellsCommonMark`, the emitter's own choice, rather than restating the
|
|
||||||
per-node conditions: a copy would refuse the list whose first item reads back as a
|
|
||||||
thematic break, which the emitter does spell as a directive, and §2 breaks in silence.
|
|
||||||
Two refusals land here for a later chunk to lift, on the same rule: the inline `[content]`
|
|
||||||
slot, which 3i opens for `emoji`, `mention` and `status`, and the `codeBlock` content
|
|
||||||
model's fenced body, 3h's. `Read<T>` stays where 3f left it — the node reader knows its
|
|
||||||
path and returns `Result`, so no second reader took it. The drift guard earned itself on
|
|
||||||
the way in: the spec's `text` attribute was missing from three inline table entries, which
|
|
||||||
the content slot spells and the vocabulary walk already passes over.
|
|
||||||
- [x] **3h — The block nodes.** `block-nodes/` reads back: the `codeBlock` directive's fenced
|
|
||||||
body and the `language` attribute a bare fence leaves it; the media family's composition;
|
|
||||||
and both table forms, the pipe table's cell split and its named errors. `fenceInfo` is a
|
|
||||||
rule both directions answer alike and moves to the `markdown/` root with the language
|
|
||||||
attribute.
|
|
||||||
**Settled** (the maintainer, 2026-08-27): 1d's last pick, the one
|
|
||||||
`container-block-separation` holds — a CommonMark block and a directive block sit adjacent
|
|
||||||
in a container body with no blank line between them. That reduces the three cases to one
|
|
||||||
rule, separation only where its absence would merge the blocks: the `:::` fence is
|
|
||||||
separation already, and 3c's claim ends the lazy continuation that would otherwise swallow
|
|
||||||
it. The fixture becomes a round-trip pair, and with `nested-list-separation` and 3e's pair
|
|
||||||
that empties `corpus/unspellable/`: this chunk settles the directory's own guard in
|
|
||||||
`corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here.
|
|
||||||
The emitter's other refusals survive on causes no fixture in that directory covers, so
|
|
||||||
3k's one-list pass is where they get fixtures or the directory goes.
|
|
||||||
**Settled** (the maintainer, 2026-09-01): losing that cause closed one of the shapes input
|
|
||||||
accepted and emit refused, not the last. Two adjacent lists of a kind are what
|
|
||||||
`adfToMarkdown` refuses and one `- ` spelling cannot hold apart, and the walk reached them
|
|
||||||
two ways — a marker change, which CommonMark opens a second list on, and an empty last item,
|
|
||||||
whose blank line pops the container the list's identity hung from. The parser opens no list
|
|
||||||
beside one of its own kind instead, the way it already drops the blank lines between items;
|
|
||||||
3k owes the CommonMark suite an exception where the reference HTML holds two `<ul>`. The
|
|
||||||
`normalization/` arm emits each document and reads it back from here, so the population that
|
|
||||||
class lives in is checked rather than read. The README's canonical-fixpoint sentence still
|
|
||||||
claims more than the parser keeps — 3e's three shapes — which stays milestone 5's to
|
|
||||||
narrow.
|
|
||||||
- [x] **3i — The inline nodes and the marks.** `inline-nodes/` reads back: the content slot's
|
|
||||||
`text` attribute and the error a slot holding anything but one unmarked text node is; the
|
|
||||||
`:text{text="…"}` whitespace spelling; the four directive marks and their nesting order,
|
|
||||||
outermost first; and `:em[x]` as the error `spec/flavour.md` promises. Editor-normal's
|
|
||||||
merging half lands here, `text-whitespace` being the first fixture that forces it, and 4's
|
|
||||||
`toEditorNormal` is built on it.
|
|
||||||
3g's shape leaves three: `readInlineDirectiveNode` takes the name, the attributes and the
|
|
||||||
slot's parsed text rather than the span, since `inline-content.ts` already imports it and
|
|
||||||
parsing the slot inside it is a cycle; the four directive marks get `parse/directive-marks.ts`
|
|
||||||
that `inline-content.ts` tries ahead of the node reader, as `mark-spellings.ts` sits apart
|
|
||||||
from `emit/inline-directive-spelling.ts`; and the five markdown-spelled mark names in inline
|
|
||||||
directive position take `unsupported-node-shape` rather than a code of their own — §8
|
|
||||||
already answers a well-formed directive the node tables refuse, and the message names the
|
|
||||||
spelling to use (`*x*`), while `unknown-directive-name`'s "a later MINOR may give the name
|
|
||||||
meaning" stays the wrong signal, as it was for `adf`. `corpus/errors/directive-content-slot` goes when the slot opens.
|
|
||||||
The marks a spelling wraps answer the same question 3g settled for a block's form: only the
|
|
||||||
nesting the emitter writes parses back.
|
|
||||||
**Settled** (the maintainer, 2026-09-01): `:text` reads back what the emitter writes and
|
|
||||||
nothing else — one run of spaces and tabs, or one run of newlines. A mixed run, and text
|
|
||||||
CommonMark carries plainly, are named errors, as 3g refuses the directive form of a node
|
|
||||||
CommonMark spells. The reader takes the slot's parsed nodes rather than its text, so the
|
|
||||||
rule refusing anything but one unmarked text node sits beside the node tables that own the
|
|
||||||
slot. Only `text`, read ahead of the slot, names a refusal before the slot's own: a
|
|
||||||
doubly-broken span reports what its content holds, `:date[<div>]{timestamp=1}` being
|
|
||||||
`unmappable-html` rather than `date takes no content`, which the maintainer pinned with an
|
|
||||||
assertion rather than reordering the readers. `directive-content-slot` stays with the
|
|
||||||
fixtures, its cause now a marked slot rather than a slot at all. The slot's own whitespace
|
|
||||||
answers the rule the spelling does: `:text{text="\n"}` and ` ` alike reach a slot the
|
|
||||||
emitter refuses a line ending in, so one function answers both directions.
|
|
||||||
**Settled** (the maintainer, 2026-09-01): a name the other position spells names that
|
|
||||||
spelling rather than reading as unknown — `:::em` and `::date` take
|
|
||||||
`unsupported-node-shape` naming the inline form, `:paragraph[a]` the block one — leaving
|
|
||||||
`unknown-directive-name` for a name no table holds, which is the meaning §8 gives it. The
|
|
||||||
two readers lean on the tables being disjoint, so that is a test beside the spec drift
|
|
||||||
guard now.
|
|
||||||
The same read found the hole the other way: `attemptLine` refused a line edged with a
|
|
||||||
vertical tab or a form feed, where CommonMark strips spaces and tabs alone, so valid
|
|
||||||
CommonMark parsed to a document `adfToMarkdown` then refused. The edges that check covered
|
|
||||||
are carried before the line is assembled, so narrowing it to spaces and tabs left it no
|
|
||||||
cause and it goes with them.
|
|
||||||
- [x] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back:
|
|
||||||
the `adf` fence and `:adf{json="…"}` restoring a deep-equal node, invalid JSON in either a
|
|
||||||
named error, a carry inside a mark spelling another, and the three carve-outs' escapes
|
|
||||||
reading as the literal text they hold. 3g refuses the `adf` fence rather than reading a
|
|
||||||
`codeBlock` from it; the refusal goes when the carry reads it. 3i left the slot parse
|
|
||||||
contextless, so the refusal a carry inside a mark spelling earns needs a channel — a reader
|
|
||||||
context in place of `parseInline`'s `strip` flag, or a return arm from the slot — and
|
|
||||||
`directiveNodes` takes its fourth reader beside it.
|
|
||||||
`index.ts` gains `markdownToAdf` here, and the README's status line with it: this is the
|
|
||||||
last parser chunk, so `parsingDirectories` becomes `emittingDirectories` and the whole
|
|
||||||
corpus round-trips both ways — `0.1.0`'s proof, which 4 widens rather than replaces.
|
|
||||||
- [x] **3k — The CommonMark spec suite (`0.2.0`).** Checked in at `corpus/commonmark-spec/`,
|
|
||||||
pinned to the version it ships — the one `commonmark-grammar.ts` names for its start
|
|
||||||
conditions — `corpus/README.md` gaining the kind.
|
|
||||||
**Settled** (the maintainer, 2026-08-27): three checks an example must pass, the reference
|
|
||||||
HTML each ships read as corpus data — which adds no format and no direction (§1). §2's
|
|
||||||
canonical fixpoint: a named error, or markdown that parses and emits to itself byte for
|
|
||||||
byte. That HTML's text, tags stripped and entities decoded, against the parsed document's
|
|
||||||
concatenated `text`. And a count of the dozen elements the CommonMark subset covers
|
|
||||||
against the marks and nodes they map to — counting distinct mark types per text node, since
|
|
||||||
3e collapses a spelling nested inside its own kind and `*(*a*)*` is two `<em>` against one
|
|
||||||
`em`. The fixpoint alone is self-consistency a parser
|
|
||||||
returning the empty document passes, and the text alone one dropping every emphasis; the
|
|
||||||
counts close both. The exception list stays the maintainer's, and one entry is owed
|
|
||||||
already: 3h continues a list across the marker change CommonMark splits on, so an example
|
|
||||||
the reference HTML gives two `<ul>` counts one `bulletList`. One outcome is no
|
|
||||||
exception and must not be filed as one: a fixable §2 hole — valid CommonMark parsing to a
|
|
||||||
document `adfToMarkdown` refuses — which is what `corpus/unspellable/` held until 3c, 3e
|
|
||||||
and 3h landed their answers and emptied it. The permanent ones — a link destination or
|
|
||||||
title no escape spells, a paragraph opening with a code span — are the exceptions, named
|
|
||||||
by AGENTS.md §2.
|
|
||||||
- [x] **4 — Round-trip property tests (`0.2.0`)**, widening 3j's corpus round-trip past the
|
|
||||||
documents a human wrote — the thing that proves 2 and 3 beyond them.
|
|
||||||
**Settled** (the maintainer, 2026-09-13): `fast-check` generates and shrinks. The gate runs a
|
|
||||||
fixed seed, the properties together adding about five seconds per engine; an environment
|
|
||||||
variable raises the runs and randomizes the seed for local digging, and a counterexample
|
|
||||||
found becomes a round-trip fixture. The generators draw from the node tables — each node's
|
|
||||||
content model and attribute vocabulary as `adf/` records them, which 11 holds to Atlassian's
|
|
||||||
schema — and misplace a share of nodes so the carry (§3) is exercised; no JSON Schema walker
|
|
||||||
enters the tests. `toEditorNormal` stays internal. 2e5's collision test goes, since a
|
|
||||||
collision already fails the round-trip on the same fixtures; the fixture-duplicate test
|
|
||||||
stays.
|
|
||||||
- [x] **4.1 — Editor-normal and the node accessors.** `toEditorNormal(doc)` in
|
|
||||||
`src/adf/editor-normal.ts`, on 3i's merging: adjacent text nodes carrying identical marks and
|
|
||||||
no attributes merged, an empty `attrs`, `marks` or `content` the absent key, `-0` read as `0`
|
|
||||||
(§2); the round-trip tests compare the parser's output through it, and `serializeCanonicalJson`
|
|
||||||
beneath it walks iteratively. `nodeContent`/`nodeAttrs`/`nodeMarks` replace the 46 inline
|
|
||||||
`?? []`/`?? {}` reads in `src/` (23 `content`, 12 `marks`, 11 `attrs`) and the `attrs?.[key]`
|
|
||||||
reads, and the branch floor rises to the integer floor of what the suite then measures.
|
|
||||||
**Settled** (the maintainer, 2026-09-14): a text node carrying attributes never merges —
|
|
||||||
`0.1.0` merged a carried one into its neighbour on read-back — and the fix lands here, as does
|
|
||||||
the iterative serializer.
|
|
||||||
- [x] **4.2 — The ADF property.** `fast-check` joins `devDependencies`, AGENTS.md §5 naming what
|
|
||||||
it earns — shrinking a failing document to the nodes that break it — and §10 the properties
|
|
||||||
beside the corpus. A generated editor-normal document either refuses in `adfToMarkdown`
|
|
||||||
with a `ConvertError` or reads back through `markdownToAdf` to an equal document, and
|
|
||||||
nothing throws, under Node, Deno and Bun alike. 2e5's collision test is deleted.
|
|
||||||
**Settled** (the maintainer, 2026-09-14): about half the block positions draw attribute-less
|
|
||||||
CommonMark shapes — single-type lists, headings, blockquotes, pipe-table-shaped tables — where
|
|
||||||
the escaping lives. A round-trip break the property finds is fixed inside 4.2, one commit per
|
|
||||||
break with its round-trip fixture seen red first, and 4.2 lands when a deep run of about
|
|
||||||
10,000 per engine passes clean; a break needing design goes to the maintainer. The first two,
|
|
||||||
both shipped in `0.1.0`: an empty `href` with a title spelled `[a]( "")`, which reads back as
|
|
||||||
the href `""`, and a `[` or `]` in a link's destination or title inside a directive mark,
|
|
||||||
refused as the emitter's own output or, with `]`, losing the link. Later, settled the same
|
|
||||||
day: an autolink whose href holds a backtick takes the `[text](url)` form inside a
|
|
||||||
directive's content; and a V8 fault the deep runs hit — once `JSON.parse` has read a key
|
|
||||||
holding an escaped backslash, a later escaped quote or newline key comes back as that
|
|
||||||
backslash, on Node and Deno but not Bun — is accepted rather than worked around, since the
|
|
||||||
library only refuses such a document, so the generators' JSON keys avoid those characters; the
|
|
||||||
fault is reported upstream (https://issues.chromium.org/issues/521080746, nodejs/node#63785),
|
|
||||||
where the maintainer added to both on 2026-09-14. The review found one more break of the
|
|
||||||
same class, fixed the same way: a would-be inline directive in a link target inside a
|
|
||||||
directive's content.
|
|
||||||
- [x] **4.3 — The markdown property.** Generated markdown through `markdownToAdf` never throws,
|
|
||||||
and the runs fit the budget; where it parses and `adfToMarkdown` spells the result, that
|
|
||||||
spelling parses and emits to itself byte for byte (§2).
|
|
||||||
**Settled** (the maintainer, 2026-09-15): the generators and run parameters 4.2 and 4.3
|
|
||||||
share live in one test-only module in `src/`, kept out of the build and coverage, with
|
|
||||||
10c's properties as its third user. Under the gate seed the property asserts floors on the
|
|
||||||
runs reaching the fixpoint and on the directive-shaped ones. It lands when hunts of several
|
|
||||||
hundred thousand runs per engine pass clean, since the breaks hit once per ~150,000 runs,
|
|
||||||
past a 10,000-run bar. Two breaks, both shipped in `0.1.0`, are fixed inside it. A backtick
|
|
||||||
string an escape formed closed an earlier bare run's code span, since CommonMark reads no
|
|
||||||
escape inside one: an escaped backtick alone or, as the review found, one joined to the bare
|
|
||||||
run after it. A backtick run now escapes whole, and a bare run escapes wherever a later
|
|
||||||
string of its length forms around an escape in the same inline content: the generalized pass
|
|
||||||
the maintainer chose (2026-09-15). A paragraph's opening read as a link
|
|
||||||
reference definition across a `]` the emitter spelled: the emitter now escapes the opening
|
|
||||||
`[` exactly when the parser's own definition reader accepts the paragraph, and a link
|
|
||||||
opening it rides the carry until 13b.
|
|
||||||
- [x] **4.4 — The real payloads.** `corpus/real-payloads/` holds ADF Atlassian's editor wrote,
|
|
||||||
each round-tripped ADF→markdown→ADF with no expected markdown.
|
|
||||||
**Settled** (the maintainer, 2026-09-15): the chunk authors the payloads itself on the
|
|
||||||
maintainer's Atlassian test site — invented content, so nothing needs sanitizing — driving the
|
|
||||||
editor with Playwright, and reads the ADF back over REST. Only the documents are committed; no
|
|
||||||
client or fetch script enters the repo (§7). Later, settled the same day: the mentions keep
|
|
||||||
the test user's real account id (the maintainer, 2026-09-15).
|
|
||||||
- [x] **4b — The block walk's retry (`0.2.0`).** `emitBlock` walks a subtree twice wherever
|
|
||||||
`readableBlock` reads it whole and then gives up — a list item whose first line reads back
|
|
||||||
as a thematic break — and the walk below does the same, so the cost doubles per level:
|
|
||||||
3.4kB of nested lists takes half a second, depth 20 about eight, depth 24 minutes. It
|
|
||||||
predates 3g on both directions, and 3g's `commonMarkSpelling` gave it a second entry point.
|
|
||||||
The README's bot and pipeline personas feed markdown nobody typed, so this ships as a hang
|
|
||||||
on a small input; §11's scanning rule is the same argument one shape further in. The retry
|
|
||||||
is what to remove — one walk answering both the readable question and the directive
|
|
||||||
fallback. Memoizing `emitBlock` is the shortcut, and the node reference is the wrong key: a
|
|
||||||
caller may hold one node object at two positions, where the cached depth and path are
|
|
||||||
another node's. `0.1.0` ships with the retry in it, so a deep document is slow rather than
|
|
||||||
wrong until the patch. `adfDocumentFault` is the second site to look at: `isNodeArray` reads
|
|
||||||
every node and attribute value, then `nestingFault` reads them again, so the emit entry the
|
|
||||||
export persona runs in bulk walks the document twice. Both walks are linear, so this is a
|
|
||||||
constant factor rather than 4b's class change, and the parting is what gives depth its own
|
|
||||||
code (§8) — measure before joining them back.
|
|
||||||
**Settled** (the maintainer, 2026-09-18): the limit stays 500 readable lists, the walk
|
|
||||||
reporting its headroom (§11). Counting every list twice was rejected for halving the limit,
|
|
||||||
counting the directive form once for doubling the parser's frames per level.
|
|
||||||
**Measured** (2026-09-18): `adfDocumentFault` walks a 9 MB document in 52 ms against 314 ms
|
|
||||||
for the emit, so its two walks stay parted.
|
|
||||||
- [x] **4c — The scanning rule's remaining sites (`0.2.0`).** A trailing-anchored regex re-walks
|
|
||||||
its run from every start position, so an interior whitespace run costs quadratic time rather
|
|
||||||
than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk
|
|
||||||
replaced the regex. The sites the same sweep did not reach: `normalizeLabel` in
|
|
||||||
`link-syntax.ts`, whose shortcut-reference input is `scan.source.slice(...)` rather than the
|
|
||||||
999-capped `readLabel` value, and `carryEdges` in `emit/inline-line.ts`. A third of another
|
|
||||||
shape joins them: `readNestedDirective` restarts its depth counter per level, so each parse
|
|
||||||
level re-scans the region below it and nested inline directives cost O(depth × content),
|
|
||||||
bounded by the 500-level guard. A fourth predates 12c: the list-item walk re-scans the rest
|
|
||||||
of a line once per item level — `isThematicBreak` in `containerStart` on an opener line,
|
|
||||||
`isBlankLine` and `leadingColumns` in `continuesContainer` on a continuation line, and a
|
|
||||||
blank line continues every open item without consuming input; 30000 nested items take 4.4s
|
|
||||||
at 59 KB (the stability-reviewer, 2026-09-16). §11's scanning rule is the whole argument; the
|
|
||||||
pipeline persona feeds documents nobody typed. A fifth is a throw rather than a cost:
|
|
||||||
`adfDocumentFault` pushes a node's content with a spread, so past about 125k sibling nodes
|
|
||||||
the guard throws a `RangeError` where §11 owes a `Result` (the stability-reviewer and the
|
|
||||||
maintainer, 2026-09-18).
|
|
||||||
**Settled** (the maintainer, 2026-09-18): the five sites land in one PR rather than split
|
|
||||||
into sub-items, and a behaviour-preserving cost fix is accepted on the suite staying green
|
|
||||||
with no fixture output changed, plus the measurement below — §14 promises no figure, so
|
|
||||||
nothing times the gate. The guard's spread is the one behavioural fix and carries a test.
|
|
||||||
**Corrected** (2026-09-18): the entry filed two sites in `emit/inline-line.ts` on 2026-09-01
|
|
||||||
and the file has changed since — `tryImageLine`'s alternation measures linear (3.4 / 1.9 /
|
|
||||||
5.3 ms over 10k / 20k / 40k spaces), leaving `carryEdges`' trailing trim the only one.
|
|
||||||
**Measured** (2026-09-18), each at the size its filing named: `normalizeLabel` 1026 ms → 5 ms
|
|
||||||
at 40k interior spaces, `carryEdges` 1024 ms → 7 ms (its heading path 978 ms → 5 ms),
|
|
||||||
`readNestedDirective` 434 ms → 10 ms at 397 kB and 200 levels, the list-item walk 4196 ms →
|
|
||||||
39 ms at 30000 items, and the document guard a `RangeError` → 42 ms at 200k siblings under
|
|
||||||
one node. The list-item walk's mixed-marker shape, which the fix had to answer too, reads
|
|
||||||
38 ms where the tail scan alone would have left it quadratic.
|
|
||||||
The sixth site the sweep found went to 18 rather than landing here (the maintainer,
|
|
||||||
2026-09-18).
|
|
||||||
**Left as is** (the stability-reviewer, 2026-09-18): of the list-item walk's three re-scans
|
|
||||||
only `containerStart`'s is fixed. `continuesContainer`'s pair costs the same either way — 400
|
|
||||||
levels at 627 kB read 469 ms before and 448 ms after, linear in the line count and only
|
|
||||||
mildly superlinear in a depth the 500-level guard bounds — so it is measured and left rather
|
|
||||||
than made an item.
|
|
||||||
**Widened** (the systems-architect, 2026-09-18): the guard's spread was a class rather than a
|
|
||||||
site, and two more threw out of the public API — `readIndentedCodeLine` releasing the blank
|
|
||||||
lines an indented code block held (200k of them at 200 kB), and `emitRun` joining a mark
|
|
||||||
run's segments (200k nodes under one mark). Both fixed here with the same loop and a test
|
|
||||||
each, and §11 gained the rule so the spelling cannot walk back in.
|
|
||||||
- [x] **5a — Rename to `@larvit/adf-codec` (`0.1.0`).** Before the first publish, the name being
|
|
||||||
the published identity: `package.json` `name` and `repository`, the Gitea repo and its
|
|
||||||
remote, the README title, §6's published-as line, the checkout directory.
|
|
||||||
**Settled** (the maintainer, 2026-09-01): ADF's own `A` is "Atlassian", and "converter" is
|
|
||||||
the one-way lossy tool §2 exists to replace, where a codec is both directions. It names the
|
|
||||||
hub, not the formats around it.
|
|
||||||
- [x] **5b — The consumer's error surface (`0.1.0`).** A product-owner read of the public surface
|
|
||||||
found the error result legible to the library and opaque to the consumer holding it, and the
|
|
||||||
README documenting no part of it. The sub-items are that read's answers, and they land before
|
|
||||||
5 because §8 freezes the code list at `0.1.0` and 5b4's table is what reads the list before
|
|
||||||
the freeze closes it.
|
|
||||||
- [x] **5b1 — The error's source position.** A parse error names an ADF path into a document the
|
|
||||||
caller does not hold yet — `unmappable-html` at `["content", 5]` for a `<span>` on line
|
|
||||||
12 — and no coordinate into the markdown string it passed in. `ConvertError` gains an
|
|
||||||
optional `position` the parser carries to every parse-side mint, and the README's published
|
|
||||||
shape gains it.
|
|
||||||
**Settled** (the maintainer, 2026-09-03): the position is the parse side's alone — an
|
|
||||||
emitter has no source string to point into, so emit-side errors keep `path` unchanged. The
|
|
||||||
representation and where the position is captured are implementation judgment.
|
|
||||||
The block walk mints it and the node walk attaches it as results return — at `blockNodes`,
|
|
||||||
and at the inline body a directive holds — so the innermost block wins and the emitter's
|
|
||||||
own refusals, which the parser re-enters for the CommonMark spelling, get an input
|
|
||||||
coordinate too. `markdownToAdf` wraps the walk once more, which is what turns the wide
|
|
||||||
`Result<T>` into the `Result<T, ParseError>` its signature promises rather than guarding
|
|
||||||
anything: the depth guard under it cannot fire at depth 0. A paragraph names the line its
|
|
||||||
kept text starts on, never a link reference definition it gave up. Line endings stay as
|
|
||||||
the input spells them, so an offset indexes the string the caller passed rather than a
|
|
||||||
normalized copy of it. §8 records the framings the review settled beside it:
|
|
||||||
`unsupported-node-shape` stays one code across the two directions, `unmappable-html` names
|
|
||||||
the version rather than the element, and a direction that reads a source returns the
|
|
||||||
narrowed error type.
|
|
||||||
- [x] **5b2 — The error messages.** Most state the rule and leave the violation to be inferred —
|
|
||||||
`a text node holds text` for a node holding none — so `rule: violation` becomes house style
|
|
||||||
across the sites that do. `not-an-adf-document` gives one sentence of eight words to `null`,
|
|
||||||
a string, a missing `version`, a `type` that is not `doc` and a REST envelope around the
|
|
||||||
document; naming the check that failed makes the highest-frequency integrator mistake
|
|
||||||
self-diagnosing without the library naming a REST shape (§7). The three carve-out claim
|
|
||||||
messages name the escape that unclaims the line — `\|`, `\~~`, `\:::` — which today only
|
|
||||||
`spec/flavour.md` holds. `unmappable-html` reads as §8 now frames it: this version converts
|
|
||||||
no raw HTML, never a permanent judgment on the element.
|
|
||||||
Thirty-odd sites gained the violation clause and §8 gained the house style. `isAdfDocument`
|
|
||||||
parts into `adfDocumentFault`, the guard reading it, so the first failing check is the
|
|
||||||
message — the wrapper mistake names the key it found. Two carve-outs claim a line, not
|
|
||||||
three: a matched `~~` pair spells `strike` silently, so nothing refuses it and no message
|
|
||||||
names `\~~`. The escape lands on the refusals a prose line hits, in the form that was
|
|
||||||
claimed — `\:::` on the directive line's, `\|` on the pipe table's, `\:` on the inline
|
|
||||||
directive's, the attribute-pair and unknown-name faults taking whichever form read them.
|
|
||||||
`a pipe table row holds 1 cells` gained its plural.
|
|
||||||
- [x] **5b3 — The code list and the flavour's gaps.** A second read of the surface, this one on
|
|
||||||
the fifteen names §8 freezes at `0.1.0`: two pairs of them are one cause each, and one
|
|
||||||
names a state the flavour leaves no way out of. `unspellable-character` and the text half
|
|
||||||
of `unspellable-whitespace` are one refusal — a character CommonMark rewrites, the message
|
|
||||||
naming it — and merge, `unspellable-whitespace` keeping the code for its other cause, the
|
|
||||||
content slot no inline directive spans. `unspellable-link-destination` and
|
|
||||||
`unspellable-link-title` become `unspellable-link`, the message naming the attribute.
|
|
||||||
`unspellable-adjacent-lists` goes entirely: two adjacent `bulletList` nodes are valid ADF a
|
|
||||||
site writes, and refusing them leaves the viewer persona a document it cannot render at
|
|
||||||
all, so the flavour gains the separator that spells the pair apart, both directions,
|
|
||||||
`spec/flavour.md` and fixtures. Thirteen codes stand — `unspellable-whitespace` keeps its own.
|
|
||||||
The bare pipe table — `a | b` over `--- | ---`, GFM's shape without the leading pipes — is
|
|
||||||
the one input that loses structure silently, reading back as a paragraph of prose; it
|
|
||||||
becomes a `malformed-pipe-table` naming the form a row takes. That code keeps its name for
|
|
||||||
the alignment colon: the flavour's own delimiter row is `-` runs, so the grammar is what
|
|
||||||
refuses, and §8 records it rather than answering it again each review. The ninth
|
|
||||||
`adfDocumentFault` branch names no node and carries the document's own path, the one branch
|
|
||||||
the other eight outshine; §8 records why the guard stays a boolean.
|
|
||||||
`::listBreak` is the separator's spelling: the grammar's leaf form, and a second reserved
|
|
||||||
name beside `adf` — every other name is an ADF node or mark type, and this one builds none.
|
|
||||||
It reads only between two adjacent lists of one type, and takes the separation any
|
|
||||||
directive block takes where it sits, so a directive container holds it with no blank line.
|
|
||||||
A hard break is the one spelling that can put a bare delimiter row under a row of its own,
|
|
||||||
so the emitter escapes that line's first character rather than refusing the document.
|
|
||||||
`spec/flavour.md` had two directive blocks inside a container taking no blank line; the
|
|
||||||
rule both directions keep is that a pair holding one takes none.
|
|
||||||
- [x] **5b4 — The README's consumer surface.** §8 invites an exhaustive switch on `code` and no
|
|
||||||
code name appears in the README, so it gains a table — code, when it fires, what the
|
|
||||||
consumer does — grouped by direction, over the thirteen names 5b3 settled. Four things a
|
|
||||||
reader who has not opened the code cannot know: raw
|
|
||||||
HTML is core CommonMark and every construct in input is an error until `0.3.0`, which the
|
|
||||||
guarantees' "three carve-outs and one gap" denies and which is the bot and LLM personas'
|
|
||||||
most common failure; `adfToHtml`, `htmlToAdf`, `markdownToHtml` and `htmlToMarkdown` sit
|
|
||||||
unmarked in the code block people copy from, as do the two HTML guarantee bullets, and take
|
|
||||||
a `0.3.0` mark or leave the block; `adfToMarkdown` is partial on valid ADF — a text node
|
|
||||||
holding a carriage return, a link destination no canonical escape spells — which the viewer
|
|
||||||
persona needs told along with
|
|
||||||
what to do about it; and GFM past tables and strikethrough is literal text, task lists
|
|
||||||
taking `:::taskList`. One sentence for the LLM persona: `code` is stable across minors,
|
|
||||||
`message` is free text. The type-level surface freezes at the same moment and gets the same
|
|
||||||
read: what `index.ts` exports and what it withholds, `ParseError` against `ConvertError`
|
|
||||||
where a direction reads a source, and `ConvertFault` staying internal — the README table
|
|
||||||
names the shapes a consumer switches on, so the two audits are one.
|
|
||||||
The direction grouping is read off the call sites rather than the code prefixes, which do
|
|
||||||
not partition by direction: the parser asks the emitter which CommonMark spelling a node
|
|
||||||
takes (§11), so six codes reach a `markdownToAdf` caller as well as an `adfToMarkdown` one.
|
|
||||||
The trailing pipe of a pipe-table row is optional in input, not required; the leading one
|
|
||||||
is what every row must carry.
|
|
||||||
- [x] **5c — The build and the release pipeline.** Split out of 5, which kept only the
|
|
||||||
maintainer's own acts. The build: `tsconfig.build.json` gains emit of JS and `.d.ts` to
|
|
||||||
`dist/` (its own `allowImportingTsExtensions` forces `noEmit`, so
|
|
||||||
`rewriteRelativeImportExtensions` lands beside it), plus `exports`/`files` in
|
|
||||||
`package.json`. Publish-on-version-change (§9) as `publish.sh`, run by a `main`-only job
|
|
||||||
needing the gate. The `ConvertErrorCode` freeze (§8) is checkable here: 3h landed the last
|
|
||||||
decision `corpus/unspellable/` held and the directory went with it, so what the code list
|
|
||||||
holds from here is permanent. The parser's own code additions are read here as one list
|
|
||||||
before that freeze — nine sessions mint them independently, and one cause wearing two codes
|
|
||||||
is breaking to undo after `0.1.0`. That read gets a test rather than an eye — every
|
|
||||||
`ConvertErrorCode` member named at a production call site, the way `spec.test.ts` guards the
|
|
||||||
node tables — since `unspelled-block-separation` outlived its cause until 3h went looking.
|
|
||||||
All thirteen have a call site; the audit's find was the depth one 5 predicted, read wrong in
|
|
||||||
its own text: an attribute value past 500 levels was `unsupported-node-shape` on parse and
|
|
||||||
`not-an-adf-document` on emit, the document guard counting the `attrs` object as a level the
|
|
||||||
parser does not, so a value at exactly 500 parsed into a document the emitter then refused.
|
|
||||||
Depth left the shape predicates on both sides: `isJsonValue` structural and `overNested`
|
|
||||||
beside it, `adfDocumentFault` returning the code with the message and `attributeValue` the
|
|
||||||
reason it refused, so both directions answer with `unsupported-nesting-depth` naming the
|
|
||||||
attribute, and `isAdfDocument` calls a deep document a document as it always did a deep
|
|
||||||
block.
|
|
||||||
`engines.node` gets its one-line proof too — the built entrypoint imported and round-tripped
|
|
||||||
under a pinned Node 18 image, which cannot run the suite that type stripping wants 22+ for,
|
|
||||||
but proves exactly what the field claims. Beside it, the emitted `.d.ts` typechecked from a
|
|
||||||
consumer's position: declaration emit leaves the `.ts` specifiers `rewriteRelativeImportExtensions`
|
|
||||||
rewrites in the JavaScript, and nothing else in the repo reads them the way an installed
|
|
||||||
consumer would. 3k's exception list landing after the release left the README's
|
|
||||||
canonical-fixpoint sentence claiming more than `0.1.0` keeps — 3e names three shapes that
|
|
||||||
parse and then refuse — so it now says a parse succeeding is no promise of a way back, and
|
|
||||||
names them.
|
|
||||||
- [x] **5d — The browser leg.** §6's browser half is checkable on the emitted `dist/index.js` a
|
|
||||||
browser can load — the compile gate names no host API, and a real page converting the corpus
|
|
||||||
is the other half. Headless Firefox is that page, settling both at once: the browser proof,
|
|
||||||
and the only SpiderMonkey there is, the gate's three engine legs being two V8s and a
|
|
||||||
JavaScriptCore that is not Safari's. The mechanism is the decision this item opens with: a
|
|
||||||
browser leg wants an image, a driver and a way to carry a verdict back out, none of which
|
|
||||||
the gate's plain `docker run` per engine has. The answer is `with_firefox`, which runs the
|
|
||||||
Firefox image beside the node one in a shared network namespace, so the page's server and
|
|
||||||
the driver are each other's `127.0.0.1` and no user-defined network, container name or
|
|
||||||
geckodriver `--allow-hosts` entry is wanted; its `EXIT INT TERM` trap bakes in the container
|
|
||||||
id, since the `local` holding it is gone by the time the trap fires. `browser-tests/run.js`
|
|
||||||
serves the repo, drives one `execute/sync` and asserts the results against the corpus with the
|
|
||||||
Node-side `assert.deepEqual` the corpus runner uses, so the browser page holds no second copy
|
|
||||||
of the comparison. The whole corpus fits: 118 fixtures in 8s warm against a 120s script
|
|
||||||
timeout — no slice was worth choosing. A `try` around the dynamic import is what turns a
|
|
||||||
broken build into SpiderMonkey's own message rather than an undefined global.
|
|
||||||
**Settled** (the maintainer, 2026-09-04): `selenium/standalone-firefox` over the smaller
|
|
||||||
`instrumentisto/geckodriver`, currency over size — the leg's whole worth is a real
|
|
||||||
SpiderMonkey, which decays the moment the pin stops moving, and the smaller image was four
|
|
||||||
Firefox majors behind with a publisher that may go quiet while Renovate stays silent.
|
|
||||||
- [x] **11 — Atlassian's ADF schema as the tables' truth (`0.2.0`).** `@atlaskit/adf-schema`'s two
|
|
||||||
JSON Schemas vendored rather than the package installed (AGENTS.md §5), and the node tables
|
|
||||||
gated against them (§10). **Settled** (the maintainer, 2026-09-13): vendored at
|
|
||||||
`spec/adf-schema/` and re-pinned by hand when a need shows; the gate compares attribute names
|
|
||||||
and kinds, never value sets, over `full.json` and `stage-0.json` together.
|
|
||||||
- [x] **11a — The vendored schema.** `full.json` and `stage-0.json`, byte-exact from
|
|
||||||
`@atlaskit/adf-schema@57.4.9`'s `dist/json-schema/v1/`, at `spec/adf-schema/`, each pinned
|
|
||||||
by its SHA-256 in a test the way `spec.json` is. The version, the source and the Apache-2.0
|
|
||||||
attribution sit beside them with the licence text; no gate re-serializes either file.
|
|
||||||
- [x] **11b — The gate.** For each node and mark type the tables spell, the attribute names and
|
|
||||||
kinds equal the union over every definition in both files whose `type` enum names it,
|
|
||||||
`anyOf`/`allOf` branches included, the argument slot (`panelType`, `state`) counting as
|
|
||||||
spelled. Kinds: `string`; `number`, `integer` included; `boolean`; `json` for an object, an
|
|
||||||
array or an untyped value; an `enum`-only attribute takes its values' kind. What the schema
|
|
||||||
holds past the tables is pinned in two exact lists — an entry the schema no longer needs is
|
|
||||||
red, like a difference neither list names: gaps, attributes of a spelled type (57.4.9:
|
|
||||||
`link` `collection` `id` `occurrenceKey`, `rule` `color` `style` `weight`, `layoutSection`
|
|
||||||
`columnRuleStyle`), emptied by 13; and carried, types the tables do not spell (`alignment`
|
|
||||||
`annotation` `backgroundColor` `blockCard` `bodiedRule` `breakout` `dataConsumer`
|
|
||||||
`embedCard` `fontSize` `fragment` `indentation` `inlineExtension` `placeholder`), `doc` and
|
|
||||||
`text` counting as the grammar's own.
|
|
||||||
- [x] **12 — The `!adf:` re-spelling (`0.2.0`).** Replace the colon directive grammar with the
|
|
||||||
namespaced prefix, a breaking change to the emitted contract (shipped `0.1.0`, so §8 makes it
|
|
||||||
`0.2.0`). Forms: block container `!adf:name arg {attrs}` … `!adf:/name` — the `/` parts open
|
|
||||||
from close, nestable without a fence-length discipline, so the `::::`/`:::::` runs and their
|
|
||||||
length rule go and every container opens the constant `!adf:`; block leaf `!adf:name arg
|
|
||||||
{attrs}` with no closer; inline node `!adf:name[content]{attrs}`; directive marks
|
|
||||||
`!adf:border`/`subsup`/`textColor`/`underline` `[content]{attrs}`. Attributes and their
|
|
||||||
escaping stay `{key=value}`; the literal escape is `\!adf:`. Leaf vs container is decided by
|
|
||||||
the node's content model rather than syntax — the `::`/`:::` split goes, a simplification the
|
|
||||||
carry makes safe (an unknown *block* node already rides the fence, not the directive). The
|
|
||||||
carry's reserved name becomes `carry`, both spellings — the block fence info string `carry`
|
|
||||||
and the inline `!adf:carry{json="…"}` — named for what it does: it carries a node verbatim,
|
|
||||||
never "unknown-node", since a known node no section spells where it stands rides it too. No
|
|
||||||
`ConvertErrorCode` is added, removed or renamed, and the round-trip guarantee and the carry
|
|
||||||
both hold through it. Mechanical surface: the grammar in `spec/flavour.md`,
|
|
||||||
`src/adf/block-directives.ts` + `inline-directives.ts`, `src/markdown/`'s
|
|
||||||
`directive-syntax.ts`, `opaque-carry.ts` and the `emit/` + `parse/` readers, every corpus
|
|
||||||
fixture (round-trip, normalization and `errors/`), the prose reader over `spec/flavour.md`,
|
|
||||||
the markdown property's generator, and the README's examples.
|
|
||||||
**Settled** (the maintainer, 2026-09-13):
|
|
||||||
- A line opening `!adf:name` is a block line when a space or the line's end follows the name,
|
|
||||||
and a paragraph when `[` or `{` does. Claiming stays syntactic and structure comes from the
|
|
||||||
tables: an unknown name is `unknown-directive-name` at the opener, whatever follows it.
|
|
||||||
- An unescaped `!adf:` claims on its own anywhere inline: one completing no directive is
|
|
||||||
`malformed-directive`, the emitter escapes every literal `!adf:`, and `!adf:hardBreak{}`
|
|
||||||
keeps its braces. Block and inline share the one `\!adf:` escape hint.
|
|
||||||
- A closer names the innermost open container, crosses no list-item or blockquote edge,
|
|
||||||
indents as a fence does and carries nothing after the name; anything else is
|
|
||||||
`malformed-directive`.
|
|
||||||
- A node holding no content whose content model takes some is an empty opener–closer pair,
|
|
||||||
never a leaf.
|
|
||||||
- A spelled node's content model is frozen with its spelling: changing it is MAJOR (§8).
|
|
||||||
- The colon spellings are dropped, not refused: `0.1.0` markdown reads back as prose, `adf`
|
|
||||||
is no longer a reserved language, and `MIGRATION.md` tells a consumer to convert stored
|
|
||||||
markdown through `0.1.0`'s parser and `0.2.0`'s emitter.
|
|
||||||
- Inputs moving between codes ride the break: a leaf given a body, a container missing its
|
|
||||||
closer and `listBreak` with a body are `malformed-directive`, and an empty inline-body
|
|
||||||
container parses.
|
|
||||||
- Split by construct, each sub-item both directions: 55 of 78 round-trip fixtures feed both
|
|
||||||
the emit and the read-back test, so an emit-only chunk cannot land green.
|
|
||||||
- The spec leads the code from 12a to 12d: `spec/flavour.md` and `AGENTS.md` §4 spell the
|
|
||||||
`!adf:` grammar whole, while code and fixtures reach it one form at a time. A reader landing
|
|
||||||
in either without `todo.md` sees a gap that is the plan, not a defect.
|
|
||||||
- [x] **12a — The spec and the decision.** `spec/flavour.md` rewritten to the `!adf:` grammar and
|
|
||||||
the settled answers above, no colon directive form left in it; AGENTS.md §4's directive
|
|
||||||
bullet and prior-art line, and §8's escape hints and `::adf`/`::listBreak` examples, name
|
|
||||||
the new forms, §8 gaining the frozen content model.
|
|
||||||
- [x] **12b — The inline form.** Inline nodes, directive marks, `text` and the inline carry
|
|
||||||
`!adf:carry{json=…}` spelled and read as `!adf:name[content]{attrs}`, with the prefix claim
|
|
||||||
and its escape; the round-trip, normalization and `errors/` fixtures holding inline forms
|
|
||||||
re-spelled, and the gate green. The content slot of `emoji`, `mention` and `status` refuses a
|
|
||||||
text node carrying attributes as `unsupported-node-shape`, which it drops silently today (the
|
|
||||||
maintainer, 2026-09-14).
|
|
||||||
- [x] **12c — The block form.** Openers and `!adf:/name` closers, leaf vs container by content
|
|
||||||
model, empty pairs, `listBreak` and the `carry` fence, spelled and read; the fence-length
|
|
||||||
rule and the corpus test's fence nesting check deleted; the remaining fixtures re-spelled
|
|
||||||
and `errors/` re-derived under the shifted codes, and the gate green.
|
|
||||||
12b's two temporary seams expire here: `carryFence` folds back into `carryName` once the
|
|
||||||
fence reads `carry`, and `directiveLineEscape` into `inlineDirectiveEscape` once one escape
|
|
||||||
serves both forms. `spellLeafDirective` takes the `Inline` its reader-side regex already
|
|
||||||
carries, and `header` versus `opener` settles as one word in spec and code. While
|
|
||||||
`readNestedDirective` is open, its `[content]` and `{attrs}` reads lift out as named steps,
|
|
||||||
and the two `charAt`-against-`!` fast paths ahead of `claimsDirectivePrefix` — in
|
|
||||||
`readDirectiveContent` and `line-escaping`'s `bracketed-link-target` arm — either earn a
|
|
||||||
reason or go (the systems-architect, 2026-09-16).
|
|
||||||
- [x] **12d — The README, `MIGRATION.md` and the sweep.** The README's examples and error tables
|
|
||||||
follow, `MIGRATION.md` linked from one README line; docs and fixtures swept for any stale
|
|
||||||
`::`/`:name` spelling.
|
|
||||||
- [x] **13 — The schema's gap attributes (`0.2.0`).** Spell the attributes 11b pins as gaps, in
|
|
||||||
12's grammar, and empty the list.
|
|
||||||
**Settled** (the maintainer, 2026-09-13): a link `[text](url "title")` cannot hold takes the
|
|
||||||
directive mark `!adf:link[text]{attrs}` — one carrying `collection`, `id` or `occurrenceKey`,
|
|
||||||
or an `href` or `title` no CommonMark escape writes — and a directive link CommonMark could
|
|
||||||
spell is `unsupported-node-shape`. That leaves `unspellable-link` no cause, so it leaves
|
|
||||||
`ConvertErrorCode` in `0.2.0`, §8 recording the removal.
|
|
||||||
- [x] **13a — `rule` and `layoutSection`.** `rule`'s `color`, `style` and `weight` and
|
|
||||||
`layoutSection`'s `columnRuleStyle` join their tables and `spec/flavour.md` bullets, with
|
|
||||||
round-trip fixtures; their gap entries go.
|
|
||||||
- [x] **13b — The directive link.** `link` spelled as above in both directions, with a round-trip
|
|
||||||
fixture per trigger, `spec/flavour.md`'s Marks section following; `unspellable-link` removed
|
|
||||||
from the code list, its `errors/` fixtures and the CommonMark suite's `unspellable`
|
|
||||||
exceptions it cures re-derived, the README's code table and its "not every document
|
|
||||||
converts back" guarantee following, and `MIGRATION.md` naming the removed code; the gap
|
|
||||||
list is empty. A round-trip fixture holds the shape 4.2's review left refused until then:
|
|
||||||
an autolink-shaped link under a directive mark whose href holds `\!adf:name{`. A link
|
|
||||||
opening a paragraph whose opening reads as a link reference definition, which 4.3 leaves
|
|
||||||
riding the carry, takes the directive link too, with its round-trip fixture (the
|
|
||||||
maintainer, 2026-09-15).
|
|
||||||
|
|
||||||
## 5 — Ship `0.1.0`
|
|
||||||
|
|
||||||
- [ ] **5 — Ship `0.1.0`.** Only the maintainer's own acts are left (§15): make the Gitea repo
|
|
||||||
public (§6), create the `NPM_TOKEN` secret, confirm the Actions token may push tags — the
|
|
||||||
publish succeeds and the tag push then reddens the run, though the next push to `main`
|
|
||||||
retries the tag alone — and open the bump PR that sets `version` to `0.1.0` and drops
|
|
||||||
`private: true`, the guard against any earlier publish. The bump and the drop go in one
|
|
||||||
commit: dropping `private` alone publishes `0.0.0`, which also differs from npm's nothing. `0.1.0` is the
|
|
||||||
markdown round-trip: both markdown directions, the types, `isAdfDocument`, proved over the
|
|
||||||
checked-in corpus.
|
|
||||||
**Settled** (the maintainer, 2026-09-01): the round-trip proved over the checked-in corpus
|
|
||||||
is what `0.1.0` ships on, and the open-ended proof work follows it rather than gating it —
|
|
||||||
3k's spec suite and 4's generators and maintainer-supplied payloads are `0.2.0`, 4b's retry
|
|
||||||
`0.1.1`. A consumer using the library is worth more than a wider proof nobody has needed
|
|
||||||
yet, and §8's pre-1.0 rules cover what the wider proof then finds.
|
|
||||||
|
|
||||||
**Shipped** 2026-09-05: `@larvit/adf-codec@0.1.0` published and `v0.1.0` tagged on `8a847de`. Publishing needed a
|
|
||||||
bypass-2FA token — the account carrying no write-2FA requirement was not enough, npm demanded an
|
|
||||||
OTP until the token itself bypassed it.
|
|
||||||
@@ -1,259 +1,180 @@
|
|||||||
# Todo
|
# todo
|
||||||
|
|
||||||
The plan. Design questions are settled in `AGENTS.md`; remaining spec detail is settled at its own
|
## Scoring
|
||||||
milestone. A done item shrinks to its title here; its full text moves to `todo-history.md`.
|
|
||||||
|
|
||||||
## Milestones
|
`Score = -R - S/4 + 2*A + 2*G*W`
|
||||||
|
|
||||||
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 3k, 11, 4, 12, 13, 4b,
|
`Bar = 9`
|
||||||
4c, 14, 15, 16, 18, 4d, 17, 10, 6, 7, 5f, 5g → `0.2.0`; 8, 9 → TBD; 5e last.
|
|
||||||
The numbering is the order the work was planned in, not the order it ships. Everything known and
|
|
||||||
shaped ships in one release rather than a string of them: nothing waits on a version, and no
|
|
||||||
consumer is served by the churn (the maintainer, 2026-09-18). So `0.2.0` completes §1's three
|
|
||||||
formats, and `0.2.1` and `0.3.0` are gone. `8` and `9` stay out as the two goals nothing has shaped
|
|
||||||
yet. `0.2.0`'s order is settled (the maintainer, 2026-09-13, extended 2026-09-18): 11 makes the
|
|
||||||
tables 4 generates from answer to Atlassian's schema, 4 proves 12, 13 spells 11's gaps in 12's
|
|
||||||
grammar, and 12 rewrites code 4b and 4c change; then 14 moves the files 15, 16 and 10 edit and HTML
|
|
||||||
is written against that layout, 4d marks the gate legs before 17 adds one, 17 puts the complexity
|
|
||||||
guardrail under the largest body of new code, and 5f and 5g read last because 7 is what changes the
|
|
||||||
bundle size and the tagline.
|
|
||||||
|
|
||||||
- [x] **0 — Scaffold.**
|
`Next ID = 52`
|
||||||
- [x] **1a — The directive grammar.**
|
|
||||||
- [x] **1b — Block node syntaxes.**
|
|
||||||
- [x] **1c — Inline node syntaxes and marks.**
|
|
||||||
- [x] **1d — Corpus start.**
|
|
||||||
- [x] **1d1 — The CommonMark subset.**
|
|
||||||
- [x] **1d2 — Block nodes.**
|
|
||||||
- [x] **1d3 — Inline nodes and marks.**
|
|
||||||
- [x] **2 — `adfToMarkdown`.**
|
|
||||||
- [x] **2a — The runner and the CommonMark subset.**
|
|
||||||
- [x] **2b — Block nodes.**
|
|
||||||
- [x] **2c — Inline nodes and marks.**
|
|
||||||
- [x] **2d — The opaque carry.**
|
|
||||||
- [x] **2e — Carve-outs and combinations.**
|
|
||||||
- [x] **2e1 — The carve-outs and the claimed line.**
|
|
||||||
- [x] **2e2 — Mark runs and the runs a carry breaks.**
|
|
||||||
- [x] **2e3 — Attribute canonicalization and the quoted value's escape.**
|
|
||||||
- [x] **2e4 — The carry's fallback triggers.**
|
|
||||||
- [x] **2e5 — Combined documents and the collision property.**
|
|
||||||
- [x] **2f — The attributes CommonMark cannot hold.**
|
|
||||||
- [x] **3 — `markdownToAdf`.**
|
|
||||||
- [x] **3a — The hierarchy.**
|
|
||||||
- [x] **3b — The leaf blocks.**
|
|
||||||
- [x] **3c — The container blocks.**
|
|
||||||
- [x] **3d — Inline text.**
|
|
||||||
- [x] **3e — Emphasis and links.**
|
|
||||||
- [x] **3f — The directive grammar.**
|
|
||||||
- [x] **3g — The node tables read backwards.**
|
|
||||||
- [x] **3h — The block nodes.**
|
|
||||||
- [x] **3i — The inline nodes and the marks.**
|
|
||||||
- [x] **3j — The carry and the combinations.**
|
|
||||||
- [x] **3k — The CommonMark spec suite.**
|
|
||||||
- [x] **4 — Round-trip property tests.**
|
|
||||||
- [x] **4.1 — Editor-normal and the node accessors.**
|
|
||||||
- [x] **4.2 — The ADF property.**
|
|
||||||
- [x] **4.3 — The markdown property.**
|
|
||||||
- [x] **4.4 — The real payloads.**
|
|
||||||
- [x] **4b — The block walk's retry (`0.2.0`).**
|
|
||||||
- [x] **4c — The scanning rule's remaining sites (`0.2.0`).**
|
|
||||||
- [ ] **4d — What the gate says while it runs (`0.2.0`).** `ci.sh` runs nine legs and announces
|
|
||||||
none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
|
|
||||||
a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its
|
|
||||||
own fix. The legs need markers: `plainpages`' `ci.sh` prints a `step()` header per leg and
|
|
||||||
this one prints nothing, so name the leg and the image before each. The longest leg is the
|
|
||||||
quietest: `test_output=$(… npm test 2>&1)` buffers the whole Node run to replay it after,
|
|
||||||
because the zero-test guard greps the count — stream it and grep a copy (`tee`), rather than
|
|
||||||
trading the output for the guard. And two legs are silenced outright, `npm pack` and the
|
|
||||||
tarball install, whose `>/dev/null` predates the offline install that made them quick and
|
|
||||||
quiet. `publish.sh` owes the same: today it says nothing between reading `private` and the
|
|
||||||
registry answering, which is where its `npm ci` and rebuild sit — the seconds §9 accepts
|
|
||||||
rather than promoting the gate's `dist`, and unmeasured until the log shows them. Per-leg
|
|
||||||
timing is what turns "slow or hung" from a guess into a reading; the browser leg's own
|
|
||||||
5.4–7.9s against a 17s warm gate is the number that made it obviously cheap.
|
|
||||||
- [x] **5 — Ship `0.1.0`.**
|
|
||||||
- [ ] **5e — The publish token's deadline.** `0.1.0` published only once the npm
|
|
||||||
token carried **Bypass 2FA**: the account requiring no 2FA on writes was not enough, and npm
|
|
||||||
answered `EOTP` until the token itself bypassed. npm retires bypass-2FA tokens for direct
|
|
||||||
publishing around January 2027, leaving them `npm stage publish`, which a maintainer
|
|
||||||
approves with 2FA; its replacement — trusted publishing over OIDC — supports GitHub-hosted
|
|
||||||
Actions, GitLab.com's shared runners and CircleCI's cloud, self-hosted runners planned
|
|
||||||
without a date. So the release path has an expiry date and no drop-in successor yet. Revisit:
|
|
||||||
whether npm has added Gitea or self-hosted OIDC, and otherwise whether the
|
|
||||||
release moves to the staged publish — which fits badly with publish-on-merge,
|
|
||||||
and is the trade to weigh rather than discover on a red release run.
|
|
||||||
**Settled** (the maintainer, 2026-09-13): last of the known work, clear of `0.2.0`, placed
|
|
||||||
there knowing the cutoff may land before `0.2.0` ships.
|
|
||||||
- [ ] **5f — Publish the bundle size (`0.2.0`).** Measure the shipped artifact and put the number in the
|
|
||||||
README, kept honest by the release pipeline rather than by a human re-reading it. The
|
|
||||||
quantity is what a consumer downloads and loads: the tarball `npm pack` produces, its
|
|
||||||
unpacked `dist`, and the built JavaScript minified + gzipped — the figure the competitors
|
|
||||||
advertise (marklassian's "12kb") and the only apple-to-apple one, since ours ships tsc's
|
|
||||||
unminified output and no minifier yet (decide here whether to minify for the build or report
|
|
||||||
the unminified gzip). A publish/pipeline leg measures it and fails when the README figure
|
|
||||||
drifts, so the number can't rot; the figure lands in README §The package beside the
|
|
||||||
"no runtime dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked
|
|
||||||
221.5 kB, JS gzipped 45.6 kB.
|
|
||||||
- [ ] **5g — Reweight the README for the reader (`0.2.0`).** It opens with the pre-launch rationale —
|
|
||||||
Atlassian's REST APIs, `pf-editor-service/convert` being decommissioned, a link to
|
|
||||||
JRACLOUD-77436 — where a shipped package should answer what it is, what it does and for whom
|
|
||||||
first, then the shortest runnable example.
|
|
||||||
**Settled** (the maintainer, 2026-09-13): the background goes entirely, no endpoint, ticket or
|
|
||||||
"why" note left. The top follows the package-README order: an npm version badge and the Gitea
|
|
||||||
Actions badge, a tagline that is also `package.json`'s `description`, a feature list and a
|
|
||||||
one-line table of contents, then install and the shortest runnable example; a table of
|
|
||||||
everything exported sits near the bottom. The HTML directions were to stay an aside until a
|
|
||||||
later release shipped them; 7 now ships in this one and reads ahead of this item, so the
|
|
||||||
README documents HTML as it documents markdown, the tagline and `description` naming both
|
|
||||||
(the maintainer, 2026-09-13, revised 2026-09-18).
|
|
||||||
- [x] **5a — Rename to `@larvit/adf-codec`.**
|
|
||||||
- [x] **5b — The consumer's error surface.**
|
|
||||||
- [x] **5b1 — The error's source position.**
|
|
||||||
- [x] **5b2 — The error messages.**
|
|
||||||
- [x] **5b3 — The code list and the flavour's gaps.**
|
|
||||||
- [x] **5b4 — The README's consumer surface.**
|
|
||||||
- [x] **5c — The build and the release pipeline.**
|
|
||||||
- [x] **5d — The browser leg.**
|
|
||||||
- [ ] **6 — The HTML dialect spec (`0.2.0`).** Element-by-element mapping, the `data-*` fidelity
|
|
||||||
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
|
|
||||||
- [ ] **7 — HTML, the third format (`0.2.0`).** `adfToHtml`, `htmlToAdf`, the composed
|
|
||||||
`markdownToHtml` / `htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from
|
|
||||||
here (§10). The README's tagline and `package.json`'s `description` regain HTML (5g).
|
|
||||||
- [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists.
|
|
||||||
- [ ] **9 — The online sandbox.** A web page with two textboxes converting back and forth between ADF and markdown, powered by the library's browser build.
|
|
||||||
- [ ] **10 — Lossy conversion (`0.2.0`).** Markdown other tools render readably, to and from ADF,
|
|
||||||
keeping the content while dropping what markdown cannot hold — format, design and the richer
|
|
||||||
nodes.
|
|
||||||
**Settled** (the maintainer, 2026-09-14): two exports composed around the lossless pair, so §1's
|
|
||||||
four conversions stay four. `adfToPlainMarkdown(doc)` reduces the document ADF→ADF and hands it
|
|
||||||
to `adfToMarkdown`; `plainMarkdownToAdf(markdown)` hands the markdown to `markdownToAdf` and
|
|
||||||
lifts the result ADF→ADF. Both carry markdown conventions, so the reduction sits in
|
|
||||||
`src/markdown/emit/`, the lift in `src/markdown/parse/` and what both read in `src/markdown/`
|
|
||||||
(§11). The markdown is the flavour without directives — CommonMark, the pipe table and `~~` —
|
|
||||||
plus the conventions below, chosen for readability from a survey of GitHub, GitLab, Gitea,
|
|
||||||
Obsidian, Pandoc, MkDocs, Docusaurus, Typora, Joplin, Logseq, Bear, Notion, Azure DevOps and
|
|
||||||
Discord, GitHub's renderer confirming each shape. Writing refuses only what the document guard
|
|
||||||
refuses (`not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`)
|
|
||||||
and degrades every other shape; reading refuses what `markdownToAdf` refuses. A lifted node
|
|
||||||
carries no `localId`. The lift also reads other tools' spellings — type words in any case,
|
|
||||||
Obsidian's aliases, `[X]` — since it reads their output and never writes those spellings.
|
|
||||||
- A `panel` is an alert: the marker alone on the quote's first line, a blank `>`, then the body
|
|
||||||
(`> [!WARNING]`), in GitHub's five words by colour — info `NOTE`, note `IMPORTANT`, tip and
|
|
||||||
success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE`. The lift reads those words
|
|
||||||
back (`NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error) and
|
|
||||||
Obsidian's by meaning (hint tip; success, check and done success; attention warning; danger,
|
|
||||||
failure, fail, missing and bug error; any other word info). Text after a marker in its
|
|
||||||
paragraph is the panel's first body paragraph.
|
|
||||||
- An `expand` or `nestedExpand` is Obsidian's folded callout, `> [!NOTE]- Title`, a blank `>`,
|
|
||||||
then the body. The lift reads a fold sign (`-` or `+`) as an expand whatever the word, the
|
|
||||||
rest of the marker's paragraph as its title, and an expand inside an expand as a
|
|
||||||
`nestedExpand`.
|
|
||||||
- A `taskList` is a bullet list whose items lead with `[x]` or `[ ]` (`- [x] Write the spec`).
|
|
||||||
The lift reads a list whose every item is so marked back as a `taskList` — a `blockTaskItem`
|
|
||||||
where an item holds more than one block, a nested task list moved beside its item — and
|
|
||||||
leaves mixed and ordered lists plain. A `decisionList` is a plain bullet list.
|
|
||||||
- `backgroundColor` is `==text==`, and the lift gives `==text==` the Atlassian editor's default
|
|
||||||
highlight colour.
|
|
||||||
- `layoutSection`/`layoutColumn`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`
|
|
||||||
and `extensionFrame` unwrap to their body blocks in order; the CommonMark blocks keep their
|
|
||||||
spelling, attributes dropped.
|
|
||||||
- `mention` and `status` become their text, the mention's `@` kept; `emoji` its text or else its
|
|
||||||
`shortName`; `date` its ISO date in UTC (`2026-09-13`); `inlineCard`, `blockCard` and
|
|
||||||
`embedCard` a link to their `url`, dropped when they carry only `data`; a `mediaSingle`
|
|
||||||
holding an external image stays ``; `media`, `mediaGroup` and `mediaInline` their
|
|
||||||
`alt` text or nothing; `caption` its text as a paragraph; `extension`, `inlineExtension` and
|
|
||||||
`syncBlock` their `text` attribute or nothing; `placeholder` nothing; a node no row names, or
|
|
||||||
one standing where no spelling holds it, its blocks or its text.
|
|
||||||
- A table stays a pipe table: the first row becomes the header, a cell's blocks join on one line
|
|
||||||
with spaces, and spans and the cells they cover drop.
|
|
||||||
- `code`, `em`, `link`, `strike` and `strong` stay and every other mark drops, keeping its text —
|
|
||||||
`subsup` too, since `~2~` is a strike on GitHub; a link no CommonMark escape writes becomes its
|
|
||||||
text, and a mark run CommonMark's flanking or matching cannot spell drops its mark.
|
|
||||||
- A newline in text becomes a hard break and edge whitespace is trimmed; carriage returns and
|
|
||||||
null characters are removed; a paragraph line opening with a code span whose backticks would
|
|
||||||
read as a fence loses the code mark; an empty paragraph drops, and adjacent lists of one type
|
|
||||||
merge.
|
|
||||||
- Rejected in the survey: `~sub~` and `^sup^`, underline and colour spellings, raw HTML
|
|
||||||
(`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family, footnotes, definition
|
|
||||||
lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states past `[x]`/`[ ]`,
|
|
||||||
and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes.
|
|
||||||
- [ ] **10a — The reduction.** `adfToPlainMarkdown`'s ADF→ADF reduction, tests first, a test per
|
|
||||||
row above.
|
|
||||||
- [ ] **10b — The lift.** `plainMarkdownToAdf`'s ADF→ADF lift, tests first, a test per row it reads,
|
|
||||||
other tools' spellings included; the editor's default highlight colour looked up and cited.
|
|
||||||
- [ ] **10c — The exports.** `adfToPlainMarkdown` and `plainMarkdownToAdf` exported with their README
|
|
||||||
sections, and two properties over 4.2's generators: writing refuses only the guard's codes,
|
|
||||||
and markdown `adfToPlainMarkdown` wrote reads back through `plainMarkdownToAdf` and writes
|
|
||||||
again byte for byte. AGENTS.md §1 records the pair as composed around the lossless one.
|
|
||||||
- [x] **11 — Atlassian's ADF schema as the tables' truth.**
|
|
||||||
- [x] **11a — The vendored schema.**
|
|
||||||
- [x] **11b — The gate.**
|
|
||||||
- [x] **12 — The `!adf:` re-spelling.**
|
|
||||||
- [x] **12a — The spec and the decision.**
|
|
||||||
- [x] **12b — The inline form.**
|
|
||||||
- [x] **12c — The block form.**
|
|
||||||
- [x] **12d — The README, `MIGRATION.md` and the sweep.**
|
|
||||||
- [x] **13 — The schema's gap attributes (`0.2.0`).**
|
|
||||||
- [x] **13a — `rule` and `layoutSection`.**
|
|
||||||
- [x] **13b — The directive link.**
|
|
||||||
- [ ] **14 — The CommonMark subset's directory (`0.2.0`).** `src/markdown/` holds 16 source
|
|
||||||
files at its root and 10 adds more there. The CommonMark subset moves under
|
|
||||||
`src/markdown/commonmark/` — `backtick-runs.ts`, `commonmark-grammar.ts` as `grammar.ts`,
|
|
||||||
`emphasis-matching.ts`, `entity-references.ts` with its test, `link-reference-definitions.ts`
|
|
||||||
and `link-syntax.ts` — leaving the flavour's own constructs at the root, the split
|
|
||||||
`spec/flavour.md` draws between the subset and the flavour (the systems-architect and the
|
|
||||||
maintainer, 2026-09-16).
|
|
||||||
- [ ] **15 — The href-less directive link (`0.2.0`).** Refuse `!adf:link[text]` spelling no `href`
|
|
||||||
with `unsupported-node-shape` naming the attribute, so the mark has one spelling: today it
|
|
||||||
parses to a mark the emitter writes back as a carry, while the schema requires `href` and
|
|
||||||
every other directive mark spells without attributes in both directions alike (the
|
|
||||||
stability-reviewer, 2026-09-16; the maintainer, 2026-09-17).
|
|
||||||
- [ ] **16 — The link wrapping a link (`0.2.0`).** Read `[<http://x/>](/v)` and
|
|
||||||
`[!adf:link[a]{href="/u"}](/v)` as `[[a](/u)](/v)` reads — the inner link wins and the outer
|
|
||||||
brackets stay literal text, CommonMark's rule that no link holds another — rather than
|
|
||||||
dropping the outer link silently as `closeLink`'s `applyMark` does today, with a normalization
|
|
||||||
fixture per shape (the stability-reviewer, 2026-09-16; the maintainer, 2026-09-17).
|
|
||||||
- [ ] **17 — A machine-enforced size guardrail (`0.2.0`).** Add a per-function complexity check to
|
|
||||||
the gate — branch count or size — so the fits-in-your-head guardrail fails the build rather
|
|
||||||
than waiting for a review to catch it (the systems-architect, 2026-09-16). It reads ahead of
|
|
||||||
6, 7 and 10 so the largest body of new code is written under it, which is also what decides
|
|
||||||
the threshold: today's worst is `readDirectiveContent`, 27 lines and about 12 decision points
|
|
||||||
over four concerns in one loop — escape, code span, nested directive, bracket balance — which
|
|
||||||
4c left half-split and this item either passes or forces apart (the systems-architect and the
|
|
||||||
maintainer, 2026-09-18).
|
|
||||||
- [ ] **18 — The subtree the directive spelling asks about (`0.2.0`).** The parser asks
|
|
||||||
`commonMarkSpelling` at every directive-spelled block and the answer emits the whole subtree
|
|
||||||
below, so a node at depth d is spelled d times: three nested rule-first directive lists cost
|
|
||||||
18 asks over 10 nodes, and 250 levels parse in 1.2 s at 16.4 kB, 4.9 s at 261 kB with a
|
|
||||||
kilobyte of content per level. The depth guard bounds the levels at about 250, never the
|
|
||||||
content, so this is the pipeline persona's hang on an input nobody typed (§11). Keeping each
|
|
||||||
child's emitted result for its parent's ask is not a straight handover: the same node object
|
|
||||||
is asked at different depths — 4, 3 and 2 for the innermost list of three — because the
|
|
||||||
parser counts a list and its item as two levels where the emitter's readable list counts one
|
|
||||||
(4b), and `headroom` is that guard's slack. The parts that survive the measurement: the paths
|
|
||||||
agree, `text` and `spelling` carry no depth, `headroom` is affine in it, and the parser asks
|
|
||||||
first at the deepest of them, so a kept result rebases by the difference. Either rebase and
|
|
||||||
record that argument in `AGENTS.md`, or give both directions one list accounting so a node
|
|
||||||
has one depth and nothing needs rebasing — which reopens 4b. A single post-build walk was
|
|
||||||
rejected: it reports the outer offender where the build reports the inner one (the
|
|
||||||
maintainer, 2026-09-18).
|
|
||||||
|
|
||||||
## The ADF inventory to cover
|
| Goal | W |
|
||||||
|
|---|---|
|
||||||
|
| 1 | 1.00 |
|
||||||
|
| 2 | 0.89 |
|
||||||
|
| 3 | 0.78 |
|
||||||
|
| 4 | 0.67 |
|
||||||
|
| 5 | 0.56 |
|
||||||
|
| 6 | 0.44 |
|
||||||
|
| 7 | 0.33 |
|
||||||
|
| 8 | 0.22 |
|
||||||
|
| 9 | 0.11 |
|
||||||
|
|
||||||
From Atlassian's [structure
|
## Items
|
||||||
reference](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/) — not the
|
|
||||||
whole schema: real payloads also carry `taskList`/`taskItem`, `decisionList`/`decisionItem`,
|
|
||||||
`layoutSection`/`layoutColumn`, `blockCard`/`embedCard`, `extension`/`bodiedExtension`/`inlineExtension`
|
|
||||||
and `placeholder`, none documented there. The documented set is the floor: the floor gets designed
|
|
||||||
syntax, the rest rides the opaque carry (§3) until it does too.
|
|
||||||
|
|
||||||
| | |
|
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|
||||||
| --- | --- |
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| Top-level block | `blockquote` `bodiedSyncBlock` `bulletList` `codeBlock` `expand` `heading` `mediaGroup` `mediaSingle` `multiBodiedExtension` `orderedList` `panel` `paragraph` `rule` `syncBlock` `table` |
|
| 40 | 0.2.0 | decision | **Make `markdownToAdf(adfToMarkdown(doc))` deep-equal `doc` for every document `adfToMarkdown` takes.** | 6 | 7 | 8 | 9 | 1 | 26.2 |
|
||||||
| Child block | `blockTaskItem` `extensionFrame` `listItem` `media` `nestedExpand` `tableCell` `tableHeader` `tableRow` |
|
| 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 3 | 25.8 |
|
||||||
| Inline | `date` `emoji` `hardBreak` `inlineCard` `mediaInline` `mention` `status` `text` |
|
| 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
|
||||||
| Marks | `border` `code` `em` `link` `strike` `strong` `subsup` `textColor` `underline` |
|
| 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 3 | 24.7 |
|
||||||
|
| 43 | 0.2.0 | | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 3, 4 | 22.3 |
|
||||||
|
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.** | 4 | 5 | 5 | 8 | 3, 4 | 17.2 |
|
||||||
|
| 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 3, 4 | 12.4 |
|
||||||
|
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 3, 4, 6 | 10.4 |
|
||||||
|
| 38 | 0.3.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 4 | 7 | 1 | 19.5 |
|
||||||
|
| 47 | 0.3.0 | | **Open the README with what the package is, what it does and for whom.** | 1 | 4 | 7 | 9 | 9 | 14.0 |
|
||||||
|
| 34 | 0.3.0 | | **Read emphasis flanking by the whole character beside an astral symbol.** | 2 | 3 | 3 | 6 | 3, 4 | 12.6 |
|
||||||
|
| 42 | 0.3.0 | | **Trim a text leaf's trailing blanks in linear time.** | 1 | 2 | 5 | 9 | 8 | 12.5 |
|
||||||
|
| 48 | 0.3.0 | | **Keep the release path publishing past npm's bypass-2FA token retirement.** | 4 | 4 | 8 | 3 | 9 | 11.7 |
|
||||||
|
| 31 | 0.3.0 | | **Make the branch-coverage figure repeat across runs of an unchanged tree.** | 2 | 3 | 3 | 4 | 1 | 11.2 |
|
||||||
|
| 33 | 0.3.0 | | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 8 | 10.7 |
|
||||||
|
| 46 | 0.3.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 7, 9 | 10.3 |
|
||||||
|
| 9 | 0.3.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 6 | 8 | 9 | 10.3 |
|
||||||
|
| 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 9 | 10.6 |
|
||||||
|
|
||||||
Plain markdown covers `blockquote`, `bulletList`, `codeBlock`, `heading`, `orderedList`,
|
## Details
|
||||||
`paragraph`, `rule`, `listItem`, `hardBreak`, `text`, and the `code`, `em`, `link` and `strong`
|
|
||||||
marks; `strike` is the flavour's `~~` carve-out. Everything else is what the flavour is for.
|
### 40. Make `markdownToAdf(adfToMarkdown(doc))` deep-equal `doc` for every document `adfToMarkdown` takes.
|
||||||
|
|
||||||
|
Today it holds for editor-normal documents only: two adjacent text nodes with the same marks merge,
|
||||||
|
an empty `attrs`, `marks` or `content` drops, and `-0` reads back `0` — shapes pipelines and bots
|
||||||
|
build. Spell each so it reads back as written; CommonMark's spelling stays wherever the document
|
||||||
|
holds none of these shapes. The spellings are part of the chunk. `docs/decisions.md` §Equality is
|
||||||
|
editor-normal, `spec/flavour.md` and `corpus/README.md` follow, and the tests drop `toEditorNormal`.
|
||||||
|
|
||||||
|
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.
|
||||||
|
|
||||||
|
Lands after item 6. The CommonMark spec suite also runs against `markdownToHtml`. The README
|
||||||
|
documents HTML as it documents markdown, and its tagline and `package.json`'s `description` regain
|
||||||
|
HTML.
|
||||||
|
|
||||||
|
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
|
||||||
|
|
||||||
|
Goal 1 has every call return a result; the boolean guard is the one export that does not, and it
|
||||||
|
cannot say which branch refused, where `not-an-adf-document`'s message already does. Breaking:
|
||||||
|
`MIGRATION.md` shows the guard's replacement.
|
||||||
|
|
||||||
|
### 6. Specify the HTML dialect.
|
||||||
|
|
||||||
|
Element-by-element mapping, the `data-*` fidelity scheme, the opaque-carry form, and the documented
|
||||||
|
foreign-element set `htmlToAdf` accepts — the set `markdownToAdf` shares (`spec/flavour.md` §Raw
|
||||||
|
HTML in input). The set sorts per `docs/decisions.md` §Foreign HTML sorts three ways.
|
||||||
|
|
||||||
|
### 43. Give each markdown input its own reader, strict to its own standard.
|
||||||
|
|
||||||
|
Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a
|
||||||
|
directive, a pipe table or a `~~` pair becomes a flavour node where CommonMark reads plain text. A
|
||||||
|
caller names the markdown it hands in: CommonMark, read as its spec says, or the lossless flavour,
|
||||||
|
read as `spec/flavour.md` says. Breaking: `MIGRATION.md` says which call a caller takes.
|
||||||
|
|
||||||
|
### 49. Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.
|
||||||
|
|
||||||
|
Lands after item 43. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads
|
||||||
|
two (spec examples 301 and 302), and so must the CommonMark reader. `spec/flavour.md` merges them in
|
||||||
|
the lossless flavour on purpose and parts two adjacent lists with `!adf:listBreak`. The chunk
|
||||||
|
settles by Goals 3 and 4 whether the flavour follows, and asks where the Goals do not decide. That
|
||||||
|
answer also settles what `adfToMarkdown` and `adfToPlainMarkdown` write for two adjacent lists, and
|
||||||
|
whether `!adf:listBreak` still reads. Breaking, so it ships beside item 43: `MIGRATION.md`'s
|
||||||
|
Readings table gains its row, and its Spellings table one if `!adf:listBreak` retires. Examples 301
|
||||||
|
and 302 lose their `pending` exceptions, and the spelling leaves the README's "Four CommonMark
|
||||||
|
spellings" bullet, which counts one fewer.
|
||||||
|
|
||||||
|
### 51. Match a reference label to its definition under Unicode case folding.
|
||||||
|
|
||||||
|
`link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec
|
||||||
|
example 540); lowercasing and then uppercasing folds it. Breaking, so it ships beside item 43:
|
||||||
|
`MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go, and its spelling leaves
|
||||||
|
the README's "Four CommonMark spellings" bullet, which counts one fewer.
|
||||||
|
|
||||||
|
### 50. Read `[](/url)` and `[]()` as CommonMark's empty link.
|
||||||
|
|
||||||
|
Both stay literal text today (spec examples 484 and 487). ADF holds no empty text node to carry a
|
||||||
|
link mark, so the chunk settles what the empty link builds by Goals 3 and 6, and asks where they do
|
||||||
|
not decide; whatever it builds, both still parse, since a bot relies on plain CommonMark being valid
|
||||||
|
input. Breaking, so it ships beside item 43: `MIGRATION.md`'s Readings table gains its row. Its
|
||||||
|
`pending` exceptions go, and its spelling leaves the README's "Four CommonMark spellings" bullet,
|
||||||
|
which counts one fewer.
|
||||||
|
|
||||||
|
### 38. Spell a lone surrogate in a text node so it survives a UTF-8 encode.
|
||||||
|
|
||||||
|
`adfToMarkdown` emits it verbatim, so markdown stored as UTF-8 reads back U+FFFD; attribute values
|
||||||
|
already escape it.
|
||||||
|
|
||||||
|
### 47. Open the README with what the package is, what it does and for whom.
|
||||||
|
|
||||||
|
It opens with the pre-launch rationale — Atlassian's REST APIs, `pf-editor-service/convert` being
|
||||||
|
decommissioned, a link to JRACLOUD-77436. The background goes entirely, no endpoint, ticket or "why"
|
||||||
|
note left. The badges are npm's version and the Gitea Actions status. The README names the lossy
|
||||||
|
pair, `adfToPlainMarkdown` and `plainMarkdownToAdf`, and the flavours it writes and reads — GitHub
|
||||||
|
Flavored Markdown's alerts and task lists, Obsidian Flavored Markdown's callouts — so a search for
|
||||||
|
any of these names finds the package.
|
||||||
|
|
||||||
|
### 34. Read emphasis flanking by the whole character beside an astral symbol.
|
||||||
|
|
||||||
|
Check whether `line-escaping.ts`'s `charAt` and the parser's flanking read one UTF-16 unit beside an
|
||||||
|
astral symbol — a lone surrogate is neither punctuation nor symbol, where CommonMark reads `😀` as
|
||||||
|
punctuation — and, where they do, read the code point, with a fixture per direction.
|
||||||
|
|
||||||
|
### 42. Trim a text leaf's trailing blanks in linear time.
|
||||||
|
|
||||||
|
`plain-inline.ts`'s `leafEdges` finds the trail with an unanchored `/[ \t]*$/`, quadratic in a run
|
||||||
|
of blanks inside one leaf: a paragraph of `a`, 80 000 spaces, `b` takes 6.5 s in
|
||||||
|
`adfToPlainMarkdown`. Scan backward, as the expand title's trim does.
|
||||||
|
|
||||||
|
### 48. Keep the release path publishing past npm's bypass-2FA token retirement.
|
||||||
|
|
||||||
|
Lands after 2027-01-01, or after a release run fails on the token, whichever comes first: the
|
||||||
|
maintainer chose on 2026-10-02 to wait and see whether the retirement bites. It holds back no
|
||||||
|
release: when the rest of its release is done, it moves to the next. `0.1.0` published only once the
|
||||||
|
npm token carried **Bypass 2FA**: the account requiring no 2FA on writes was not enough, and npm
|
||||||
|
answered `EOTP` until the token itself bypassed. npm retires bypass-2FA tokens for direct publishing
|
||||||
|
around January 2027, leaving them `npm stage publish`, which a maintainer approves with 2FA; its
|
||||||
|
replacement — trusted publishing over OIDC — supports GitHub-hosted Actions, GitLab.com's shared
|
||||||
|
runners and CircleCI's cloud, self-hosted runners planned without a date. Revisit: whether npm has
|
||||||
|
added Gitea or self-hosted OIDC, and otherwise whether the release moves to the staged publish —
|
||||||
|
which fits badly with publish-on-merge, and is the maintainer's trade to weigh. The Goals and G
|
||||||
|
cells are provisional: no README goal covers the release path.
|
||||||
|
|
||||||
|
### 31. Make the branch-coverage figure repeat across runs of an unchanged tree.
|
||||||
|
|
||||||
|
Three Node test legs over one unchanged tree reported `emit/inline-line.ts` at 95.83%, 96.23% and
|
||||||
|
96.23%, and the total at 98.80%, 98.84% and 98.84% (2026-09-21). `--experimental-test-coverage`
|
||||||
|
counts branches off V8's own coverage, which the runner's parallel files and V8's optimization make
|
||||||
|
run-dependent, so the number the floor is read against is not the code's alone. The floor of 98
|
||||||
|
holds today on 0.8 points of slack and `docs/decisions.md` §The coverage floors says it only ever
|
||||||
|
moves upward, so the first raise to the measured figure reddens a run that changed nothing. Make the
|
||||||
|
measurement repeatable, or state the number the floor may be raised to and why it is not the
|
||||||
|
measured one.
|
||||||
|
|
||||||
|
### 33. Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.
|
||||||
|
|
||||||
|
`adfToMarkdown` spends 23 s on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking
|
||||||
|
cannot spell re-emits the whole line before riding the carry, quadratic in the runs, and the plain
|
||||||
|
reduction's `spellableLine` drops one mark per re-emit the same way. Make both linear.
|
||||||
|
|
||||||
|
### 46. Publish the bundle size in the README, failing the release pipeline when it drifts.
|
||||||
|
|
||||||
|
Lands after item 7, which changes it. The quantity is what a consumer downloads and loads: the
|
||||||
|
tarball `npm pack` produces, its unpacked `dist`, and the built JavaScript minified + gzipped — the
|
||||||
|
figure the competitors advertise (marklassian's "12kb") and the only apples-to-apples one, since
|
||||||
|
ours ships tsc's unminified output and no minifier yet (decide here whether to minify for the build
|
||||||
|
or report the unminified gzip). The figure lands in README §The package beside the "no runtime
|
||||||
|
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6
|
||||||
|
kB.
|
||||||
|
|
||||||
|
### 8. Ship a CLI.
|
||||||
|
|
||||||
|
The Goals and G cells are provisional: no README goal or persona covers a CLI yet. The chunk
|
||||||
|
proposes both (the maintainer, 2026-10-02), and they land in the README's `## Goals` and `##
|
||||||
|
Audience` with the CLI.
|
||||||
|
|||||||
+1
-1
@@ -27,6 +27,6 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"exclude": ["src/**/*.test.ts", "src/property-harness.ts"],
|
"exclude": ["src/**/*.test.ts", "src/conformance/property-harness.ts"],
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user