96 Commits

Author SHA1 Message Date
lilleman 5798a3cde1 Answer prose round 3: clean
CI / gate (push) Successful in 53s
CI / publish (push) Successful in 5s
2026-10-04 22:32:07 +02:00
lilleman 55c6f01517 Answer prose round 2: item 47 marks the README's current opening as today's
CI / gate (push) Successful in 52s
CI / publish (push) Has been skipped
2026-10-04 22:29:53 +02:00
lilleman 8a27cb52d8 Answer product-owner round 2: item 72 drops the npm token for the trusted publisher
CI / gate (push) Successful in 52s
CI / publish (push) Has been skipped
2026-10-04 22:29:43 +02:00
lilleman 730bf88da7 Answer prose round 2: item 72 names the entries it revises and the publishing change, item 47 opens with what it lands after 2026-10-04 22:29:43 +02:00
lilleman db4599837c Answer prose round 1: the plain bullet ties the drop to what a reader sees, and the editor-normal comments read once
CI / gate (push) Successful in 53s
CI / publish (push) Has been skipped
2026-10-04 22:28:14 +02:00
lilleman b36f6fbeef Answer product-owner round 1: the README opener and bundle size move to 0.2.0, the comparison and announcement wait for them, and the GitHub move carries issues, releases and trusted publishing 2026-10-04 22:28:14 +02:00
lilleman be54d24dc0 Answer stability round 1: item 33 returns as a defect, and item 42 takes the same mark 2026-10-04 22:28:14 +02:00
lilleman e844943d46 Rank discoverability third among the goals, plan the move to GitHub and the outreach, and drop a text node's content in plain
CI / gate (push) Successful in 52s
CI / publish (push) Has been skipped
2026-10-04 22:24:52 +02:00
lilleman 0fd98de1fd Answer prose round 3: clean
CI / gate (push) Successful in 52s
CI / publish (push) Successful in 6s
2026-10-04 21:12:43 +02:00
lilleman 7dd3ef0b24 Answer prose round 2: the slot rule gives the decided reason, and the touched paragraphs rewrap
CI / gate (push) Successful in 52s
CI / publish (push) Has been skipped
2026-10-04 21:11:18 +02:00
lilleman 03dbc46763 Answer product-owner round 3: item 68 points at the error row that lists the shapes 2026-10-04 21:11:18 +02:00
lilleman 04931ab35a Answer prose round 1: the refusal list lives in the error row, the slot rule gives its reason, and stale item citations go
CI / gate (push) Successful in 51s
CI / publish (push) Has been skipped
2026-10-04 21:09:51 +02:00
lilleman d88f4a4351 Answer product-owner round 2: clean 2026-10-04 21:09:51 +02:00
lilleman bb83888d8f Answer stability round 2: the carries comment names the carries it decides 2026-10-04 21:09:51 +02:00
lilleman a0e4247cfd Answer product-owner round 1: the migration row names what 0.1.0 refused, item 69 asks about a text node's content in plain 2026-10-04 21:04:57 +02:00
lilleman b01dfd4b0b Answer stability round 1: carries() decides every leaf carry up front, so the line is emitted once 2026-10-04 21:04:57 +02:00
lilleman 63e0e1dd52 Answer architecture round 2: the error row and migration lead name each direction's causes 2026-10-04 20:48:17 +02:00
lilleman 7b57c1edc8 Answer architecture round 1: a code span opening a line spells as CommonMark, the slot refuses a null character, every generated document emits 2026-10-04 20:43:46 +02:00
lilleman 03f18ee17b Spell every document the emitter refused for a carriage return, a null character, a code-span line start or an empty text node
CI / gate (push) Successful in 57s
CI / publish (push) Has been skipped
2026-10-04 20:29:29 +02:00
lilleman bfbe4a51fb Answer stability round 5: clean
CI / gate (push) Successful in 55s
CI / publish (push) Successful in 5s
2026-10-04 19:35:10 +02:00
lilleman 5a9fd06974 Answer prose round 5: nits declined 2026-10-04 19:35:10 +02:00
lilleman 758549ab24 Answer product-owner round 5: item 66 is a defect 2026-10-04 19:35:10 +02:00
lilleman e8733068ab Answer prose round 4: the premise says plain data, Goal 8's size is stated once
CI / gate (push) Successful in 55s
CI / publish (push) Has been skipped
2026-10-04 19:21:11 +02:00
lilleman 2f0ab4dda9 Answer product-owner round 4: the guarantee names strings 2026-10-04 19:21:11 +02:00
lilleman 9f23c89719 Answer stability round 4: the decision's title backs the loop guarantee alone, item 66 filed 2026-10-04 19:21:11 +02:00
lilleman 58a9c89dfc Record the guards' input boundary and Goal 8's size as JSON, and drop the hardening past it 2026-10-04 19:18:55 +02:00
lilleman fb0d2b4eac Answer prose round 3: the ownsWhatItHolds comment names any other key
CI / gate (push) Successful in 54s
CI / publish (push) Has been skipped
2026-10-04 17:18:03 +02:00
lilleman 638881b555 Answer product-owner round 3: CHANGELOG names the own-key refusal 2026-10-04 17:18:03 +02:00
lilleman d26e4e8152 Answer stability round 3: walk an array's elements as the guards do 2026-10-04 17:18:03 +02:00
lilleman e501b95cb3 Answer prose round 2: the cycle message, README row and CHANGELOG say an object 2026-10-04 17:12:46 +02:00
lilleman b9fa9bff85 Answer product-owner round 2: the cycle message names any object 2026-10-04 17:12:46 +02:00
lilleman 9778844c11 Answer stability round 2: refuse a node or mark holding a key it does not own 2026-10-04 17:12:46 +02:00
lilleman 261dc3a5cf Answer prose round 1: CHANGELOG states what isAdfDocument returns 2026-10-04 17:03:21 +02:00
lilleman addef08fa4 Answer product-owner round 1: name the cycle in its own message and in the error table 2026-10-04 17:03:21 +02:00
lilleman 438bf8876b Answer stability round 1: nits moot once the walks revert 2026-10-04 17:03:21 +02:00
lilleman bf610fc438 Answer security round 1: clean 2026-10-04 17:03:21 +02:00
lilleman f9f863fb28 Refuse a cyclic input as not-an-adf-document
CI / gate (push) Successful in 53s
CI / publish (push) Has been skipped
2026-10-04 16:57:51 +02:00
lilleman 9c1573ce02 Answer the prose review's second round
CI / gate (push) Successful in 53s
CI / publish (push) Successful in 5s
2026-10-04 12:18:09 +02:00
lilleman 146257cc49 Answer the prose review's first round
CI / gate (push) Successful in 53s
CI / publish (push) Has been skipped
2026-10-04 12:17:24 +02:00
lilleman 9f87aa8b8b Record the writer panel's picks and the goals' answers for items 55 and 65
CI / gate (push) Successful in 53s
CI / publish (push) Has been skipped
2026-10-04 12:16:20 +02:00
lilleman 19a70e0e18 Answer the prose review's third round
CI / gate (push) Successful in 52s
CI / publish (push) Successful in 5s
2026-10-04 11:55:40 +02:00
lilleman 5e5e123ff7 Answer the prose review's second round
CI / gate (push) Successful in 52s
CI / publish (push) Has been skipped
2026-10-04 11:54:31 +02:00
lilleman 7217b75a1d Answer the prose review's first round
CI / gate (push) Successful in 52s
CI / publish (push) Has been skipped
2026-10-04 11:52:59 +02:00
lilleman deb639d004 Record the maintainer's answers for items 49, 50, 55 and 65
CI / gate (push) Successful in 2m2s
CI / publish (push) Has been skipped
2026-10-04 11:48:47 +02:00
lilleman 31d3f53a58 Leave Flavour's move to item 59 alone
CI / gate (push) Successful in 52s
CI / publish (push) Successful in 5s
2026-10-03 17:54:41 +02:00
lilleman 80a45b69da Sharpen items 55, 62, 63, 64 and 65 2026-10-03 17:53:30 +02:00
lilleman 8287a483db Take round 5 as the comprehension baseline, and tighten four decision sentences 2026-10-03 17:53:13 +02:00
lilleman 7b38eba29e Reword the isBareText and adfNodes comments 2026-10-03 17:52:56 +02:00
lilleman ee9a7e7562 Join only content-free text nodes in editor-normal ADF, type checked first: adfToPlainMarkdown on 3,000 extension blocks of 200-key parameters 1,752-1,941 ms → 1,034-1,240 ms (1,018-1,283 ms at 608ef26) 2026-10-03 17:52:52 +02:00
lilleman 317262b240 Say which emitter refuses which text node in item 55 2026-10-03 17:02:57 +02:00
lilleman eccfd12d2d Cut the empty-key and -0 grammar from the decisions into the spec, and cite item 65 as what ends the image gap
CI / gate (push) Successful in 52s
CI / publish (push) Has been skipped
2026-10-03 16:23:23 +02:00
lilleman 7bab478729 Keep an empty doc content in editor-normal ADF, as the schema requires 2026-10-03 16:22:54 +02:00
lilleman 00c89dd63b Give the unmarked bare text check and the text-holding-content refusal one owner each 2026-10-03 16:22:19 +02:00
lilleman fd3452b124 Name the reader's own-marks items for what they hold, and drop the carry flag the items already say 2026-10-03 16:21:49 +02:00
lilleman d56e5b4f57 Keep near-miss text pairs apart in the property harness: merge only what the reader joins 2026-10-03 16:21:06 +02:00
lilleman 36604b1e2b Hold the editor's join rule in plain/editor-normal.ts as the reader's over editor-normal forms 2026-10-03 16:19:55 +02:00
lilleman 1791542b3c Rename isPlainText to isBareText, apart from the plain flavour, and say truly what it admits 2026-10-03 16:19:09 +02:00
lilleman f7d2eb6517 Widen item 56 to the inline scanner's Scan 2026-10-03 16:18:30 +02:00
lilleman 79005f07be File the round-4 goals audit's defects and widen items 55 and 62 2026-10-03 16:16:46 +02:00
lilleman 7f67998c3b Prose round 3: retitle the floor decision, put the plain/ exception first, sharpen items 59-62
CI / gate (push) Successful in 57s
CI / publish (push) Has been skipped
2026-10-03 16:11:56 +02:00
lilleman b39525cad7 Ship under the comprehension floor with the panel's scores as the baseline, and file items 59-62 that end it
CI / gate (push) Successful in 52s
CI / publish (push) Has been skipped
2026-10-03 15:53:25 +02:00
lilleman d0cb63fcf2 Name the emitter's block walk PlacedBlocks, apart from the parser's Walk 2026-10-03 15:52:43 +02:00
lilleman 1d3a020926 Keep 'carry' for the opaque carry: holdsOnlyAttributes, spellEdgeWhitespace, bothCommonMark 2026-10-03 15:52:19 +02:00
lilleman 4579f32fcd Name the two text-joining rules for whose rule they are, side by side in adjacent-text.ts 2026-10-03 15:51:41 +02:00
lilleman 6df17a7069 Gather the plain flavour's code in markdown/plain/ 2026-10-03 15:50:24 +02:00
lilleman 4349df9017 Final wording: the reserved-directive refusal row, the adf:-alone fence, the changelog's carry and scope, the developer panel, the migration's empty shape 2026-10-03 15:17:53 +02:00
lilleman d68f311a14 Reword the attrs=empty and language-placement refusals, quoting a language that needs it 2026-10-03 15:17:40 +02:00
lilleman 608ef266ed Reword item 53's tagline
CI / gate (push) Successful in 56s
CI / publish (push) Has been skipped
2026-10-03 15:13:57 +02:00
lilleman a2c620da27 Define the writer panel, split the carry fence into its own changelog entry, and say how an adf: fence stays code 2026-10-03 15:13:50 +02:00
lilleman e4eecebeab Keep the decisions to their reasons and link the grammar; name the bare adf: fence's refusal exactly; call the doc panel a writer panel 2026-10-03 15:13:30 +02:00
lilleman 230d590fb4 Trim two comments 2026-10-03 15:12:54 +02:00
lilleman 7846502a1a Lead the code block, empty-key, carry fence, text break and carry-in-mark refusals with their fix 2026-10-03 15:12:51 +02:00
lilleman 79d9b4db53 Keep the fence wrapper's code span on one line
CI / gate (push) Successful in 52s
CI / publish (push) Has been skipped
2026-10-03 15:06:22 +02:00
lilleman 912c5039aa Rewrap the broken lines, and say plainly how an adf: fence stays code 2026-10-03 15:06:11 +02:00
lilleman fb78063f12 Name the code block wrapper in the two carry-fence refusals that lacked it 2026-10-03 15:05:51 +02:00
lilleman 5cffe82dd5 Lead the refusals ordinary editing meets with the edit that fixes them 2026-10-03 15:05:36 +02:00
lilleman d9f2d325ca Tell migrators an empty stored document gets content: [], and what an adf: fence refuses 2026-10-03 15:04:50 +02:00
lilleman 419bbcb4f2 Say equality is deep over the document's JSON values 2026-10-03 15:04:44 +02:00
lilleman 4d7e8c944c Pin a code block holding content=empty beside its language 2026-10-03 15:04:35 +02:00
lilleman ff9bb7fab2 Inline the identity blocks() helper in the plain reader tests 2026-10-03 15:04:15 +02:00
lilleman 5a7279059d Drop the as const on the doc directive's attribute 2026-10-03 15:04:11 +02:00
lilleman 0ac5dccf11 Pin the empty fence beside another as an error fixture 2026-10-03 15:04:04 +02:00
lilleman 6fc0d6bba9 Refuse attrs=empty on a code block whose fences carry its language 2026-10-03 15:03:58 +02:00
lilleman 423dcbbff5 File the audits' pre-existing findings, and mark item 43 cited by decisions
CI / gate (push) Successful in 53s
CI / publish (push) Has been skipped
2026-10-03 14:47:57 +02:00
lilleman b9760d0af2 Name plainMarkdownToAdf in the empty-markdown changelog entry
CI / gate (push) Successful in 52s
CI / publish (push) Has been skipped
2026-10-03 14:46:29 +02:00
lilleman d7ba8e6aae Keep format knowledge out of mergeAdjacentText's comment 2026-10-03 14:46:29 +02:00
lilleman bba2fa409e Drop empty keys in the property harness through emptyKeys 2026-10-03 14:46:29 +02:00
lilleman c3bc2eb572 Read a carry fence's type in one function 2026-10-03 14:45:48 +02:00
lilleman 2700184e56 Spell the doc directive's content=none through spellAttributes from one constant 2026-10-03 14:45:28 +02:00
lilleman d9c3e4b349 Derive a block directive's empty content from its node, and let readEmptyKeys own the attrs=empty-stands-alone rule 2026-10-03 14:45:12 +02:00
lilleman 2d02165702 Keep the browser leg on dist/index.js: tag -0 in the JSON it hands back 2026-10-03 14:44:14 +02:00
lilleman a565d49c24 Spell 'a text node holding its text and marks alone' once, as isPlainText 2026-10-03 14:44:00 +02:00
lilleman ade3997cfd Hold a carried node's own marks on the item, not in a side set copies fall out of 2026-10-03 14:43:03 +02:00
lilleman 31576bb824 Take the raw run's refusal path only where its code matches the editor-normal refusal 2026-10-03 14:41:42 +02:00
lilleman 723c0e31ef Name the adf: fence among the carve-outs, and the way a fence stays code in its errors 2026-10-03 14:41:30 +02:00
lilleman a5238e4fe5 Cite item 43 as what ends the CommonMark carve-outs and the adf: fence reservation 2026-10-03 14:40:31 +02:00
63 changed files with 1072 additions and 699 deletions
+6 -4
View File
@@ -48,11 +48,13 @@ In `docs/decisions.md`:
- Firefox reads the build - Firefox reads the build
- The coverage floors - The coverage floors
- The size ratchet - The size ratchet
- The project ships under the comprehension floor until items 59, 60, 61 and 62 land
- Properties on a fixed seed - Properties on a fixed seed
- The CommonMark suite checks three ways - The CommonMark suite checks three ways
- The flavour spec is read as a source - The flavour spec is read as a source
- The node tables answer to Atlassian's schema - The node tables answer to Atlassian's schema
- Nothing recurses unbounded - Nothing recurses unbounded
- No value `JSON.parse` or `structuredClone` builds loops a call
- Nothing spreads an unbounded array - Nothing spreads an unbounded array
- A retry loop checks its own termination - A retry loop checks its own termination
- Readers scan by index - Readers scan by index
@@ -167,16 +169,16 @@ reading is the ask. Never ask "A or B?": state the gap, the earlier entries of i
nearest text, a candidate entry in that file's voice, and the instance it yields. An entry that 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. keeps collecting instances is wrong: rewrite it.
Which output the audience expects — README goal 5 — is settled by a reader panel rather than Which output the audience expects — README goal 6 — is settled by a reader panel rather than
asked: three fresh-context readers, one per README persona the question serves, each given only asked: three fresh-context readers, one per README persona the question serves, each given only
`## Audience` and the input, writing what they expect before picking among outputs the goals `## 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 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. 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`. The verdict lands in `docs/decisions.md`.
Every new or changed markdown or HTML spelling goes to such a panel, seated by the people who read A writer panel settles every new or changed markdown or HTML spelling: a reader panel whose readers
and write that format (README `## Audience`); a question about what an app relies on seats the are the people who read and write that format (README `## Audience`). A panel of the developer
developer personas. personas settles a question about what an app relies on.
### Stated numbers ### Stated numbers
+23 -11
View File
@@ -4,18 +4,28 @@
- **Breaking:** directives, the inline opaque carry among them (now `!adf:carry{json="…"}`), are - **Breaking:** directives, the inline opaque carry among them (now `!adf:carry{json="…"}`), are
spelled under an `!adf:` prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`, spelled under an `!adf:` prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`,
`!adf:name arg {attrs}`) in place of the `:::`/`::`/`:name` forms, and the block carry is a code `!adf:name arg {attrs}`) in place of the `:::`/`::`/`:name` forms: text holding an unescaped
fence whose info string `adf:<type>` names the node's type, its body the node's JSON without `!adf:` is claimed. Convert stored markdown per `MIGRATION.md`.
`type`: text holding an unescaped `!adf:` and a code fence whose info string opens `adf:` are - **Breaking:** the block carry is a code fence whose info string `adf:<type>` names the node's
type, its body the node's JSON without `type`, and a code fence whose info string opens `adf:` is
claimed, and `adf` is an ordinary code block language. Convert stored markdown per `MIGRATION.md`. claimed, and `adf` is an ordinary code block language. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** `markdownToAdf` reads markdown holding no block as a document whose `content` is - **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` read markdown holding no block as a
empty, as Atlassian's schema requires; `!adf:doc {content=none}` spells a document holding no document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}`
`content` key. spells a document holding no `content` key.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc`: two adjacent text nodes a reader would - `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it: two adjacent text
join are parted by `!adf:textBreak{}`, an empty `attrs`, `content` or `marks` is spelled nodes CommonMark would read back as one are parted by `!adf:textBreak{}`, an empty `attrs`,
`{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is spelled `-0`, and a `codeBlock` `content` or `marks` is spelled `{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is
of several text nodes is a fence per node. A `codeBlock` holding other than plain text nodes spelled `-0`, and a `codeBlock` of several text nodes is a fence per node. A `codeBlock` holding
rides the block carry, where it was refused. other than plain text nodes rides the block carry, where it was refused.
- **Breaking:** `unspellable-character` and `unspellable-line-start` leave `ConvertErrorCode`, and
`adfToMarkdown` no longer returns `unspellable-whitespace`. A carriage return in text is spelled
`&#13;` and a null character `!adf:text{text="\u0000"}`; a paragraph line opening with a code span
is spelled as that code span, its backticks opening no fence; a code block holding either
character, a code span holding a null character, a text node holding no text or holding
`content`, and an `emoji`, `mention` or `status` whose `text` holds a line ending or a null
character ride the carry. See `MIGRATION.md`.
- Fix a link `title` holding a null character: it is written as `!adf:link[text]{attrs}`, where it
read back as U+FFFD.
- **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no - **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no
CommonMark escape spells is written as `!adf:link[text]{attrs}`. CommonMark escape spells is written as `!adf:link[text]{attrs}`.
- **Breaking:** some directive refusals carry `malformed-directive` where they carried - **Breaking:** some directive refusals carry `malformed-directive` where they carried
@@ -30,6 +40,8 @@
`collection`, `id` and `occurrenceKey` directly where they rode the opaque carry. `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 - Fix an image inside another image's description: it flattens into the alt text, where it was
refused. refused.
- Fix a cyclic document, in which an object holds itself: `adfToMarkdown` and `adfToPlainMarkdown`
refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, where all three hung.
## 0.1.0 ## 0.1.0
+18 -8
View File
@@ -5,8 +5,9 @@
Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling without an error, Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling without an error,
turning each directive into text and each carried node into an `adf` code block. Before `0.2.0` turning each directive into text and each carried node into an `adf` code block. Before `0.2.0`
reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the
recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by the
the tables below. Stored ADF needs no change. tables below. Stored ADF needs one change: give a document holding no `content` key `content: []`.
`0.1.0` built that shape from empty markdown, meaning the empty document.
### Convert markdown ### Convert markdown
@@ -22,7 +23,7 @@ import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1'
function migrateMarkdown(stored: string) { function migrateMarkdown(stored: string) {
const parsed = markdownToAdf010(stored) const parsed = markdownToAdf010(stored)
// 0.1.0's reader was editor-normal, so a document with no content key always meant an empty one. // 0.1.0 dropped an empty content array, so a document with no content key meant an empty one.
return parsed.ok ? adfToMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed return parsed.ok ? adfToMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed
} }
``` ```
@@ -30,8 +31,9 @@ function migrateMarkdown(stored: string) {
- Convert each document once: a second pass can return ok while turning the directives into text. - Convert each document once: a second pass can return ok while turning the directives into text.
Stop `0.1.0` writing first, and record which documents are converted. Stop `0.1.0` writing first, and record which documents are converted.
- A refusal carrying `position` is `0.1.0`'s parse, which refused that markdown before too. One - A refusal carrying `position` is `0.1.0`'s parse, which refused that markdown before too. One
without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF rather than keeping without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF, with
the unconverted markdown. `content: parsed.value.content ?? []` as the recipe gives it, rather than keeping the unconverted
markdown.
### Spellings ### Spellings
@@ -56,16 +58,24 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
| --- | --- | --- | | --- | --- | --- |
| a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them | | a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them |
| markdown holding no block (`markdownToAdf("")`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former | | markdown holding no block (`markdownToAdf("")`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former |
| a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | the block carry; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block | | a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | the block carry, refusing a body that is not one node's canonical JSON; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block |
### Error codes ### Error codes
`unspellable-link` leaves `ConvertErrorCode`: a `switch` naming it stops compiling, and the link `unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode`:
it named converts. a `switch` naming one stops compiling. A document `adfToMarkdown` refused with one of them now
converts; markdown the parser refused with one of them now refuses with the code the table gives.
| Input | `0.1.0` | `0.2.0` | | Input | `0.1.0` | `0.2.0` |
| --- | --- | --- | | --- | --- | --- |
| a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` | | a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` |
| a carriage return in text, on emit | `unspellable-character` | spells `&#13;` |
| a null character in text, on emit | `unspellable-character` | spells `!adf:text{text="\u0000"}` |
| a carriage return or null character in a code block, or a null character in a code span, on emit | `unspellable-character` | rides the carry |
| a paragraph line opening with a code span of three or more backticks, on emit | `unspellable-line-start` | spells the code span |
| a block directive whose CommonMark spelling holds a carriage return, a null character or a code span opening a line, on parse | `unspellable-character`, `unspellable-line-start` | `unsupported-node-shape`; write the CommonMark spelling |
| an `emoji`, `mention` or `status` whose `text` holds a line ending or a null character, on emit | `unspellable-whitespace`, `unspellable-character` | rides the inline carry |
| a text node holding no text, or holding `content`, on emit | `unsupported-node-shape` | rides the carry |
| a `codeBlock` holding other than plain text nodes, on emit | `unsupported-node-shape` | rides the block carry | | a `codeBlock` holding other than plain text nodes, on emit | `unsupported-node-shape` | rides the block carry |
| a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` | | a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` |
| a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` | | a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` |
+30 -33
View File
@@ -28,13 +28,13 @@ The most useful ADF conversion library available, judged by these goals, in prio
1. **Lossless, and every call returns a result, never a throw.** 1. **Lossless, and every call returns a result, never a throw.**
2. **ADF is the hub.** 2. **ADF is the hub.**
3. **Each format reads and writes as its standard says.** 3. **Easy to find, and clear at a glance what it does.**
4. **Our markdown is CommonMark, extended only where CommonMark has no spelling.** 4. **Each format reads and writes as its standard says.**
5. **No surprises: output reads and edits the way its audience expects.** 5. **Our markdown is CommonMark, extended only where CommonMark has no spelling.**
6. **Lossy conversion drops form, never content.** 6. **No surprises: output reads and edits the way its audience expects.**
7. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.** 7. **Lossy conversion drops form, never content.**
8. **Fast, and linear in the document's size.** 8. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.**
9. **Easy to find, and clear at a glance what it does.** 9. **Fast, and linear in the document's size as JSON.**
## Audience ## Audience
@@ -74,7 +74,7 @@ if (result.ok) {
} }
``` ```
Serves Goals 1, 2 and 7. Pure functions, each taking a whole document and returning a whole Serves Goals 1, 2 and 8. Pure functions, each taking a whole document and returning a whole
result; no I/O, no configuration. `markdownToHtml` and `htmlToMarkdown` convert through ADF: 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. they keep only what ADF holds, and refuse what `markdownToAdf` or `htmlToAdf` refuses.
@@ -96,7 +96,7 @@ htmlToMarkdown(html: string): Result<string> // 0.2.0
## Plain markdown ## Plain markdown
Serves Goal 6. Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` writes Serves Goal 7. 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 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 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 document sees or follows: its text, images and link targets. It refuses only
@@ -136,6 +136,7 @@ read replaces mentions, attachments and macros with text.
- A newline in text is a hard break and in an expand's title a space, edge whitespace outside a - 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 link or code span is trimmed, carriage returns and null characters are removed, and an empty
paragraph drops. paragraph drops.
- A text node's `content` drops: Atlassian's schema forbids it, so no rendered document shows it.
- An ordered list numbered past `999999999`, or adjacent ordered lists whose numbering does not - An ordered list numbered past `999999999`, or adjacent ordered lists whose numbering does not
continue, is one bullet list keeping its numbers as text. 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 - A task list beside a bullet or decision list, or holding a block other than a task, joins one
@@ -175,17 +176,18 @@ 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 an opaque carry | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text | | `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in an opaque carry | write the spelling the message names, or keep it literal: escape the prefix — `\!adf:`, block and inline alike — or, for a code fence whose info string opens `adf:`, drop the info string and wrap the fence in `!adf:codeBlock {language="adf:…"}` |
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way | | `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning | | `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
| `unmappable-html` | the input holds an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the lossless flavour | | `unmappable-html` | the input holds an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the lossless flavour |
| `unmappable-image` | an image sits inside other content that is not another image's description, or carries a title | give the image a paragraph of its own and drop the title | | `unmappable-image` | an image sits inside other content that is not another image's description, or carries a title | give the image a paragraph of its own and drop the title |
| `unspellable-whitespace` | an `emoji`, `mention` or `status` directive's content slot spells a newline or a carriage return — `&#10;`, `&#13;`, `!adf:text{text="\n"}` | replace it with a space, or write the node as `!adf:carry` — an inline directive never spans lines |
Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`: Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`:
| Code | Fires when | What you can do | | Code | Fires when | What you can do |
| --- | --- | --- | | --- | --- | --- |
| `not-an-adf-document` | the value handed in is no ADF document — a missing or wrong `type`, a stray key, a node that is not a node | guard the boundary you receive JSON at with `isAdfDocument`; the message names the branch that refused | | `not-an-adf-document` | the value handed in is no ADF document — a missing or wrong `type`, a stray key, a node that is not a node, an object in the document holding itself | guard the boundary you receive JSON at with `isAdfDocument`; the message names the branch that refused |
| `unsupported-document-version` | the document's `version` is not 1 | keep the ADF and pass the document over, or show it read-only; the version is the site's, not yours to change | | `unsupported-document-version` | the document's `version` is not 1 | keep the ADF and pass the document over, or show it read-only; the version is the site's, not yours to change |
Either direction. The emitter's own refusals are in this group — a parse reaches them by asking it Either direction. The emitter's own refusals are in this group — a parse reaches them by asking it
@@ -194,31 +196,27 @@ emit refuses:
| Code | Fires when | What you can do | | Code | Fires when | What you can do |
| --- | --- | --- | | --- | --- | --- |
| `unspellable-character` | text or a code block holds a carriage return or a null character, which CommonMark rewrites wherever it sits | strip or replace the character; no escape carries it through the round-trip |
| `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span |
| `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines |
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | | `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body | | `unsupported-node-shape` | parsing: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node carrying one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content` | fix what the message names; `spec/flavour.md` lists every type's attributes and body |
## The guarantees ## The guarantees
Serves Goals 1, 3 and 4. Serves Goals 1, 4 and 5.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` — every key and value as `doc` holds it, - `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as JSON, for a document of plain objects
adjacent text nodes, an empty `attrs`, `content` or `marks` and `-0` included, and unknown node as `JSON.parse` builds them — every key and value as `doc` holds it, adjacent text nodes, an
types carried opaquely empty `attrs`, `content` or `marks` and `-0` included, and unknown node types carried opaquely
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)). ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
- Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from - Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from
`0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below `0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
name every exception. name every exception.
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html` - Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
names, with three carve-outs — literal text matching directive, pipe-table or strikethrough names, with four carve-outs — literal text matching directive, pipe-table or strikethrough syntax,
syntax is claimed (escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as and a code fence whose info string opens `adf:`, are claimed (each can be kept literal —
its own title-less paragraph; mid-text and titled images are error results, save an image inside `spec/flavour.md`) — and one gap: a CommonMark image fits only as its own title-less paragraph;
another's description, which flattens into the alt text. Converting back yields the library's mid-text and titled images are error results, save an image inside another's description, which
canonical spelling, which round-trips byte-identically — where it converts back at all: a parse flattens into the alt text. Converting back yields the library's canonical spelling, which
succeeding is no promise of that, so keep the source until the way back succeeds. round-trips byte-identically.
``` ` `` ` ``` reads cleanly and then refuses.
- Four CommonMark spellings parse without an error and build a document the reference - Four CommonMark spellings parse without an error and build a document the reference
implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's
empty link, a list continuing past a marker change stays one list against CommonMark's two, a empty link, a list continuing past a marker change stays one list against CommonMark's two, a
@@ -227,18 +225,17 @@ Serves Goals 1, 3 and 4.
text, which the spec requires and the reference itself breaks, nesting one `<a>` in the other. text, which the spec requires and the reference itself breaks, nesting one `<a>` in the other.
The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite
holds no example of the fourth. 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` refuses a document nesting past 500 levels, and
a carriage return, or a paragraph line beginning with a code span whose backticks read back as a the node shapes the `unsupported-node-shape` row lists. Show the refusal and keep the document
fence. Show the refusal and keep the document read-only; saving markdown you could not produce read-only; saving markdown you could not produce is the loss the round-trip exists to stop.
is the loss the round-trip exists to stop.
- The pipe table narrows GFM's twice: every row opens with a pipe, so GFM's bare form is an error - The pipe table narrows GFM's twice: every row opens with a pipe, so GFM's bare form is an error
result rather than the prose it reads as, and an alignment colon in the delimiter row is an result rather than the prose it reads as, and an alignment colon in the delimiter row is an
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 — `plainMarkdownToAdf` turns the marker into a `taskList`. is the `taskList` directive — `plainMarkdownToAdf` turns the marker into a `taskList`.
- A document nested deeper than 500 levels is an error result, not a stack overflow, and no input - A document nested deeper than 500 levels is an error result, not a stack overflow, and no call
makes a call loop forever. loops forever on a string, or on a value `JSON.parse` or `structuredClone` builds.
- The emitted formats are semver surface - 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)). ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#the-formats-are-api)).
- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` deep-equals `doc`; fidelity HTML cannot express rides - **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` deep-equals `doc`; fidelity HTML cannot express rides
@@ -248,7 +245,7 @@ Serves Goals 1, 3 and 4.
## The package ## The package
Serves Goal 7. ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts` Serves Goal 8. ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts`
beside it. Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs beside it. Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs
under Node, Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint. under Node, Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
Contract: [`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#any-es2022-engine), §Any Contract: [`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#any-es2022-engine), §Any
+2 -3
View File
@@ -1,8 +1,7 @@
try { try {
const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js') const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js')
const { serializeCanonicalJson } = await import('/dist/canonical-json.js') // WebDriver's JSON reads -0 back as 0, so a document crosses as JSON text with -0 tagged; run.js revives it.
// WebDriver's JSON reads -0 back as 0, so a document crosses as its canonical spelling. const spelled = (result) => (result.ok ? { ok: true, value: JSON.stringify(result.value, (_, value) => (Object.is(value, -0) ? '\u0000-0' : value)) } : result)
const spelled = (result) => (result.ok ? { ok: true, value: `${serializeCanonicalJson(result.value, 'two-space')}\n` } : result)
window.convertCorpus = (corpus) => ({ window.convertCorpus = (corpus) => ({
errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })), errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: spelled(markdownToAdf(markdown)) })), normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: spelled(markdownToAdf(markdown)) })),
+8 -3
View File
@@ -49,6 +49,11 @@ function fixtureNames(kind, extension) {
.sort() .sort()
} }
// convert-corpus.js tags -0 so it survives WebDriver's JSON.
function revived(text) {
return JSON.parse(text, (_, value) => (value === '\u0000-0' ? -0 : value))
}
function refusal(result) { function refusal(result) {
return result.ok ? '' : `${result.error.code}: ${result.error.message}` return result.ok ? '' : `${result.error.code}: ${result.error.message}`
} }
@@ -106,7 +111,7 @@ for (const [index, { json, markdown, name }] of corpus.roundTrip.entries()) {
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`) assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
assert.equal(result.emitted.value, markdown) assert.equal(result.emitted.value, markdown)
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`) assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.equal(result.parsed.value, json) assert.deepEqual(revived(result.parsed.value), JSON.parse(json))
}) })
} }
@@ -114,7 +119,7 @@ for (const [index, { name }] of corpus.normalization.entries()) {
const result = results.normalization[index] const result = results.normalization[index]
checking(name, result, () => { checking(name, result, () => {
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`) assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.equal(result.parsed.value, fixture(name, '.json')) assert.deepEqual(revived(result.parsed.value), JSON.parse(fixture(name, '.json')))
}) })
} }
@@ -124,7 +129,7 @@ for (const [index, { json, name }] of corpus.realPayloads.entries()) {
assert.ok(result.isDocument, `${name}.json is no ADF document`) assert.ok(result.isDocument, `${name}.json is no ADF document`)
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`) assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
assert.ok(result.parsed.ok, `it did not parse back — ${refusal(result.parsed)}`) assert.ok(result.parsed.ok, `it did not parse back — ${refusal(result.parsed)}`)
assert.equal(result.parsed.value, json) assert.deepEqual(revived(result.parsed.value), JSON.parse(json))
}) })
} }
+6 -5
View File
@@ -16,10 +16,11 @@ One directory per contract kind:
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). model) or `pending` (a parser gap).
JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's
(`docs/decisions.md` §Equality is deep). `spec.json` is the vendored, upstream machine-readable suite, byte-exact from (`docs/decisions.md` §Equality is deep). `spec.json` is the vendored, upstream machine-readable
[spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John suite, byte-exact from [spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json)
MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not (CommonMark 0.31.2, © John MacFarlane,
re-serialized by the corpus gate. [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not re-serialized by the
corpus gate.
-21
View File
@@ -13,27 +13,6 @@
"kind": "pending", "kind": "pending",
"reason": "A list continuing past a marker change renders as two lists, the parser opens one." "reason": "A list continuing past a marker change renders as two lists, the parser opens one."
}, },
{
"check": "fixpoint",
"divergence": "unspellable-line-start",
"example": 330,
"kind": "unspellable",
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
},
{
"check": "fixpoint",
"divergence": "unspellable-line-start",
"example": 331,
"kind": "unspellable",
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
},
{
"check": "fixpoint",
"divergence": "unspellable-line-start",
"example": 340,
"kind": "unspellable",
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
},
{ {
"check": "count", "check": "count",
"divergence": "em 2/1", "divergence": "em 2/1",
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,8 @@
!adf:codeBlock {attrs=empty}
```js
a
```
```js
b
```
!adf:/codeBlock
@@ -0,0 +1 @@
unsupported-node-shape
+7
View File
@@ -0,0 +1,7 @@
!adf:codeBlock
```
a
```
```
```
!adf:/codeBlock
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1 @@
!adf:status[!adf:carry{json="{\"marks\":[],\"text\":\"x\",\"type\":\"text\"}"}]
@@ -0,0 +1,15 @@
{
"content": [
{
"content": [
{
"text": "a\r\nb",
"type": "text"
}
],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,10 @@
```adf:codeBlock
{
"content": [
{
"text": "a\r\nb",
"type": "text"
}
]
}
```
@@ -82,6 +82,13 @@
}, },
"marks": [], "marks": [],
"type": "codeBlock" "type": "codeBlock"
},
{
"attrs": {
"language": "js"
},
"content": [],
"type": "codeBlock"
} }
], ],
"type": "doc", "type": "doc",
@@ -30,3 +30,6 @@ Next
```js ```js
``` ```
!adf:/codeBlock !adf:/codeBlock
!adf:codeBlock {content=empty language=js}
!adf:/codeBlock
@@ -0,0 +1,20 @@
{
"content": [
{
"content": [
{
"marks": [
{
"type": "code"
}
],
"text": "``",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
``` `` ```
@@ -0,0 +1,43 @@
{
"content": [
{
"content": [
{
"text": "a\r\nb\u0000c",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/",
"title": "t\u0000"
},
"type": "link"
}
],
"text": "link",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"attrs": {
"color": "neutral",
"text": "a\nb"
},
"type": "status"
},
{
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,3 @@
a&#13;!adf:text{text="\n"}b!adf:text{text="\u0000"}c!adf:link[link]{href="https://example.com/" title="t\u0000"}
!adf:carry{json="{\"attrs\":{\"color\":\"neutral\",\"text\":\"a\\nb\"},\"type\":\"status\"}"}!adf:carry{json="{\"type\":\"text\"}"}
+128 -106
View File
@@ -23,73 +23,62 @@ sanitized and a mention keeps the test user's real account id.
## Markdown in is a canonical fixpoint ## Markdown in is a canonical fixpoint
2026-08-23, the maintainer. Goals 1 and 4. Valid while markdown input may be written by hand. 2026-08-23, the maintainer; every parsed document has converted back since 2026-10-04. Goals 1 and
5. Valid while markdown input may be written by hand.
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
back yields the library's canonical spelling, and that spelling round-trips byte-identically — back yields the library's canonical spelling, and that spelling round-trips byte-identically.
where there is a way back. CommonMark spells some things the flavour has no escape for — a
paragraph opening with a code span whose backticks read back as a fence — so a parse succeeding
does not imply a spellable document; `corpus/commonmark-spec/exceptions.json` names those.
## Equality is deep ## Equality is deep
2026-08-24, deep 2026-10-03, the maintainer. Goal 1. Valid while a pipeline or a bot can build a 2026-08-24, deep 2026-10-03, the maintainer. Goal 1. Valid while a pipeline or a bot can build a
shape the editor would not. shape the editor would not.
"Equals" is `assert.deepStrictEqual`: every key and value `doc` holds, two adjacent text nodes, an "Equals" is deep equality over the document's JSON values: `assert.deepStrictEqual` on plain objects
empty `attrs`, `content` or `marks` and `-0` included, with no normalization on either side. as `JSON.parse` builds them, since ADF is JSON. Every key and value in `doc` counts, including two
CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader adjacent text nodes, an empty `attrs`, `content` or `marks`, and `-0`. Neither side is normalized.
builds what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader builds
reads and writes editor-normal ADF — adjacent text nodes of identical marks and no attributes what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction reads and
merged, `-0` as `0`, and an empty `attrs`, `content` or `marks` the absent key — so two documents writes editor-normal ADF — adjacent text nodes of identical marks and no attributes merged, `-0` as
the editor holds equal write the same plain markdown. `0`, and an empty `attrs`, `content` or `marks` the absent key, except the doc's `content`, which
ADF's schema requires — so two documents the editor holds equal write the same plain markdown.
## `!adf:textBreak{}` parts text CommonMark would join ## `!adf:textBreak{}` parts text CommonMark would join
2026-10-03, the maintainer. Goal 1. Valid while CommonMark reads adjacent text as one run. 2026-10-03, the maintainer. Goal 1. Valid while CommonMark reads adjacent text as one run.
Two adjacent text nodes a reader would build back as one — neither carried, neither holding Two adjacent text nodes CommonMark would read back as one are parted by the reserved inline leaf
`attrs` or an empty key, their marks identical, attributes included — are parted by the reserved `!adf:textBreak{}`, mirroring `!adf:listBreak`: a leaf building no node keeps both nodes and asks
inline leaf `!adf:textBreak{}`, mirroring `!adf:listBreak`: it builds no node, and anywhere but nothing of the text around it. A code span holds no directive, so the spans close and reopen
between two such nodes, or given `[content]` or `{attrs}`, it is `unsupported-node-shape`. It sits around the leaf. The grammar: `spec/flavour.md` §Inline nodes, **Adjacent text nodes**.
inside every mark spelling the pair shares, `**Hello, !adf:textBreak{}world**`; a code span holds
no directive, so it closes and reopens, `` `a`!adf:textBreak{}`b` ``. A carried node never joins
its neighbour, so a carry needs no break. A mark run breaks on any difference in the mark, `attrs:
{}` against no `attrs` included.
## An empty key spells `empty` ## An empty key spells `empty`
2026-10-03, a writer panel and the maintainer. Goals 1 and 5. Valid while no attribute value 2026-10-03, a writer panel and the maintainer. Goals 1 and 6. Valid while no attribute value
spells an empty object or array. spells an empty object or array.
An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the
bare value `empty` on every directive — block, inline node and mark: `{attrs=empty}`, bare value `empty`, so a container opener and closer with nothing between them stays the node
`{content=empty}`, `{marks=empty}`. A writer panel chose the spelling, 5 of 7. A container holding no `content` key. A writer panel chose the spelling, 5 of 7. The grammar: `spec/flavour.md`
spelling `content=empty` closes with no body, so an empty pair stays the node holding no content §Directives, **Attributes**, and §Marks.
key; a leaf spells it too, `!adf:rule {content=empty}`. A node CommonMark spells takes its
directive form to hold an empty key, and a text node holding one rides the inline carry, as does a
node under an `em`, `strong`, `strike`, `code` or `link` mark whose `attrs` is empty, since none of
those spellings holds attributes. Any other value of a reserved key is `unsupported-node-shape`,
except on a block's `marks`, which reads it as the marks array in JSON.
## `-0` is spelled `-0` ## `-0` is spelled `-0`
2026-10-03, the maintainer. Goal 1. Valid while JSON's own serialization writes `-0` as `0`. 2026-10-03, the maintainer. Goal 1. Valid while JSON's own serialization writes `-0` as `0`.
Wherever the flavour writes a number or a JSON value — an attribute, a `json` value, the carry — `-0` is spelled `-0` wherever the flavour writes a number or a JSON value, since JSON's grammar
`-0` is `-0`, which JSON's grammar reads back as `-0`. An `orderedList` whose `order` is `-0` takes reads it back as `-0`. The grammar: `spec/flavour.md` §Block nodes and §The CommonMark blocks.
the directive form, since no list marker spells the sign.
## Empty markdown is a document of no blocks ## Empty markdown is a document of no blocks
2026-10-03, a panel and the maintainer. Goals 1 and 5. Valid while ADF's schema requires 2026-10-03, a writer panel and the maintainer. Goals 1 and 6. Valid while ADF's schema requires
`content` on `doc`. `content` on `doc`.
Markdown holding no block reads as `{ content: [], type: 'doc', version: 1 }`, the document Markdown holding no block reads as `{ content: [], type: 'doc', version: 1 }`, the document
`spec/adf-schema/full.json` requires. A document holding no `content` key is `spec/adf-schema/full.json` requires. A document holding no `content` key is
`!adf:doc {content=none}` standing alone as its only block, and a named error anywhere else. The `!adf:doc {content=none}` as its only block, and a named error anywhere else. The writer panel split
writer panel split 4 for `none` and 3 for `absent`, and the maintainer chose `none`; all seven rejected a 4 for `none` and 3 for `absent`, and the maintainer chose `none`; all seven rejected a bare
bare `!adf:doc`. `!adf:doc`.
## Unknown nodes ride the carry ## Unknown nodes ride the carry
@@ -105,15 +94,17 @@ checking its position, and refusing loses a document ADF itself keeps in an `uns
## The carry fence names the node type ## The carry fence names the node type
2026-10-03, the maintainer. Goals 1 and 5. Valid while a code fence's info string reads back 2026-10-03, the maintainer. Goals 1 and 6. Valid while a code fence's info string reads back
verbatim. verbatim.
The block carry is a code fence whose info string `adf:<type>` names the node's type, its body the The block carry is a code fence whose info string `adf:<type>` names the node's type, its body the
node's JSON without `type`: ```` ```adf:blockCard ````. A type no info string carries back — by the node's JSON without `type`: ```` ```adf:blockCard ````. A type no info string carries back — by the
rule a code language follows — leaves the info string `adf:` and keeps `type` in the body. Every rule a code language follows — leaves the info string `adf:` and keeps `type` in the body. Every
info string opening `adf:` is reserved, so a `codeBlock` whose language opens so takes the info string opening `adf:` is reserved, so a `codeBlock` whose language opens so takes the
`language` attribute, and `carry` is an ordinary language. A body holding `type` under a named `language` attribute, and `carry` is an ordinary language. A body holding `type` under a named type,
type, or an `adf:` fence whose type an info string carries, is `unsupported-node-shape`. or a fence whose info string is `adf:` alone while its body's `type` could be spelled in the info
string, is `unsupported-node-shape`. The reservation claims a fence CommonMark reads as code until
`todo.md` item 43 gives CommonMark its own reader.
## A code block is a fence per text node ## A code block is a fence per text node
@@ -121,15 +112,13 @@ type, or an `adf:` fence whose type an info string carries, is `unsupported-node
node. node.
A `codeBlock` holding several text nodes is the `!adf:codeBlock` container holding one fence per A `codeBlock` holding several text nodes is the `!adf:codeBlock` container holding one fence per
node, each fence's info string the language; its other attributes sit on the opener, and a node, so each node keeps its own text. The fences carry one info string, since ADF holds one
language no info string carries stays the opener's `language` with bare fences. A `codeBlock` language, and none is empty beside another, since a text node holds text. The grammar:
spelling `content=empty` has no fence to carry the language, so the opener does. Fences with `spec/flavour.md` §The CommonMark blocks, the `codeBlock` bullet.
differing info strings are `unsupported-node-shape` — ADF holds one language — and so is an empty
fence beside another, since a text node holds text.
## Foreign HTML sorts three ways ## 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 2026-08-23, the sort 2026-09-20, the maintainer. Goals 1 and 5. Valid while ADF holds no node
for a bare container, a comment or a script. Lands with `todo.md` item 6. 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 Every foreign element `htmlToAdf` and `markdownToAdf` read sorts one of three ways, never a silent
@@ -150,14 +139,14 @@ than they buy.
## Names stay text ## Names stay text
2026-08-23, the maintainer. Goal 7. Valid while resolving a name to an id needs I/O. 2026-08-23, the maintainer. Goal 8. 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 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. mention/emoji/media nodes; resolving names to ids is the consumer's job.
## Directives under `!adf:` ## Directives under `!adf:`
2026-08-23, prefixed `!adf:` 2026-09-16, the maintainer. Goals 4 and 5. Valid while prose does not 2026-08-23, prefixed `!adf:` 2026-09-16, the maintainer. Goals 5 and 6. Valid while prose does not
write `!adf:`. write `!adf:`.
Directives are one grammar for everything markdown lacks, namespaced under `!adf:`: Directives are one grammar for everything markdown lacks, namespaced under `!adf:`:
@@ -168,21 +157,23 @@ opener nests by itself and leaf versus container falls out of the node's content
## CommonMark is a subset ## CommonMark is a subset
2026-08-23, the maintainer. Goal 4. Valid while prose rarely writes the shapes the carve-outs claim. 2026-08-23, the maintainer. Goal 5. 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 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. directive, a pipe table or a `~~` pair is claimed, and so is a code fence whose info string opens
`adf:` (§The carry fence names the node type) — plus one image gap. `todo.md` item 43 ends the
claims, giving CommonMark its own reader, and `todo.md` item 65 ends the image gap.
## Tables ## Tables
2026-08-23, the maintainer. Goal 5. Valid while a pipe table holds only one header row and inline 2026-08-23, the maintainer. Goal 6. Valid while a pipe table holds only one header row and inline
cells. cells.
One header row plus plain inline cells → pipe table; anything richer → directive form. One header row plus plain inline cells → pipe table; anything richer → directive form.
## Links ## Links
2026-09-13, nesting 2026-09-17, the maintainer. Goals 1 and 5. Valid while CommonMark's link 2026-09-13, nesting 2026-09-17, the maintainer. Goals 1 and 6. Valid while CommonMark's link
syntax is what readers edit. syntax is what readers edit.
`[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark spells the `[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark spells the
@@ -201,7 +192,7 @@ accepted.
## Plain task ids come from position ## 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 2026-09-26, spelling 2026-09-29, the maintainer. Goals 7 and 8. Valid while a site rejects a task
node with no `localId`. node with no `localId`.
`plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId` `plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId`
@@ -213,7 +204,7 @@ ids are only skipped.
## A callout title keeps its link targets ## 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. 2026-09-29, the maintainer. Goals 6 and 7. 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 `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 parentheses: `> [!faq]- See [x](http://y)` reads to the title `See x (http://y)`. A link whose text
@@ -222,7 +213,7 @@ is its target, with or without `mailto:`, keeps its text alone: `<http://y>` tit
## The plain flavour's spellings ## 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 2026-09-14, panels 2026-09-25 and 2026-09-29, the maintainer. Goals 6 and 7. Valid while GitHub's
renderer is the one the audience's markdown is read in. 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, README §Plain markdown's rows come from a survey of GitHub, GitLab, Gitea, Obsidian, Pandoc,
@@ -241,7 +232,7 @@ past `[x]`/`[ ]`, and lifting bare URLs, `@name`, `:shortcode:` or ISO dates int
## The HTML dialect ## The HTML dialect
2026-08-23, the maintainer. Goals 5 and 7. Valid while HTML output is read by consumers styling it 2026-08-23, the maintainer. Goals 6 and 8. Valid while HTML output is read by consumers styling it
themselves. themselves.
The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*` The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*`
@@ -249,7 +240,7 @@ for what HTML cannot express, text always escaped. No stylesheet ships.
## No runtime dependencies ## No runtime dependencies
2026-08-23, the maintainer. Goal 7. Valid while ~20 lines of own code, or a vendored table, do each 2026-08-23, the maintainer. Goal 8. Valid while ~20 lines of own code, or a vendored table, do each
job a dependency would. job a dependency would.
`dependencies` is empty. A runtime dependency enters only through an entry here stating why ~20 `dependencies` is empty. A runtime dependency enters only through an entry here stating why ~20
@@ -259,7 +250,7 @@ and HTML parsers are written in this repo.
## Standards ship as data ## Standards ship as data
2026-08-30, the CommonMark suite 2026-09-05 and ADF's schemas 2026-09-13, the maintainer. Goals 1, 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. 5 and 8. 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 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. character references ship packed in their own module, so entity decoding is complete without one.
@@ -282,7 +273,7 @@ nodes that break it.
## Any ES2022 engine ## Any ES2022 engine
2026-09-01, the maintainer. Goal 7. Valid while ES2022 is the floor browsers and servers share. 2026-09-01, the maintainer. Goal 8. 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 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. shipped source is ECMAScript and nothing else: no host import, no host global, no DOM.
@@ -296,13 +287,13 @@ never the higher one those repo-only tools want.
## ESM only ## ESM only
2026-08-23, the maintainer. Goal 7. Valid while the audience's toolchains all import ES modules. 2026-08-23, the maintainer. Goal 8. Valid while the audience's toolchains all import ES modules.
No CommonJS build, no dual-package hazard. No CommonJS build, no dual-package hazard.
## One built entrypoint ## One built entrypoint
2026-08-23, the maintainer. Goal 7. Valid while Node refuses to type-strip under `node_modules`. 2026-08-23, the maintainer. Goal 8. 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 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 type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so it cannot serve
@@ -310,7 +301,7 @@ an npm consumer.
## Public on npm ## Public on npm
2026-08-23, the name 2026-09-01, the maintainer. Goals 2 and 7. Valid while the package's source 2026-08-23, the name 2026-09-01, the maintainer. Goals 2 and 8. Valid while the package's source
stays public beside it. stays public beside it.
Published to public npm as `@larvit/adf-codec`. Public source: the Gitea repo goes public, Published to public npm as `@larvit/adf-codec`. Public source: the Gitea repo goes public,
@@ -345,8 +336,9 @@ switches on `code` with no `default`.
`unsupported-node-shape` — since a code no input reaches is one no consumer can switch on `unsupported-node-shape` — since a code no input reaches is one no consumer can switch on
(2026-09-20). (2026-09-20).
- A refusal no spelling recovers from is a gap in the flavour rather than a code: give the flavour - 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 the spelling and the code goes (`unspellable-link`, 2026-09-13; `unspellable-character` and
no code: a mark no spelling writes rides the carry with its node. `unspellable-line-start`, 2026-10-04). A cause the carry answers gets no code: a mark no
spelling writes rides the carry with its node.
## Which code a cause takes ## Which code a cause takes
@@ -355,25 +347,24 @@ 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 - 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 — 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, `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 `unspellable-whitespace` is the newline an inline directive's content slot spells in input; the
line between them is what they name: `unspellable-character` is a character CommonMark rewrites emitter carries such a node. A null character in that slot is `unsupported-node-shape`, since
wherever text holds it, `unspellable-whitespace` the newline no inline directive's content slot `unspellable-whitespace` does not read true of it (2026-10-04).
spans, in either direction.
- A claim code names the spelling claimed, never the node that spelling would have built: a - 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 `!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 `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 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 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). missing its closer is (2026-09-16).
- A directive whose name reads back to no node is `unknown-directive-name` rather than a claim - A directive whose name reads back to no node is `unknown-directive-name` rather than a claim code
code — the spelling is well formed, and telling that apart from a typo is what a consumer — the spelling is well formed, and telling that apart from a typo is what a consumer switches on
switches on when a later MINOR gives the name meaning. A reserved name is a known name, so never when a later MINOR gives the name meaning. A reserved name is a known name, so never that code,
that code, and the names the flavour reserves part on form: a form the grammar does not have is and the names the flavour reserves part on form: a form the grammar does not have is a claim code
a claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place is
is `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one type,
type, `!adf:textBreak{}` anything but two text nodes a reader joins, `!adf:doc` standing beside `!adf:textBreak{}` anything but two adjacent text nodes CommonMark would read back as one,
another block (2026-09-01, the text break and `doc` 2026-10-03). `!adf:doc` standing beside another block (2026-09-01, the text break and `doc` 2026-10-03).
- A well-formed directive the node tables refuse — an attribute a node does not hold or spells - 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 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 its content model does not take — is `unsupported-node-shape`, the emitter's code for the same
@@ -384,21 +375,21 @@ handles one cause alike whichever node, attribute or direction raised it.
## `message` and `path` ## `message` and `path`
2026-09-03, the path 2026-09-23, the maintainer. Goals 1 and 4. Valid while a person fixing the 2026-09-03, the path 2026-09-23, the maintainer. Goals 1 and 5. Valid while a person fixing the
input reads `message`. input reads `message`.
- A message names the violation, not the rule alone — a rule by itself states a truth the reader - 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 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 it names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and
inline alike, `\|` for every pipe row. inline alike, `\|` for every pipe row.
- `not-an-adf-document` carries the document's own path throughout: seven of the guard's eight - `not-an-adf-document` carries the document's own path throughout: seven of the guard's nine
branches read the document's own shape, and threading a path to the eighth — a malformed node branches read the document's own shape, and threading a path to the other two, which look anywhere
anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces. The message in the tree — a malformed node, or an object holding itself — wants the manual stack §Nothing
names the violation instead. recurses unbounded forces. The message names the violation instead.
## Publish on a version bump ## Publish on a version bump
2026-08-23, converging 2026-09-03, the maintainer. Goal 7. Valid while CI on `main` holds the npm 2026-08-23, converging 2026-09-03, the maintainer. Goal 8. Valid while CI on `main` holds the npm
token. token.
`package.json` version on `main` is the source of truth. CI on `main`: tests green and the version `package.json` version on `main` is the source of truth. CI on `main`: tests green and the version
@@ -414,7 +405,7 @@ depend on a store that the gate would then have to keep.
## Docs describe the release being built ## Docs describe the release being built
2026-09-16, the maintainer. Goal 7. Valid while a bump on `main` publishes. 2026-09-16, the maintainer. Goal 8. 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 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 moment the bump publishes; add no interim note marking the gap.
@@ -430,7 +421,7 @@ nesting a spelling inside its own kind (`*(*a*)*`) names that mark once.
## The gate runs on Deno and Bun ## 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 2026-09-01, Deno's reason 2026-09-28, the maintainer. Goals 4 and 8. Valid while the library claims
any ES2022 engine. any ES2022 engine.
The gate runs the suite under Deno and Bun as well as Node. Bun runs JavaScriptCore, the one engine The gate runs the suite under Deno and Bun as well as Node. Bun runs JavaScriptCore, the one engine
@@ -442,7 +433,7 @@ over the corpus rather than over a smoke import.
## The gate installs the tarball ## The gate installs the tarball
2026-09-03, the maintainer. Goal 7. Valid while consumers install the packed package. 2026-09-03, the maintainer. Goal 8. Valid while consumers install the packed package.
The gate packs the build and installs the tarball under `package-tests/`, so `files`, `exports` 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 and `types` are proved on the artifact that ships rather than on the source tree a self-reference
@@ -454,7 +445,7 @@ resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` s
## Firefox reads the build ## Firefox reads the build
2026-09-04, the maintainer. Goal 7. Valid while the library claims a browser and no other leg runs 2026-09-04, the maintainer. Goal 8. Valid while the library claims a browser and no other leg runs
SpiderMonkey. SpiderMonkey.
A headless Firefox loads `dist/index.js` over HTTP and converts the round-trip, normalization and A headless Firefox loads `dist/index.js` over HTTP and converts the round-trip, normalization and
@@ -497,6 +488,26 @@ config gone missing fails the leg instead of falling back to oxlint's own defaul
`--deny-warnings`, since a rule from a category this config never names arrives as a warning it `--deny-warnings`, since a rule from a category this config never names arrives as a warning it
exits 0 on. exits 0 on.
## The project ships under the comprehension floor until items 59, 60, 61 and 62 land
2026-10-03, the maintainer. KISS, a technical principle, and its comprehension floor of 7. Valid
while `todo.md` items 59, 60, 61 and 62 are open.
A four-seat comprehension panel scored the project under the floor of 7. Its scoring run of
2026-10-04 is the baseline. Since 2026-10-04, a chunk whose panel scores any dimension lower
improves what the seats named and scores again, and after three such rounds asks the maintainer.
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior | 7 | 5.5 | 6.5 | 6 | 6 |
| Mid | 7 | 5.5 | 6.5 | 6 | 6 |
| Senior | 7 | 5.5 | 6.5 | 5.5 | 6 |
| Architect | 7 | 5.5 | 6 | 6 | 6 |
| Mean | 7.00 | 5.50 | 6.38 | 5.88 | 6.00 |
Three panels on near-identical code scored overall means of 5.88, 5.75 and 5.75, and a seat moves
±0.5 between runs.
## Properties on a fixed seed ## Properties on a fixed seed
2026-09-14, the maintainer. Goal 1. Valid while a red gate must reproduce. 2026-09-14, the maintainer. Goal 1. Valid while a red gate must reproduce.
@@ -506,15 +517,15 @@ markdown, on a fixed seed in the gate; a counterexample found becomes a round-tr
## The CommonMark suite checks three ways ## 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 2026-08-27, the maintainer. Goals 1 and 4. Valid while the suite's answers are HTML ADF cannot be
compared against. compared against.
Each example is a named error or markdown that parses and emits to itself byte for byte; its 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 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 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 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 add, and valid CommonMark parsing to a document `adfToMarkdown` refuses is a bug to fix, never an
is a bug to fix, never an exception. exception.
## The flavour spec is read as a source ## The flavour spec is read as a source
@@ -551,6 +562,15 @@ format, as its parser does. A list giving way to the directive form refuses at z
than walking again; counting every list twice halved the list limit, counting the directive form than walking again; counting every list twice halved the list limit, counting the directive form
once doubled the parser's frames per level. once doubled the parser's frames per level.
## No value `JSON.parse` or `structuredClone` builds loops a call
2026-10-04, the maintainer. Goals 1 and 9. Valid while every persona hands in plain data, as
`JSON.parse` or `structuredClone` builds it.
The guards walk a value by `Object.values` and refuse a cycle in it. An inherited or non-enumerable
key, a getter, a Proxy or a custom iterator is the caller's to rule out: no persona builds one.
Goal 9's size counts ADF as JSON, so a shared object costs once per place it is held.
## Nothing spreads an unbounded array ## Nothing spreads an unbounded array
2026-09-18, the maintainer. Goal 1. Valid while engines cap a call's arguments. 2026-09-18, the maintainer. Goal 1. Valid while engines cap a call's arguments.
@@ -569,7 +589,7 @@ termination is the loop's own check.
## Readers scan by index ## Readers scan by index
2026-08-30, the kept scan 2026-09-18, the maintainer. Goal 8. Valid while the pipeline persona 2026-08-30, the kept scan 2026-09-18, the maintainer. Goal 9. Valid while the pipeline persona
feeds documents nobody typed. feeds documents nobody typed.
A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the line A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the line
@@ -581,7 +601,7 @@ two cannot disagree — which is what makes the kept value a memo rather than a
## The spelling memo ## The spelling memo
2026-09-19, the maintainer. Goal 8. Valid while the `commonMarkSpelling` ask spells a node once 2026-09-19, the maintainer. Goal 9. Valid while the `commonMarkSpelling` ask spells a node once
per level above it otherwise. per level above it otherwise.
The parse and the plain reduction keep each node's readable spelling in a memo, so the The parse and the plain reduction keep each node's readable spelling in a memo, so the
@@ -593,7 +613,7 @@ one. Only what succeeded is kept, so no path minted at another position is ever
## Cost fixes are measured, never timed ## Cost fixes are measured, never timed
2026-09-18, the maintainer and the stability-reviewer. Goal 8. Valid while Goal 8 promises growth 2026-09-18, the maintainer and the stability-reviewer. Goal 9. Valid while Goal 9 promises growth
rather than a figure. rather than a figure.
A cost fix that changes no behaviour lands on the suite staying green with no fixture output A cost fix that changes no behaviour lands on the suite staying green with no fixture output
@@ -612,7 +632,7 @@ refuses it — which is how the whitespace carry finds a line edge.
## Emphasis follows CommonMark's matching ## Emphasis follows CommonMark's matching
2026-08-27, the maintainer. Goals 1 and 3. Valid while CommonMark's emphasis rules are the 2026-08-27, the maintainer. Goals 1 and 4. Valid while CommonMark's emphasis rules are the
reader's. reader's.
Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text
@@ -621,7 +641,7 @@ only ones in play, and a pair that matching hands to another delimiter rides the
## Readable spellings take the `try` prefix ## 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 2026-09-21, the maintainer. Goals 1 and 6. Valid while a readable spelling's refusal would cost a
document the general form spells. document the general form spells.
A readable spelling tried ahead of a general one takes the `try` prefix and fails only where the A readable spelling tried ahead of a general one takes the `try` prefix and fails only where the
@@ -643,8 +663,8 @@ is the markdown flavour's choice, not ADF's.
## The source parts by ADF and format ## 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 2026-08-27, placement 2026-09-18, `adf/`'s bar 2026-09-21, `plain/` 2026-10-03, the maintainer.
each format has a reader and a writer through ADF. 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 `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 lives: the question is answered in ADF's vocabulary — a node type, an attribute kind, a content
@@ -657,13 +677,15 @@ them. A primitive knowing neither ADF nor a format stays at `src/` root. A const
`spec/flavour.md` draws, and a placement nothing here settles goes beside its only reader, or in `spec/flavour.md` draws, and a placement nothing here settles goes beside its only reader, or in
what both read where there are two. what both read where there are two.
Each format directory parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest of it A flavour's own code, both directions included, sits in its own directory: `markdown/plain/`;
holding what both directions read. A construct's reader lives there beside the regex the emitter `todo.md` item 62 moves what still sits in `parse/` and `emit/`. Otherwise each format directory
escapes against, so the two cannot drift; a reader with no emit counterpart goes in `parse/`, parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest of it holding what both
unless it is part of a construct that side already holds — a grammar stays in one file rather than directions read. A construct's reader lives there beside the regex the emitter escapes against, so
splitting across the seam. A rule both directions must answer alike — whether a list marker the two cannot drift; a reader with no emit counterpart goes in `parse/`, unless it is part of a
interrupts a paragraph — is one function there too, never a copy per direction, however construct that side already holds — a grammar stays in one file rather than splitting across the
conservative the copy would be. Where the rule is the emitter's own choice, input consults it seam. A rule both directions must answer alike — whether a list marker interrupts a paragraph — is
rather than restating it, and that is the only import `parse/` takes from `emit/` — one function there too, never a copy per direction, however conservative the copy would be. Where
`commonMarkSpelling` and `openingLinkTakesDirective` — so no fixture the emitter writes can be the rule is the emitter's own choice, input consults it rather than restating it, and that is the
refused, and a spelling the emitter refuses gives its own error rather than a second name for it. only value import `parse/` takes from `emit/` — `commonMarkSpelling` and `openingLinkTakesDirective`
— so no fixture the emitter writes can be refused, and a spelling the emitter refuses gives its own
error rather than a second name for it.
+39 -31
View File
@@ -1,12 +1,14 @@
# The markdown flavour # The markdown flavour
The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain
CommonMark is a subset apart from raw HTML (below), with three carve-outs: literal text that matches CommonMark is a subset apart from raw HTML (below), with four carve-outs: literal text that matches
directive syntax below or reads as a pipe table is claimed by the flavour, and a matched `~~` pair directive syntax below or reads as a pipe table is claimed by the flavour, a matched `~~` pair
spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal) — and one gap: a CommonMark spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal), and a code fence whose info
image fits only as its own title-less paragraph — mid-text and titled images are named errors. The string opens `adf:` is the opaque carry (drop the info string and wrap the fence in
emitted form is contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this `!adf:codeBlock {language="adf:…"}` to keep it code) — and one gap: a CommonMark image fits only as
grammar in the sections below. its own title-less paragraph — mid-text and titled images are named errors. The emitted form is
contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this grammar in the
sections below.
## Canonical form ## Canonical form
@@ -179,8 +181,9 @@ product). Block and inline positions canonicalize differently, each fitting wher
- **Block position**: a fenced code block with info string `adf:` and the node's type, body = the - **Block position**: a fenced code block with info string `adf:` and the node's type, body = the
node's JSON without its `type` — two-space indent, object keys sorted: ```` ```adf:blockCard ````. node's JSON without its `type` — two-space indent, object keys sorted: ```` ```adf:blockCard ````.
A type no info string carries back, by the rule a `codeBlock`'s language follows, leaves the info A type no info string carries back, by the rule a `codeBlock`'s language follows, leaves the info
string `adf:` and keeps `type` in the body. A body holding `type` under a named type, or an string `adf:` and keeps `type` in the body. A body holding `type` under a named type, or a fence
`adf:` fence whose type an info string carries, is a named error. whose info string is `adf:` alone while its body's `type` could be spelled in the info string, is
a named error.
- **Inline position**: `!adf:carry{json="…"}` — compact serialization (keys sorted, no whitespace), - **Inline position**: `!adf:carry{json="…"}` — compact serialization (keys sorted, no whitespace),
JSON-string-escaped into the attribute. JSON-string-escaped into the attribute.
@@ -242,13 +245,14 @@ form.
leaves empty, or in both, is a named error, and so are fences whose info strings differ and an leaves empty, or in both, is a named error, and so are fences whose info strings differ and an
empty fence beside another. Each fence is an ordinary code block, and its info string decodes empty fence beside another. Each fence is an ordinary code block, and its info string decodes
escapes and entity references as any other does. A node holding a child no fence holds — any but escapes and entity references as any other does. A node holding a child no fence holds — any but
a text node carrying no marks, `attrs` or `content` — rides the block carry. a text node holding text free of carriage returns and null characters, and carrying no marks,
`attrs` or `content` — rides the block carry.
- `heading` — container, inline body. Attributes: `level` (number), `localId` (string). `level` is - `heading` — container, inline body. Attributes: `level` (number), `localId` (string). `level` is
the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
CommonMark spelling. CommonMark spelling.
- `orderedList` — container of `listItem`, block body. Attributes: `localId` (string), `order` - `orderedList` — container of `listItem`, block body. Attributes: `localId` (string), `order`
(number). `order` is the first marker, so a list carrying none, one that is no whole number (number). `order` is the first marker, so a list carrying none, one that is `-0` or no whole
from 0, or one whose markers would run past 999999999, has no CommonMark spelling. number from 0, or one whose markers would run past 999999999, has no CommonMark spelling.
- `paragraph` — container, inline body. Attributes: `localId` (string). - `paragraph` — container, inline body. Attributes: `localId` (string).
- `rule` — leaf. Attributes: `color` (string, `#rrggbb`), `localId` (string), `style` (`dashed` - `rule` — leaf. Attributes: `color` (string, `#rrggbb`), `localId` (string), `style` (`dashed`
`dotted` `fade` `sketch` `solid`), `weight` (number, 1–3). `dotted` `fade` `sketch` `solid`), `weight` (number, 1–3).
@@ -445,8 +449,10 @@ Attributes and the carry fallback read as in the block sections, the carry in it
the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot as the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot as
plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
parsing to anything but one text node carrying neither marks, attributes nor content is a named parsing to anything but one text node carrying neither marks, attributes nor content is a named
error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot. The rest take no content, error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot.
`!adf:text` included; content on a node that takes none is a named error. An `emoji`, `mention` or `status` whose `text` attribute holds a line ending or a null character
rides the inline carry; a content slot spelling one is a named error.
The rest take no content, `!adf:text` included; content on a node that takes none is a named error.
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds). - `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
- `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` - `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text`
@@ -468,14 +474,15 @@ error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not
Shipped !adf:emoji[🎉]{shortName=":tada:"} on !adf:date{timestamp=1756080000000}. Shipped !adf:emoji[🎉]{shortName=":tada:"} on !adf:date{timestamp=1756080000000}.
``` ```
**Whitespace CommonMark cannot hold.** A newline inside a text node, and a space or tab where **Text CommonMark cannot hold plainly.** A carriage return in text is `&#13;`, which CommonMark
CommonMark strips or refuses one — a block's inline content edges, either side of a line break, an reads back as the character. Three things are spelled `!adf:text{text="…"}`: a newline inside a text
em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled `!adf:text{text="…"}`, node; a null character, which CommonMark reads as U+FFFD; and a space or tab where CommonMark strips
the reserved key carrying the node's text, escaped by the attribute grammar and never literal: pipe or refuses one — a block's inline content edges, either side of a line break, an em, strong or
cells trim and pad. The emitter wraps the whitespace run alone and leaves the rest plain text, which strike spelling's inner edges, a pipe cell's edges. The reserved key carries the node's text,
the spelled run joins on reading. Input reads that spelling alone: the value is one run of spaces escaped by the attribute grammar and never literal: pipe cells trim and pad. The emitter wraps the
and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries run alone and leaves the rest plain text, which the spelled run joins on reading. Input reads that
plainly — is a named error. spelling alone: the value is one run of spaces and tabs, one run of newlines, or one run of null
characters, and anything else — a mixed run, or text CommonMark carries plainly — is a named error.
``` ```
!adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines. !adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines.
@@ -486,8 +493,8 @@ carried, neither holds `attrs` or an empty key, and their marks are identical, a
The reserved leaf `!adf:textBreak{}` parts such a pair, inside every mark spelling the two share; a The reserved leaf `!adf:textBreak{}` parts such a pair, inside every mark spelling the two share; a
code span holds no directive, so it closes and reopens. It builds no node and reads only between code span holds no directive, so it closes and reopens. It builds no node and reads only between
two such nodes: elsewhere, or with `[content]` or `{attrs}`, it is a named error (`docs/decisions.md` two such nodes: elsewhere, or with `[content]` or `{attrs}`, it is a named error (`docs/decisions.md`
§`!adf:textBreak{}` parts text CommonMark would join). A text node holding `attrs` or an empty key §`!adf:textBreak{}` parts text CommonMark would join). A text node holding `attrs`, `content` or an
rides the inline carry. empty key, or holding no text, rides the inline carry.
``` ```
Hello, !adf:textBreak{}world — **Hello, !adf:textBreak{}world** — `a`!adf:textBreak{}`b` Hello, !adf:textBreak{}world — **Hello, !adf:textBreak{}world** — `a`!adf:textBreak{}`b`
@@ -526,14 +533,15 @@ that depth: `attrs: {}` differs from no `attrs`, and a directive spells it `{att
breaks at every node the emitter carries, so no emitted carry sits inside a mark spelling. breaks at every node the emitter carries, so no emitted carry sits inside a mark spelling.
An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs spelling does not list, a value that is not the spelling's type, an attribute the spelling needs and
and the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside another mark, `code` over anything but a the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside
text node or over text holding a newline, or a spelling CommonMark's flanking rules cannot open or another mark, `code` over anything but a text node holding text, or over text holding a line ending
close where the run sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the or a null character, or a spelling CommonMark's flanking rules cannot open or close where the run
intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the intra-word `*` runs
merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a together with a neighbouring `**`, and the multiple-of-3 rule can leave the merged run's pairing to
mark spelling is a named error in input: the carry restores its node exactly, marks included another delimiter — rides the inline carry whole. An opaque carry inside a mark spelling is a named
(`docs/decisions.md` §Unknown nodes ride the carry). error in input: the carry restores its node exactly, marks included (`docs/decisions.md` §Unknown
nodes ride the carry).
``` ```
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed]. !adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
+20
View File
@@ -82,3 +82,23 @@ test("reads a node's siblings as a walk rather than as one call's arguments", ()
assert.equal(isAdfDocument(wide), true) assert.equal(isAdfDocument(wide), true)
assert.equal(fault(wide), 'accepted') assert.equal(fault(wide), 'accepted')
}) })
test('refuses an object holding itself, and accepts one shared', () => {
const holdsItself = 'an ADF document is a tree: an object in it holds itself'
const paragraph: { content: unknown[]; type: string } = { content: [], type: 'paragraph' }
paragraph.content.push(paragraph)
assert.equal(fault({ content: [paragraph], type: 'doc', version: 1 }), holdsItself)
const document: { content: unknown[]; type: string; version: number } = { content: [], type: 'doc', version: 1 }
document.content.push(document)
assert.equal(fault(document), holdsItself)
const attrs: Record<string, unknown> = {}
attrs['a'] = [attrs]
assert.equal(fault({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), holdsItself)
assert.equal(fault({ content: [{ marks: [{ attrs, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), holdsItself)
assert.equal(isAdfDocument({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), false)
const bold = { type: 'strong' }
const text = { marks: [bold], text: 'x', type: 'text' }
const shared = { content: [text, text], type: 'paragraph' }
const values = { b: 1 }
assert.equal(fault({ content: [shared, shared, { attrs: { a: [values, values] }, type: 'paragraph' }], type: 'doc', version: 1 }), 'accepted')
})
+28 -2
View File
@@ -43,6 +43,7 @@ export function adfDocumentFault(value: unknown): ConvertFault | undefined {
const held: unknown = value['content'] const held: unknown = value['content']
if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(held)}`) if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(held)}`)
const content: readonly unknown[] = held const content: readonly unknown[] = held
if (holdsItself(content)) return notADocument('an ADF document is a tree: an object in it holds itself')
if (!isNodeArray(content)) return notADocument("an ADF document's content holds ADF nodes: one of them is not") if (!isNodeArray(content)) return notADocument("an ADF document's content holds ADF nodes: one of them is not")
return nestingFault(content) return nestingFault(content)
} }
@@ -51,7 +52,7 @@ export function attributeNestingMessage(key: string, type: string): string {
return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
} }
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean { export function holdsOnlyAttributes(node: AdfNode, attributes: readonly string[]): boolean {
if (nodeMarks(node).length > 0 || node.text !== undefined || emptyKeys(node).length > 0) return false if (nodeMarks(node).length > 0 || node.text !== undefined || emptyKeys(node).length > 0) return false
return holdsOnly(nodeAttrs(node), attributes) return holdsOnly(nodeAttrs(node), attributes)
} }
@@ -64,6 +65,15 @@ export function emptyKeys(held: { attrs?: AdfAttributes; content?: AdfNode[]; ma
return keys return keys
} }
// No attrs, content or empty marks; a format may spell this node as text, and anything more rides a carry.
export function isBareText(node: AdfNode): boolean {
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
}
export function isUnmarkedBareText(node: AdfNode): boolean {
return isBareText(node) && node.marks === undefined
}
export function identicalMark(left: AdfMark, right: AdfMark): boolean { export function identicalMark(left: AdfMark, right: AdfMark): boolean {
return marksKey([left]) === marksKey([right]) return marksKey([left]) === marksKey([right])
} }
@@ -72,7 +82,6 @@ export function identicalMarks(left: readonly AdfMark[], right: readonly AdfMark
return marksKey(left) === marksKey(right) return marksKey(left) === marksKey(right)
} }
// `joins` is the caller's: a reader joins what CommonMark reads as one, editor-normal ADF what the editor would.
export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] { export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] {
const merged: AdfNode[] = [] const merged: AdfNode[] = []
for (const node of nodes) { for (const node of nodes) {
@@ -114,6 +123,23 @@ export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] {
return node.marks ?? [] return node.marks ?? []
} }
// Tracks ancestors alone: a tree may share an object, but an object holding itself walks the guards forever.
function holdsItself(root: object): boolean {
const pending = [{ depth: 0, item: root }]
const ancestors: object[] = []
const onPath = new Set<unknown>()
while (pending.length > 0) {
const entry = pending.pop()
if (entry === undefined) continue
while (ancestors.length > entry.depth) onPath.delete(ancestors.pop())
if (onPath.has(entry.item)) return true
ancestors.push(entry.item)
onPath.add(entry.item)
for (const child of Object.values(entry.item)) if (typeof child === 'object' && child !== null) pending.push({ depth: entry.depth + 1, item: child })
}
return false
}
function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] { function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
const pending: unknown[] = [...value] const pending: unknown[] = [...value]
while (pending.length > 0) { while (pending.length > 0) {
+4 -4
View File
@@ -5,10 +5,10 @@ import test from 'node:test'
import type { AdfNode } from '../adf/document.ts' import type { AdfNode } from '../adf/document.ts'
import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts' import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts'
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts' import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { adfToPlainMarkdown, reduceToPlain } from '../markdown/emit/plain-reduction.ts' import { adfToPlainMarkdown, reduceToPlain } from '../markdown/plain/adf-to-plain-markdown.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts' import { directivePrefix } from '../markdown/directive-syntax.ts'
import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts' import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { toEditorNormal } from '../adf/editor-normal.ts' import { toEditorNormal } from '../markdown/plain/editor-normal.ts'
const gateRuns = 1600 const gateRuns = 1600
const renamedPrefix = '!adg:' const renamedPrefix = '!adg:'
@@ -34,11 +34,11 @@ function shownText(nodes: readonly AdfNode[]): string[] {
return shown.filter((text) => text !== '') return shown.filter((text) => text !== '')
} }
test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => { test('a generated document emits markdown that reads back to it', { timeout: propertyTimeout }, () => {
fc.assert( fc.assert(
fc.property(adfDocument, (document) => { fc.property(adfDocument, (document) => {
const emitted = adfToMarkdown(document) const emitted = adfToMarkdown(document)
if (!emitted.ok) return assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
const read = markdownToAdf(emitted.value) const read = markdownToAdf(emitted.value)
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`) assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
assert.deepEqual(read.value, document, `reading ${JSON.stringify(emitted.value)}`) assert.deepEqual(read.value, document, `reading ${JSON.stringify(emitted.value)}`)
+4 -4
View File
@@ -13,7 +13,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus',
const checks = ['count', 'fixpoint', 'text'] as const const checks = ['count', 'fixpoint', 'text'] as const
type Check = (typeof checks)[number] type Check = (typeof checks)[number]
type ExceptionKind = 'mark-model' | 'pending' | 'unspellable' type ExceptionKind = 'mark-model' | 'pending'
type SpecExample = { example: number; html: string; markdown: string; section: string } type SpecExample = { example: number; html: string; markdown: string; section: string }
@@ -30,7 +30,7 @@ function isCheck(value: unknown): value is Check {
} }
function isKind(value: unknown): value is ExceptionKind { function isKind(value: unknown): value is ExceptionKind {
return value === 'mark-model' || value === 'pending' || value === 'unspellable' return value === 'mark-model' || value === 'pending'
} }
function isSpecExample(value: unknown): value is SpecExample { function isSpecExample(value: unknown): value is SpecExample {
@@ -77,12 +77,12 @@ test('the CommonMark spec suite is 0.31.2, vendored byte-exact', () => {
assert.equal(digest, 'd431b29d97b6f73e69d547109cf5081578fac931e72afe95639ebe766c1b2a20') assert.equal(digest, 'd431b29d97b6f73e69d547109cf5081578fac931e72afe95639ebe766c1b2a20')
}) })
test('every exception is unique, names a parsing example, and files a fixpoint only as unspellable', () => { test('every exception is unique, names a parsing example, and files no fixpoint divergence', () => {
assert.equal(exceptionIndex.size, exceptions.length, 'one exception repeats an example and check another holds') assert.equal(exceptionIndex.size, exceptions.length, 'one exception repeats an example and check another holds')
for (const entry of exceptions) { for (const entry of exceptions) {
assert.ok(spec.some((candidate) => candidate.example === entry.example), `exception ${entry.example} names no example in the suite`) assert.ok(spec.some((candidate) => candidate.example === entry.example), `exception ${entry.example} names no example in the suite`)
assert.equal(exampleToRefusal.get(entry.example), undefined, `exception ${entry.example} is on the refusal list, not an exception`) assert.equal(exampleToRefusal.get(entry.example), undefined, `exception ${entry.example} is on the refusal list, not an exception`)
if (entry.check === 'fixpoint') assert.equal(entry.kind, 'unspellable', `exception ${entry.example} files a fixpoint divergence as ${entry.kind}; a fixable hole is given the spelling instead`) assert.notEqual(entry.check, 'fixpoint', `exception ${entry.example} files a fixpoint divergence; the emitter spells every document the parser builds`)
} }
}) })
+2 -2
View File
@@ -418,7 +418,7 @@ function holdsDirectiveShape(document: AdfDocument): boolean {
return false return false
} }
test('generated markdown refuses, or what it parses to refuses to emit, or its spelling reads back and spells itself', { timeout: propertyTimeout }, () => { test('generated markdown refuses, or its spelling reads back and spells itself', { timeout: propertyTimeout }, () => {
const parameters = propertyRuns(gateRuns) const parameters = propertyRuns(gateRuns)
let directiveShaped = 0 let directiveShaped = 0
let fixpoints = 0 let fixpoints = 0
@@ -427,7 +427,7 @@ test('generated markdown refuses, or what it parses to refuses to emit, or its s
const parsed = markdownToAdf(input) const parsed = markdownToAdf(input)
if (!parsed.ok) return if (!parsed.ok) return
const emitted = adfToMarkdown(parsed.value) const emitted = adfToMarkdown(parsed.value)
if (!emitted.ok) return assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message} — spelling ${JSON.stringify(input)}`)
fixpoints += 1 fixpoints += 1
if (holdsDirectiveShape(parsed.value)) directiveShaped += 1 if (holdsDirectiveShape(parsed.value)) directiveShaped += 1
const read = markdownToAdf(emitted.value) const read = markdownToAdf(emitted.value)
+9 -11
View File
@@ -2,17 +2,17 @@ import assert from 'node:assert/strict'
import { env } from 'node:process' import { env } from 'node:process'
import fc from 'fast-check' import fc from 'fast-check'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../adf/document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode, EmptyKey } from '../adf/document.ts'
import type { Arbitrary } from 'fast-check' import type { Arbitrary } from 'fast-check'
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { blockArgument } from '../markdown/block-directive.ts' import { blockArgument } from '../markdown/block-directive.ts'
import { blockNodes } from '../adf/block-nodes.ts' import { blockNodes } from '../adf/block-nodes.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts' import { directivePrefix } from '../markdown/directive-syntax.ts'
import { emptyKeys, mergeAdjacentText } from '../adf/document.ts'
import { inlineNodes } from '../adf/inline-nodes.ts' import { inlineNodes } from '../adf/inline-nodes.ts'
import { joinsNormally } from '../adf/editor-normal.ts' import { joinsWhenRead } from '../markdown/adjacent-text.ts'
import { markAttributes } from '../adf/mark-attributes.ts' import { markAttributes } from '../adf/mark-attributes.ts'
import { mergeAdjacentText } from '../adf/document.ts'
type Positions = { block: AdfNode; inline: AdfNode } type Positions = { block: AdfNode; inline: AdfNode }
@@ -24,7 +24,7 @@ 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🎉日ー한𠀀', '==', '[!NOTE]', '[x]', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`) export const markdownPieces = fc.constantFrom(...'aZ09 \t\n\r\0!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉日ー한𠀀', '==', '[!NOTE]', '[x]', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
const nestingCommonMarkShapeWeight = 21 const nestingCommonMarkShapeWeight = 21
const spelledTypes = new Set(['text', ...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes)]) const spelledTypes = new Set(['text', ...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes)])
@@ -78,22 +78,20 @@ function heldAttributes(held: Readonly<Record<string, JsonValue | undefined>>):
return attrs return attrs
} }
// An empty attrs, content or marks key, and adjacent text a reader would merge, stay occasional: each takes a spelling outside CommonMark. // An empty attrs, content or marks key, and adjacent text CommonMark would read back as one, stay occasional: each takes a spelling outside CommonMark.
// The copy gives fast-check's null-prototype records the prototype a parsed node has. // The copy gives fast-check's null-prototype records the prototype a parsed node has.
function occasionallyEmpty<T extends AdfMark | AdfNode>(arbitrary: Arbitrary<T>): Arbitrary<T> { function occasionallyEmpty<T extends AdfMark | AdfNode>(arbitrary: Arbitrary<T>): Arbitrary<T> {
return fc.tuple(arbitrary, fc.nat({ max: 9 })).map(([held, roll]) => (roll === 0 ? { ...held } : withoutEmptyKeys(held))) return fc.tuple(arbitrary, fc.nat({ max: 9 })).map(([held, roll]) => (roll === 0 ? { ...held } : withoutEmptyKeys(held)))
} }
function withoutEmptyKeys<T extends AdfMark | AdfNode>(held: T): T { function withoutEmptyKeys<T extends AdfMark | AdfNode>(held: T): T {
const kept = { ...held } const kept: Partial<Record<EmptyKey, unknown>> & T = { ...held }
if (kept.attrs !== undefined && Object.keys(kept.attrs).length === 0) delete kept.attrs for (const key of emptyKeys(held)) delete kept[key]
if ('content' in kept && kept.content?.length === 0) delete kept.content
if ('marks' in kept && kept.marks?.length === 0) delete kept.marks
return kept return kept
} }
function occasionallyApart(arbitrary: Arbitrary<AdfNode[]>): Arbitrary<AdfNode[]> { function occasionallyApart(arbitrary: Arbitrary<AdfNode[]>): Arbitrary<AdfNode[]> {
return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes, joinsNormally))) return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes, joinsWhenRead)))
} }
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode { function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
@@ -108,7 +106,7 @@ const mark: Arbitrary<AdfMark> = occasionallyEmpty(fc.oneof(
)) ))
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type }) const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
const textNode = occasionallyEmpty(fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' }))) const textNode = occasionallyEmpty(fc.record({ marks, text: fc.oneof({ arbitrary: text, weight: 19 }, { arbitrary: fc.constant(''), weight: 1 }) }).map((held): AdfNode => ({ ...held, type: 'text' })))
const backtickRunNode = fc const backtickRunNode = fc
.record({ marks: fc.oneof(fc.constant<AdfMark[]>([]), fc.constant<AdfMark[]>([{ type: 'code' }]), marks), text: fc.string({ maxLength: 6, minLength: 1, unit: fc.constantFrom('`', '``', ' ', 'a') }) }) .record({ marks: fc.oneof(fc.constant<AdfMark[]>([]), fc.constant<AdfMark[]>([{ type: 'code' }]), marks), text: fc.string({ maxLength: 6, minLength: 1, unit: fc.constantFrom('`', '``', ' ', 'a') }) })
+1 -1
View File
@@ -3,6 +3,6 @@ export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/documen
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 { adfToPlainMarkdown } from './markdown/plain/adf-to-plain-markdown.ts'
export { isAdfDocument } from './adf/document.ts' export { isAdfDocument } from './adf/document.ts'
export { markdownToAdf, plainMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts' export { markdownToAdf, plainMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
+12
View File
@@ -0,0 +1,12 @@
import type { AdfNode } from '../adf/document.ts'
import { identicalMarks, isBareText, nodeMarks } from '../adf/document.ts'
import { spellInlineLeafDirective } from './directive-syntax.ts'
export const textBreakName = 'textBreak'
export const textBreakSpelling = spellInlineLeafDirective(textBreakName, '')
// The lossless reader's rule: CommonMark reads the pair back as one text run (spec/flavour.md, Inline nodes).
export function joinsWhenRead(previous: AdfNode, node: AdfNode): boolean {
return isBareText(previous) && isBareText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node))
}
+4 -2
View File
@@ -4,7 +4,7 @@ import type { JsonValue } from '../json-value.ts'
import { blockNodeModel } from '../adf/block-nodes.ts' import { blockNodeModel } from '../adf/block-nodes.ts'
import { isAdfMark } from '../adf/document.ts' import { isAdfMark } from '../adf/document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
import { spellDirectiveOpener } from './directive-syntax.ts' import { spellAttributes, spellDirectiveOpener } from './directive-syntax.ts'
const argumentByType = new Map( const argumentByType = new Map(
Object.entries({ Object.entries({
@@ -17,7 +17,9 @@ const argumentByType = new Map(
export const documentName = 'doc' export const documentName = 'doc'
// spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell. // spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell.
export const documentSpelling = spellDirectiveOpener(documentName, undefined, '{content=none}') export const documentAttribute = { key: 'content', value: 'none' }
export const documentSpelling = spellDirectiveOpener(documentName, undefined, spellAttributes([[documentAttribute.key, documentAttribute.value]]))
export const listBreakName = 'listBreak' export const listBreakName = 'listBreak'
+2 -2
View File
@@ -1,5 +1,5 @@
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { carryFencePrefix } from './opaque-carry.ts' import { carryFenceType } from './opaque-carry.ts'
import { infoStringCarries } from './commonmark/grammar.ts' import { infoStringCarries } from './commonmark/grammar.ts'
export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' } export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' }
@@ -7,6 +7,6 @@ export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute'
// spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides. // spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides.
export function languageSlot(language: JsonValue | undefined): LanguageSlot { export function languageSlot(language: JsonValue | undefined): LanguageSlot {
if (language === undefined) return { kind: 'none' } if (language === undefined) return { kind: 'none' }
if (typeof language !== 'string' || language.startsWith(carryFencePrefix) || !infoStringCarries(language)) return { kind: 'attribute' } if (typeof language !== 'string' || carryFenceType(language) !== undefined || !infoStringCarries(language)) return { kind: 'attribute' }
return { info: language, kind: 'fence' } return { info: language, kind: 'fence' }
} }
-4
View File
@@ -83,10 +83,6 @@ export function backslashEscape(text: string, index: number): string | undefined
return isAsciiPunctuation(escaped) ? escaped : undefined return isAsciiPunctuation(escaped) ? escaped : undefined
} }
export function claimsLine(line: string, position: LinePosition): boolean {
return escapesLineClaim(line, 0, position) || orderedListOpener.test(line)
}
export function claimsPipeLine(line: string): boolean { export function claimsPipeLine(line: string): boolean {
return pipeClaim.test(line) return pipeClaim.test(line)
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import { backslashEscape, decodeTextEscapes, holdsControlCharacter } from './grammar.ts' import { backslashEscape, decodeTextEscapes, holdsControlCharacter, holdsNullCharacter } 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 }
@@ -117,5 +117,5 @@ export function escapeUnbalanced(spelling: string, opener: string, closer: strin
} }
function spellTitle(title: string): string | undefined { function spellTitle(title: string): string | undefined {
return /[\n\r\\]/.test(title) || holdsEntityReference(title) ? undefined : ` "${title.replaceAll('"', '\\"')}"` return /[\n\r\\]/.test(title) || holdsNullCharacter(title) || holdsEntityReference(title) ? undefined : ` "${title.replaceAll('"', '\\"')}"`
} }
+5 -5
View File
@@ -1,7 +1,7 @@
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, holdsNullCharacter } from './commonmark/grammar.ts'
import { backtickRun, closingBacktickRun } from './commonmark/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'
@@ -100,10 +100,10 @@ export function readSoleStringAttribute(span: DirectiveSpan, key: string): Read<
return { value: spelled.decoded } return { value: spelled.decoded }
} }
// Both directions answer alike: an inline directive never spans lines, so no content slot holds a line ending. // An inline directive never spans lines, and no content slot holds a null character; the emitter carries such a node.
export function slotLineEndingFault(type: string, text: string): ConvertFault | undefined { export function slotFault(type: string, text: string): ConvertFault | undefined {
if (!/[\n\r]/.test(text)) return undefined if (/[\n\r]/.test(text)) return { code: 'unspellable-whitespace', message: `the ${type} content slot holds a newline no inline directive spans` }
return { code: 'unspellable-whitespace', message: `the ${type} content slot holds a newline no inline directive spans` } return holdsNullCharacter(text) ? unsupportedNodeShape(`the ${type} content slot holds a null character: write the node as !adf:carry`) : undefined
} }
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string { export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
+34 -24
View File
@@ -35,7 +35,7 @@ test('names the node a refusal came from, and no source the emitter never read',
const unspellable: AdfNode = { text: 'x', type: 'paragraph' } const unspellable: AdfNode = { text: 'x', type: 'paragraph' }
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' } const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' }
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0]) assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0])
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { type: 'text' })))), ['content', 0, 'content', 1]) assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { text: 'x', type: 'status' })))), ['content', 0, 'content', 1])
assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), []) assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), [])
assert.equal(position(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), undefined) assert.equal(position(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), undefined)
}) })
@@ -147,8 +147,16 @@ test('carries a mark the canonical spellings cannot nest', () => {
) )
}) })
test('refuses a line whose start block parsing would claim', () => { function readsBack(doc: AdfDocument): string {
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '```', type: 'text' })))), 'unspellable-line-start') const spelled = markdown(adfToMarkdown(doc))
assert.deepEqual(markdownToAdf(spelled), { ok: true, value: doc }, `reading ${JSON.stringify(spelled)}`)
return spelled
}
test('spells a code span opening a line, whose backticks open no fence', () => {
const fence: AdfNode = { marks: [{ type: 'code' }], text: '``', type: 'text' }
assert.equal(readsBack(document(paragraph(fence))), '``` `` ```\n')
assert.equal(readsBack(document(paragraph({ text: 'a', type: 'text' }, { type: 'hardBreak' }, fence))), 'a\\\n``` `` ```\n')
}) })
test('escapes the delimiter row a hard break leaves opening a pipe table with no leading pipe', () => { test('escapes the delimiter row a hard break leaves opening a pipe table with no leading pipe', () => {
@@ -456,28 +464,26 @@ test('spells a list item whose marker completes a thematic break as a directive'
) )
}) })
test('refuses the characters CommonMark rewrites', () => { test('spells the characters CommonMark rewrites', () => {
assert.equal( assert.equal(readsBack(document(paragraph({ text: 'a\r\r\nb', type: 'text' }))), 'a&#13;&#13;!adf:text{text="\\n"}b\n')
markdown(adfToMarkdown(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' }))), assert.equal(readsBack(document(paragraph({ marks: [{ type: 'strong' }], text: '\u0000\u0000a\u0000', type: 'text' }))), '**!adf:text{text="\\u0000\\u0000"}a!adf:text{text="\\u0000"}**\n')
'unspellable-character: a codeBlock holds no carriage return CommonMark keeps: this text holds one', assert.equal(readsBack(document(paragraph({ marks: [{ attrs: { href: 'u' }, type: 'link' }], text: 'a\rb\u0000', type: 'text' }))), '[a&#13;b!adf:text{text="\\u0000"}](u)\n')
) assert.equal(readsBack(document({ attrs: { level: 1 }, content: [{ text: '\r', type: 'text' }], type: 'heading' })), '# &#13;\n')
assert.equal( assert.equal(readsBack(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' })), '```adf:codeBlock\n{\n "content": [\n {\n "text": "a\\rb",\n "type": "text"\n }\n ]\n}\n```\n')
markdown(adfToMarkdown(document(paragraph({ text: 'a\rb', type: 'text' })))), readsBack(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))
'unspellable-character: a text node holds a carriage return CommonMark rewrites', readsBack(document(paragraph({ marks: [{ type: 'code' }], text: 'a\u0000b', type: 'text' })))
)
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\u0000b', type: 'text' })))), 'unspellable-character')
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))), 'unspellable-character')
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape: a text node holds text: this one has none')
}) })
test('refuses a text node the spelling would empty out', () => { test('carries a text node holding no text, or holding content', () => {
const nested: AdfNode[] = [{ text: 'lost', type: 'text' }] const nested: AdfNode[] = [{ text: 'lost', type: 'text' }]
const empty = 'unsupported-node-shape: a text node holds text: this one has none' assert.equal(readsBack(document(paragraph({ text: '', type: 'text' }))), '!adf:carry{json="{\\"text\\":\\"\\",\\"type\\":\\"text\\"}"}\n')
const holding = 'unsupported-node-shape: a text node holds no content: this one holds some' assert.equal(readsBack(document(paragraph({ type: 'text' }, { text: 'a', type: 'text' }))), '!adf:carry{json="{\\"type\\":\\"text\\"}"}a\n')
assert.equal(markdown(adfToMarkdown(document(paragraph({ text: '', type: 'text' })))), empty) readsBack(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))), empty) readsBack(document(paragraph({ marks: [{ type: 'em' }], text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: '', type: 'text' })))
assert.equal(markdown(adfToMarkdown(document(paragraph({ content: nested, text: 'x', type: 'text' })))), holding) readsBack(document(paragraph({ content: nested, text: 'x', type: 'text' })))
assert.equal(markdown(adfToMarkdown(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))), holding) readsBack(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))
readsBack(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))
readsBack(document({ content: [{ text: 'a', type: 'text' }, { type: 'text' }], type: 'codeBlock' }))
}) })
test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => { test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => {
@@ -688,8 +694,12 @@ test('refuses the content and slot an inline directive has no room for', () => {
assert.equal(refused({ text: 'x', type: 'status' }), neither('status', 'text')) assert.equal(refused({ text: 'x', type: 'status' }), neither('status', 'text'))
assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), neither('hardBreak', 'content')) assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), neither('hardBreak', 'content'))
assert.equal(refused({ text: 'x', type: 'hardBreak' }), neither('hardBreak', 'text')) assert.equal(refused({ text: 'x', type: 'hardBreak' }), neither('hardBreak', 'text'))
assert.equal(refused({ attrs: { text: 'a\nb' }, type: 'status' }), 'unspellable-whitespace: the status content slot holds a newline no inline directive spans') })
assert.equal(refused({ attrs: { text: 'a\u0000b' }, type: 'status' }), 'unspellable-character: a status content slot holds a null character CommonMark replaces')
test('carries an inline node whose content slot holds a line ending or a null character', () => {
assert.equal(readsBack(document(paragraph({ attrs: { text: 'a\nb' }, type: 'status' }))), '!adf:carry{json="{\\"attrs\\":{\\"text\\":\\"a\\\\nb\\"},\\"type\\":\\"status\\"}"}\n')
readsBack(document(paragraph({ attrs: { text: 'a\rb' }, type: 'emoji' })))
readsBack(document(paragraph({ attrs: { id: 'x', text: 'a\u0000b' }, type: 'mention' })))
}) })
test('spells the directive marks around the longest run they cover', () => { test('spells the directive marks around the longest run they cover', () => {
+35 -42
View File
@@ -1,8 +1,8 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockNodeModel } from '../../adf/block-nodes.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import type { Flavour } from '../plain-conventions.ts' import type { Flavour } from '../plain/conventions.ts'
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts' import { adfDocumentFault, holdsOnlyAttributes, isUnmarkedBareText, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain-conventions.ts' import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain/conventions.ts'
import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts' import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts'
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts' import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
import { carriedBlock } from '../opaque-carry.ts' import { carriedBlock } from '../opaque-carry.ts'
@@ -22,10 +22,10 @@ type BlockSpelling = 'commonmark' | 'directive' | 'list'
type EmittedBlock = { headroom: number; spelling: BlockSpelling; text: string } type EmittedBlock = { headroom: number; spelling: BlockSpelling; text: string }
type KeptSpelling = { block: EmittedBlock | undefined; depth: number } type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
type PlacedBlock = Omit<EmittedBlock, 'headroom'> & { node: AdfNode } type PlacedBlock = Omit<EmittedBlock, 'headroom'> & { node: AdfNode }
type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
// Keyed by reference: only a caller building one object per position (the parse, the plain reduction) passes one; a consumer's document may share a node. // Keyed by reference: only a caller building one object per position (the parse, the plain reduction) passes one; a consumer's document may share a node.
export type SpellingMemo = Map<AdfNode, KeptSpelling> export type SpellingMemo = Map<AdfNode, KeptSpelling>
type Walk = { blocks: readonly PlacedBlock[]; headroom: number } type WalkedItem = { node: AdfNode; walk: PlacedBlocks }
type WalkedItem = { node: AdfNode; walk: Walk }
export type Writing = { flavour: Flavour; memo: SpellingMemo | undefined } export type Writing = { flavour: Flavour; memo: SpellingMemo | undefined }
export const largestListMarker = 999999999 export const largestListMarker = 999999999
@@ -48,7 +48,7 @@ export function writeMarkdown(document: AdfDocument, flavour: Flavour): Result<s
} }
// 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, writing: Writing): Result<Walk> { function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
let headroom = largestNesting - depth let headroom = largestNesting - depth
if (headroom < 0) return tooDeep(path) if (headroom < 0) return tooDeep(path)
const blocks: PlacedBlock[] = [] const blocks: PlacedBlock[] = []
@@ -76,15 +76,15 @@ function joinBlocks(blocks: readonly PlacedBlock[], container: BlockContainer):
} }
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string { function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string {
const plainPair = previous.spelling !== 'directive' && next.spelling !== 'directive' const bothCommonMark = previous.spelling !== 'directive' && next.spelling !== 'directive'
if (plainPair && next.spelling === 'list') { if (bothCommonMark && next.spelling === 'list') {
if (previous.spelling === 'list' && previous.node.type === next.node.type) { if (previous.spelling === 'list' && previous.node.type === next.node.type) {
const gap = container === 'directive' ? '\n' : '\n\n' const gap = container === 'directive' ? '\n' : '\n\n'
return `${gap}${listBreakSpelling}${gap}` return `${gap}${listBreakSpelling}${gap}`
} }
if (container === 'list-item') return interruptsParagraph(next.node) ? '\n' : '\n\n' if (container === 'list-item') return interruptsParagraph(next.node) ? '\n' : '\n\n'
} }
return container === 'directive' && !plainPair ? '\n' : '\n\n' return container === 'directive' && !bothCommonMark ? '\n' : '\n\n'
} }
function interruptsParagraph(node: AdfNode): boolean { function interruptsParagraph(node: AdfNode): boolean {
@@ -128,9 +128,9 @@ function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number
if (plain !== undefined) return plain if (plain !== undefined) return plain
if (node.type === 'blockquote') return tryBlockquote(node, path, depth, writing) 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 === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, writing)
if (node.type === 'codeBlock') return tryCodeBlock(node, path) if (node.type === 'codeBlock') return readableText(tryCodeBlock(node))
if (node.type === 'heading') return tryHeading(node, path, writing.flavour) 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))
if (node.type === 'paragraph') return tryParagraph(node, path, writing.flavour) if (node.type === 'paragraph') return tryParagraph(node, path, writing.flavour)
if (node.type === 'rule') return readableText(tryRule(node)) if (node.type === 'rule') return readableText(tryRule(node))
if (node.type === 'table') return readableText(tryPipeTable(node, path, writing.flavour)) if (node.type === 'table') return readableText(tryPipeTable(node, path, writing.flavour))
@@ -181,13 +181,13 @@ function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writi
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') }) 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> { function placedBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
const block = emitBlock(node, path, depth, writing) const block = emitBlock(node, path, depth, writing)
return block.ok ? success({ blocks: [{ ...block.value, node }], headroom: block.value.headroom }) : block 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. // 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> { function taskBlocks(task: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
const marker = taskMarker(nodeAttrs(task)['state']) const marker = taskMarker(nodeAttrs(task)['state'])
const markerBlock: PlacedBlock = { node: { type: 'paragraph' }, spelling: 'commonmark', text: marker } const markerBlock: PlacedBlock = { node: { type: 'paragraph' }, spelling: 'commonmark', text: marker }
if (task.type === 'taskItem') { if (task.type === 'taskItem') {
@@ -220,7 +220,7 @@ 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, model: BlockNodeModel, path: ConvertErrorPath, depth: number, walkBody: () => Result<Walk>): Result<EmittedBlock> { function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number, walkBody: () => Result<PlacedBlocks>): Result<EmittedBlock> {
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path) if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path) if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
if (model.contentModel === 'code') return emitCodeDirective(node, model, path, depth) if (model.contentModel === 'code') return emitCodeDirective(node, model, path, depth)
@@ -230,7 +230,7 @@ function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertE
return emitDirectiveBody(node, model, opener.value, path, walkBody) return emitDirectiveBody(node, model, opener.value, path, walkBody)
} }
function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string, path: ConvertErrorPath, walkBody: () => Result<Walk>): Result<EmittedBlock> { function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string, path: ConvertErrorPath, walkBody: () => Result<PlacedBlocks>): Result<EmittedBlock> {
if (blockDirectiveForm(node.type) === 'leaf') return success({ headroom: Number.POSITIVE_INFINITY, spelling: 'directive', text: opener }) if (blockDirectiveForm(node.type) === 'leaf') return success({ headroom: Number.POSITIVE_INFINITY, spelling: 'directive', text: opener })
if (model.contentModel === 'inline') { if (model.contentModel === 'inline') {
const line = emitInlineLine(nodeContent(node), 'paragraph', path, 'lossless') const line = emitInlineLine(nodeContent(node), 'paragraph', path, 'lossless')
@@ -243,7 +243,7 @@ function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string,
} }
function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined { function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, [])) return undefined if (!holdsOnlyAttributes(node, [])) return undefined
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing) 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')
@@ -251,47 +251,40 @@ function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, wri
return success(commonMarkText(quoted(alert ? `\\${text}` : text), inner.value.headroom)) return success(commonMarkText(quoted(alert ? `\\${text}` : text), inner.value.headroom))
} }
function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function tryCodeBlock(node: AdfNode): string | undefined {
if (!carriesOnly(node, ['language'])) return undefined if (!holdsOnlyAttributes(node, ['language'])) return undefined
const slot = languageSlot(nodeAttrs(node)['language']) const slot = languageSlot(nodeAttrs(node)['language'])
if (slot.kind === 'attribute') return undefined if (slot.kind === 'attribute') return undefined
const texts = fencedTexts(node, path) const [only, ...others] = fencedTexts(node) ?? []
if (!texts.ok) return texts
const [only, ...others] = texts.value ?? []
if (only === undefined || others.length > 0) return undefined if (only === undefined || others.length > 0) return undefined
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', only))) return fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', only)
} }
// spec/flavour.md, The CommonMark blocks: one fence per text node, the language on each; with no fence to carry it, the attribute does. // spec/flavour.md, The CommonMark blocks: one fence per text node, the language on each; with no fence to carry it, the attribute does.
function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> { function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const texts = fencedTexts(node, path) const texts = fencedTexts(node)
if (!texts.ok) return texts if (texts === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (texts.value === undefined) return commonMarkLine(carriedBlock(node, path, depth)) const slot: LanguageSlot = texts.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language'])
const slot: LanguageSlot = texts.value.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language'])
const opener = spellBlockDirectiveOpener(node, model, path, 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 if (!opener.ok) return opener
const info = slot.kind === 'fence' ? slot.info : '' const info = slot.kind === 'fence' ? slot.info : ''
return success(directivePair(node, opener.value, texts.value.map((text) => fencedCodeBlock(info, text)).join('\n'))) return success(directivePair(node, opener.value, texts.map((text) => fencedCodeBlock(info, text)).join('\n')))
} }
// The text each fence holds, or `undefined` where a child is no plain text node, which the carry holds instead. // The text of each fence, or `undefined` where a child holds what no fence keeps, so the block carry takes the node.
function fencedTexts(node: AdfNode, path: ConvertErrorPath): Result<string[] | undefined> { function fencedTexts(node: AdfNode): string[] | undefined {
if (node.content === undefined) return success(['']) if (node.content === undefined) return ['']
if (node.content.some((child) => child.type !== 'text' || child.attrs !== undefined || child.content !== undefined || child.marks !== undefined)) return success(undefined)
const texts: string[] = [] const texts: string[] = []
for (const [index, child] of node.content.entries()) { for (const child of node.content) {
const childPath = [...path, 'content', index] if (!isUnmarkedBareText(child) || typeof child.text !== 'string' || child.text === '' || /\r/.test(child.text) || holdsNullCharacter(child.text)) return undefined
if (typeof child.text !== 'string' || child.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', childPath)
if (/\r/.test(child.text)) return failure('unspellable-character', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath)
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
texts.push(child.text) texts.push(child.text)
} }
return success(texts) return texts
} }
function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined { function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['level'])) return undefined if (!holdsOnlyAttributes(node, ['level'])) return undefined
const level = nodeAttrs(node)['level'] const level = nodeAttrs(node)['level']
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
const hashes = '#'.repeat(level) const hashes = '#'.repeat(level)
@@ -304,11 +297,11 @@ function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Re
function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined { function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
const ordered = node.type === 'orderedList' const ordered = node.type === 'orderedList'
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined if (!holdsOnlyAttributes(node, ordered ? ['order'] : [])) return undefined
const items = nodeContent(node) const items = nodeContent(node)
const start = listStart(node, items.length) const start = listStart(node, items.length)
if (start === undefined || items.length === 0) return undefined if (start === undefined || items.length === 0) return undefined
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined if (items.some((item) => item.type !== 'listItem' || !holdsOnlyAttributes(item, []))) return undefined
const walked: WalkedItem[] = [] const walked: WalkedItem[] = []
let headroom = Number.POSITIVE_INFINITY let headroom = Number.POSITIVE_INFINITY
for (const [offset, item] of items.entries()) { for (const [offset, item] of items.entries()) {
@@ -356,13 +349,13 @@ function tryListItemLines(inner: string, marker: string): string | undefined {
function tryParagraph(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined { function tryParagraph(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
const content = nodeContent(node) const content = nodeContent(node)
if (content.length === 0 || !carriesOnly(node, [])) return undefined if (content.length === 0 || !holdsOnlyAttributes(node, [])) return undefined
const line = emitInlineLine(content, 'paragraph', path, flavour) 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 tryRule(node: AdfNode): string | undefined { function tryRule(node: AdfNode): string | undefined {
if (!carriesOnly(node, []) || nodeContent(node).length > 0) return undefined if (!holdsOnlyAttributes(node, []) || nodeContent(node).length > 0) return undefined
return '---' return '---'
} }
+5 -6
View File
@@ -1,23 +1,22 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { ConvertErrorPath } from '../../result.ts' import { holdsOnlyAttributes, nodeAttrs, nodeContent } from '../../adf/document.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'
const centeredMediaSingle = '{"layout":"center"}' const centeredMediaSingle = '{"layout":"center"}'
const imageAttributes = ['alt', 'type', 'url'] const imageAttributes = ['alt', 'type', 'url']
export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefined { export function tryImage(node: AdfNode): string | undefined {
const image = imageShape(node) const image = imageShape(node)
if (image === undefined) return undefined if (image === undefined) return undefined
return tryImageLine(image.alt, image.url, [...path, 'content', 0]) return tryImageLine(image.alt, image.url)
} }
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined { function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
const content = nodeContent(node) const content = nodeContent(node)
const media = content[0] const media = content[0]
if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined if (!holdsOnlyAttributes(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined
if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || nodeContent(media).length > 0) return undefined if (media === undefined || content.length !== 1 || media.type !== 'media' || !holdsOnlyAttributes(media, imageAttributes) || nodeContent(media).length > 0) return undefined
const attrs = nodeAttrs(media) const attrs = nodeAttrs(media)
const alt = attrs['alt'] const alt = attrs['alt']
const url = attrs['url'] const url = attrs['url']
+50 -62
View File
@@ -1,20 +1,20 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { Flavour } from '../plain-conventions.ts' import type { Flavour } from '../plain/conventions.ts'
import type { InlineNodeModel } from '../../adf/inline-nodes.ts' import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
import type { LineContainer } from '../line-container.ts' import type { LineContainer } from '../line-container.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.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 { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { emptyKeys, identicalMark, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts' import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { identicalMark, isBareText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { highlightDelimiter } from '../plain-conventions.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { highlightDelimiter } from '../plain/conventions.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../commonmark/backtick-runs.ts' import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
import { readsAsOne, textBreakSpelling } from '../text-break.ts' import { slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
import { spellTextDirective } from '../text-directive.ts' import { spellTextDirective } from '../text-directive.ts'
@@ -38,7 +38,7 @@ type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } |
type LineFallbacks = { carried: Set<number>; flavour: Flavour; openingLinkAsDirective: boolean } type LineFallbacks = { carried: Set<number>; flavour: Flavour; openingLinkAsDirective: boolean }
export type PlainLineFallback = { kind: 'claimed-line'; line: number; text: string } | { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] } export type PlainLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<string> { export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<string> {
const emitted = emitLine(nodes, container, path, flavour) const emitted = emitLine(nodes, container, path, flavour)
@@ -67,13 +67,12 @@ export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, f
return emitted.value.line return emitted.value.line
} }
export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined { export function tryImageLine(alt: string | undefined, href: string): string | undefined {
if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined
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('!['), ...description, syntax(`](${destination})`)], 'paragraph', path, 'lossless') return attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', 'lossless').line
return attempt.ok ? attempt.value.line : undefined
} }
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<EmittedLine> { function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<EmittedLine> {
@@ -87,12 +86,11 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con
if (!taken.ok) return taken if (!taken.ok) return taken
continue continue
} }
const attempt = attemptLine(emission.value.segments, container, path, flavour) const attempt = attemptLine(emission.value.segments, container, flavour)
if (!attempt.ok) return attempt if (attempt.line !== undefined) {
if (attempt.value.line !== undefined) { return success({ line: attempt.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
return success({ line: attempt.value.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
} }
const taken = takeFallback(fallbacks, attempt.value.fallback, path) const taken = takeFallback(fallbacks, attempt.fallback, path)
if (!taken.ok) return taken if (!taken.ok) return taken
} }
} }
@@ -114,15 +112,14 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
const emission = emitRun(nodes, 0, 0, context) const emission = emitRun(nodes, 0, 0, context)
if (!emission.ok) return emission if (!emission.ok) return emission
if (emission.value.carry !== undefined) return emission if (emission.value.carry !== undefined) return emission
return success({ segments: carryStrippedWhitespace(emission.value.segments) }) return success({ segments: spellEdgeWhitespace(emission.value.segments) })
} }
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<LineAttempt> { function attemptLine(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): LineAttempt {
const verdict = lineVerdict(segments, container, flavour) const verdict = lineVerdict(segments, container, flavour)
if (verdict.kind === 'opening-link') return success({ fallback: 'opening-link' }) if (verdict.kind === 'opening-link') return { fallback: 'opening-link' }
if (verdict.kind === 'unspellable-run') return success({ fallback: verdict.runs[0] }) if (verdict.kind === 'unspellable-run') return { fallback: verdict.runs[0] }
if (verdict.kind === 'claimed-line') return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(verdict.text)}`, path) return { line: verdict.text }
return success({ line: verdict.text })
} }
// The fallbacks in the order a line takes them, or the line where it takes none. // The fallbacks in the order a line takes them, or the line where it takes none.
@@ -130,39 +127,36 @@ function lineVerdict(segments: readonly InlineSegment[], container: LineContaine
const assembled = assembleInlineLine(segments, container, flavour) const assembled = assembleInlineLine(segments, container, flavour)
if (assembled.openingLinkAsDirective) return { kind: 'opening-link' } if (assembled.openingLinkAsDirective) return { kind: 'opening-link' }
const [run, ...others] = assembled.unspellableRuns const [run, ...others] = assembled.unspellableRuns
if (run !== undefined) return { kind: 'unspellable-run', runs: [run, ...others] } return run === undefined ? { kind: 'line', text: assembled.line } : { kind: 'unspellable-run', runs: [run, ...others] }
const lines = assembled.line.split('\n')
const claimed = container === 'paragraph' ? lines.findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later')) : -1
return claimed === -1 ? { kind: 'line', text: assembled.line } : { kind: 'claimed-line', line: claimed, text: lines[claimed] ?? '' }
} }
// spec/flavour.md, Inline nodes. // spec/flavour.md, Inline nodes.
function carryStrippedWhitespace(segments: readonly InlineSegment[]): InlineSegment[] { function spellEdgeWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
const carried: InlineSegment[] = [] const spelled: InlineSegment[] = []
for (const [index, segment] of segments.entries()) { for (const [index, segment] of segments.entries()) {
const previous = segments[index - 1] const previous = segments[index - 1]
const next = segments[index + 1] const next = segments[index + 1]
const leading = previous === undefined || previous.text.includes('\n') const leading = previous === undefined || previous.text.includes('\n')
const trailing = next === undefined || next.text.includes('\n') const trailing = next === undefined || next.text.includes('\n')
carried.push(...carryEdges(segment, leading, trailing)) spelled.push(...edgeWhitespaceSegments(segment, leading, trailing))
} }
return carried return spelled
} }
function carryEdges(segment: InlineSegment, leading: boolean, trailing: boolean): InlineSegment[] { function edgeWhitespaceSegments(segment: InlineSegment, leading: boolean, trailing: boolean): InlineSegment[] {
if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment] if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment]
const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : '' const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : ''
const body = segment.text.slice(head.length) const body = segment.text.slice(head.length)
const middle = trailing ? trimTrailingSpace(body) : body const middle = trailing ? trimTrailingSpace(body) : body
const tail = body.slice(middle.length) const tail = body.slice(middle.length)
const edges: InlineSegment[] = [] const edges: InlineSegment[] = []
if (head !== '') edges.push(carriedText(head)) if (head !== '') edges.push(textDirectiveSegment(head))
if (middle !== '') edges.push({ escaping: segment.escaping, text: middle }) if (middle !== '') edges.push({ escaping: segment.escaping, text: middle })
if (tail !== '') edges.push(carriedText(tail)) if (tail !== '') edges.push(textDirectiveSegment(tail))
return edges return edges
} }
function carriedText(text: string): InlineSegment { function textDirectiveSegment(text: string): InlineSegment {
return syntax(spellTextDirective(text)) return syntax(spellTextDirective(text))
} }
@@ -186,7 +180,7 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c
const runs = inlineRuns(nodes, depth, firstIndex, context.carried) const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
const segments: InlineSegment[] = [] const segments: InlineSegment[] = []
for (const [offset, run] of runs.entries()) { for (const [offset, run] of runs.entries()) {
if (partsText(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling)) if (takesTextBreak(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling))
const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 } const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 }
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext) const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
if (!emitted.ok) return emitted if (!emitted.ok) return emitted
@@ -213,19 +207,24 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
return runs return runs
} }
function partsText(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean { function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean {
if (previous?.kind !== 'plain' || run.kind !== 'plain') return false if (previous?.kind !== 'plain' || run.kind !== 'plain') return false
if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false
return readsAsOne(previous.node, run.node) return joinsWhenRead(previous.node, run.node)
} }
function nodePath(context: InlineContext, index: number): ConvertErrorPath { function nodePath(context: InlineContext, index: number): ConvertErrorPath {
return [...context.path, 'content', index] return [...context.path, 'content', index]
} }
// Decides the text-node and slot-fault carries up front, so a line holding many of them is emitted once.
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' && inlineNodeModel(node.type) === undefined if (node.type === 'text') return !isBareText(node) || typeof node.text !== 'string' || node.text === ''
const model = inlineNodeModel(node.type)
if (model === undefined) return true
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
return typeof slot === 'string' && slotFault(node.type, slot) !== undefined && nodeContent(node).length === 0 && node.text === undefined
} }
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> { function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> {
@@ -238,7 +237,7 @@ 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 model = inlineNodeModel(node.type) const model = inlineNodeModel(node.type)
if (model === undefined) return emitText(node, context, index, path) if (model === undefined) return success(emitText(node, context, index))
if (node.type === 'hardBreak') return emitHardBreak(node, model, context, index, path) if (node.type === 'hardBreak') return emitHardBreak(node, model, context, index, path)
return emitInlineDirective(node, model, index, path) return emitInlineDirective(node, model, index, path)
} }
@@ -259,34 +258,27 @@ function emitInlineDirective(node: AdfNode, model: InlineNodeModel, index: numbe
if (attributes === undefined) return success({ carry: { first: index, last: index } }) if (attributes === undefined) return success({ carry: { first: index, last: index } })
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.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' || slotFault(node.type, slot) !== undefined) return success({ carry: { first: index, last: index } })
const spans = slotLineEndingFault(node.type, slot)
if (spans !== undefined) return faulted(spans, path)
if (holdsNullCharacter(slot)) return failure('unspellable-character', `a ${node.type} content slot holds a null character CommonMark replaces`, path)
const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }] const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }]
return success({ segments: [syntax(spellInlineDirectiveOpener(node.type)), ...content, syntax(`]${attributes}`)] }) return success({ segments: [syntax(spellInlineDirectiveOpener(node.type)), ...content, syntax(`]${attributes}`)] })
} }
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> { // spec/flavour.md, Text CommonMark cannot hold plainly.
if (node.attrs !== undefined || emptyKeys(node).length > 0) return success({ carry: { first: index, last: index } }) function emitText(node: AdfNode, context: InlineContext, index: number): Emission {
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path) if (!isBareText(node) || typeof node.text !== 'string' || node.text === '') return { carry: { first: index, last: index } }
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash' const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
const parts = node.text.split(/(\n+)/).filter((part) => part !== '') const parts = node.text.split(/(\n+|\0+|\r)/).filter((part) => part !== '')
return success({ segments: parts.map((part) => (part.startsWith('\n') ? carriedText(part) : { escaping, text: part })) }) return { segments: parts.map((part) => (part === '\r' ? syntax('&#13;') : /^[\n\0]/.test(part) ? textDirectiveSegment(part) : { escaping, text: part })) }
} }
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> { function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
const path = nodePath(context, index)
const 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) 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) const attributes = spellMarkAttributes(mark, spelling)
if (attributes === undefined) return success({ carry: range }) if (attributes === undefined) return success({ carry: range })
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path) if (spelling.kind === 'code') return success(emitCodeSpan(nodes, depth, range))
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' ? tryLink(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
@@ -300,11 +292,11 @@ function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number
const inner = emitRun(nodes, depth + 1, range.first, context) const inner = emitRun(nodes, depth + 1, range.first, context)
if (!inner.ok) return inner if (!inner.ok) return inner
if (inner.value.carry !== undefined) return inner if (inner.value.carry !== undefined) return inner
const carried = carryStrippedWhitespace(inner.value.segments) const spelled = spellEdgeWhitespace(inner.value.segments)
return success({ return success({
segments: [ segments: [
{ emphasis: 'open', escaping: 'none', nodes: { ...range, depth }, text: spelling }, { emphasis: 'open', escaping: 'none', nodes: { ...range, depth }, text: spelling },
...carried, ...spelled,
{ emphasis: 'close', escaping: 'none', nodes: { ...range, depth }, text: spelling }, { emphasis: 'close', escaping: 'none', nodes: { ...range, depth }, text: spelling },
], ],
}) })
@@ -320,19 +312,15 @@ function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRang
} }
// Each node its own span: CommonMark reads two adjacent text nodes in one span back as one. // Each node its own span: CommonMark reads two adjacent text nodes in one span back as one.
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> { function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange): Emission {
const spans: string[] = [] const spans: string[] = []
for (const node of nodes) { for (const node of nodes) {
if (node.type !== 'text' || nodeMarks(node).length !== depth + 1 || node.attrs !== undefined || emptyKeys(node).length > 0) return success({ carry: range })
const { text } = node const { text } = node
if (typeof text !== 'string' || text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path) if (!isBareText(node) || nodeMarks(node).length !== depth + 1 || typeof text !== 'string' || text === '' || /[\n\r]/.test(text) || holdsNullCharacter(text)) return { carry: range }
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
if (/[\n\r]/.test(text)) return success({ carry: range })
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
const fence = '`'.repeat(longestBacktickRun(text) + 1) const fence = '`'.repeat(longestBacktickRun(text) + 1)
spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`) spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`)
} }
return success({ segments: [syntax(spans.join(textBreakSpelling))] }) return { segments: [syntax(spans.join(textBreakSpelling))] }
} }
function needsPadding(text: string): boolean { function needsPadding(text: string): boolean {
+2 -2
View File
@@ -1,10 +1,10 @@
import type { Flavour } from '../plain-conventions.ts' import type { Flavour } from '../plain/conventions.ts'
import type { LineContainer } from '../line-container.ts' import type { LineContainer } from '../line-container.ts'
import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark/grammar.ts' import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark/grammar.ts'
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts' import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
import { claimsDirectivePrefix } from '../directive-syntax.ts' import { claimsDirectivePrefix } from '../directive-syntax.ts'
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts' import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
import { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts' import { highlightDelimiter, highlightFlanking } from '../plain/conventions.ts'
import { isBareDelimiterRow } from '../pipe-table-syntax.ts' import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts' import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
import { readEntityReference } from '../commonmark/entity-references.ts' import { readEntityReference } from '../commonmark/entity-references.ts'
+6 -6
View File
@@ -1,7 +1,7 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { ConvertErrorPath } from '../../result.ts' import type { ConvertErrorPath } from '../../result.ts'
import type { Flavour } from '../plain-conventions.ts' import type { Flavour } from '../plain/conventions.ts'
import { carriesOnly, nodeContent } from '../../adf/document.ts' import { holdsOnlyAttributes, 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'
@@ -26,16 +26,16 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath, flavour: Fla
function pipeRows(node: AdfNode): AdfNode[][] | undefined { function pipeRows(node: AdfNode): AdfNode[][] | undefined {
const rows = nodeContent(node) const rows = nodeContent(node)
const columns = rows[0] === undefined ? 0 : nodeContent(rows[0]).length const columns = rows[0] === undefined ? 0 : nodeContent(rows[0]).length
if (!carriesOnly(node, []) || columns === 0) return undefined if (!holdsOnlyAttributes(node, []) || columns === 0) return undefined
const grid: AdfNode[][] = [] const grid: AdfNode[][] = []
for (const [index, row] of rows.entries()) { for (const [index, row] of rows.entries()) {
const cells = nodeContent(row) const cells = nodeContent(row)
if (row.type !== 'tableRow' || !carriesOnly(row, []) || cells.length !== columns) return undefined if (row.type !== 'tableRow' || !holdsOnlyAttributes(row, []) || cells.length !== columns) return undefined
const wanted = index === 0 ? 'tableHeader' : 'tableCell' const wanted = index === 0 ? 'tableHeader' : 'tableCell'
const paragraphs: AdfNode[] = [] const paragraphs: AdfNode[] = []
for (const cell of cells) { for (const cell of cells) {
const paragraph = plainParagraph(cell) const paragraph = plainParagraph(cell)
if (paragraph === undefined || cell.type !== wanted || !carriesOnly(cell, [])) return undefined if (paragraph === undefined || cell.type !== wanted || !holdsOnlyAttributes(cell, [])) return undefined
paragraphs.push(paragraph) paragraphs.push(paragraph)
} }
grid.push(paragraphs) grid.push(paragraphs)
@@ -46,6 +46,6 @@ function pipeRows(node: AdfNode): AdfNode[][] | undefined {
function plainParagraph(cell: AdfNode): AdfNode | undefined { function plainParagraph(cell: AdfNode): AdfNode | undefined {
const content = nodeContent(cell) const content = nodeContent(cell)
const paragraph = content[0] const paragraph = content[0]
if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !carriesOnly(paragraph, [])) return undefined if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !holdsOnlyAttributes(paragraph, [])) return undefined
return paragraph return paragraph
} }
+4 -2
View File
@@ -16,15 +16,17 @@ export function spellsEmpty(value: DirectiveValue | undefined): boolean {
return value?.spelling === emptyValue return value?.spelling === emptyValue
} }
export function readEmptyKeys(attributes: DirectiveAttributes, keys: readonly EmptyKey[]): Read<EmptyKeysRead> { // `held` is whether the directive spells an attribute outside {attrs}: an argument or a content slot.
export function readEmptyKeys(type: string, attributes: DirectiveAttributes, keys: readonly EmptyKey[], held: boolean): Read<EmptyKeysRead> {
const rest = new Map(attributes) const rest = new Map(attributes)
const empty = new Set<EmptyKey>() const empty = new Set<EmptyKey>()
for (const key of keys) { for (const key of keys) {
const spelled = rest.get(key) const spelled = rest.get(key)
if (spelled === undefined) continue if (spelled === undefined) continue
if (!spellsEmpty(spelled)) return { fault: unsupportedNodeShape(`the reserved key ${key} reads ${key}=${emptyValue} alone: this one spells ${key}=${spelled.spelling}`) } if (!spellsEmpty(spelled)) return { fault: unsupportedNodeShape(`the reserved key ${key} takes only the value ${emptyValue}: this one spells ${key}=${spelled.spelling}`) }
empty.add(key) empty.add(key)
rest.delete(key) rest.delete(key)
} }
if (empty.has('attrs') && (held || [...rest.keys()].some((key) => key !== 'marks'))) return { fault: unsupportedNodeShape(`${type} spells attrs=empty beside another attribute, an argument or a [content] slot: drop attrs=empty, or the rest`) }
return { value: { empty, rest } } return { value: { empty, rest } }
} }
+18 -7
View File
@@ -8,7 +8,7 @@ import { infoStringCarries } from './commonmark/grammar.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 { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
import { malformedDirective, readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
export const carryFencePrefix = 'adf:' export const carryFencePrefix = 'adf:'
@@ -32,13 +32,18 @@ export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<str
return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value.json)]]))) return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value.json)]])))
} }
// The type a fence's info string names, `undefined` where the info string opens no carry.
export function carryFenceType(info: string): string | undefined {
return info.startsWith(carryFencePrefix) ? info.slice(carryFencePrefix.length) : undefined
}
export function readCarriedBlock(type: string, body: string, depth: number): Read<AdfNode> { export function readCarriedBlock(type: string, body: string, depth: number): Read<AdfNode> {
if (type !== '' && !infoStringCarries(type)) { if (type !== '' && !infoStringCarries(type)) {
return { fault: unsupportedNodeShape(`the carry fence names a type no info string carries back: spell it ${carryFencePrefix} with the type in the JSON`) } return { fault: unsupportedNodeShape(`the carry fence names a type no info string carries back: spell it ${carryFencePrefix} with the type in the JSON; ${fenceEscape(type)}`) }
} }
const read = readCarriedJson(body, 'two-space', largestNesting - depth, type) const read = readCarriedJson(body, 'two-space', largestNesting - depth, type)
if (read.fault !== undefined || type !== '' || !infoStringCarries(read.value.type)) return read if (read.fault !== undefined || type !== '' || !infoStringCarries(read.value.type)) return read
return { fault: unsupportedNodeShape(`the carry fence names a type its info string carries: spell it ${carryFencePrefix}${read.value.type}`) } return { fault: unsupportedNodeShape(`the ${carryFencePrefix} fence holds a type its info string can carry: spell the fence ${carryFencePrefix}${read.value.type} and drop type from the JSON`) }
} }
export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefined { export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefined {
@@ -58,8 +63,9 @@ function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, pat
// `type` is the one the fence's info string names, `undefined` for the inline carry. // `type` is the one the fence's info string names, `undefined` for the inline carry.
function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number, type?: string): Read<AdfNode> { function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number, type?: string): Read<AdfNode> {
const wayOut = type === undefined ? directiveEscape : fenceEscape(type)
const parsed = parseJsonText(raw) const parsed = parseJsonText(raw)
if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') } if (parsed === undefined) return { fault: malformedDirective(`the opaque carry holds invalid JSON; ${wayOut}`) }
const { value } = parsed const { value } = parsed
if (!isJsonValue(value)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') } if (!isJsonValue(value)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
const typed = type === undefined || type === '' ? { value } : typedValue(value, type) const typed = type === undefined || type === '' ? { value } : typedValue(value, type)
@@ -70,15 +76,20 @@ function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number, ty
} }
if (serializeCanonicalJson(value, spelling) !== raw) { if (serializeCanonicalJson(value, spelling) !== raw) {
const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted' const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted'
return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}`) } return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}; ${wayOut}`) }
} }
if (!isAdfNode(held)) return { fault: unsupportedNodeShape("the opaque carry holds one ADF node's JSON: this JSON is no ADF node") } if (!isAdfNode(held)) return { fault: unsupportedNodeShape(`the opaque carry holds one ADF node's JSON: this JSON is no ADF node; ${wayOut}`) }
return { value: held } return { value: held }
} }
// A code fence the carry claims stays code under the directive, whose language rides the attribute.
function fenceEscape(type: string): string {
return `${spellDirectiveOpener('codeBlock', undefined, spellAttributes([['language', spellStringAttribute(`${carryFencePrefix}${type}`)]]))} around a bare fence keeps it a code block`
}
function typedValue(value: JsonValue, type: string): Read<JsonValue> { function typedValue(value: JsonValue, type: string): Read<JsonValue> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { value } if (value === null || typeof value !== 'object' || Array.isArray(value)) return { value }
if ('type' in value) return { fault: unsupportedNodeShape(`the ${carryFencePrefix}${type} fence names its node's type: this JSON holds a type as well`) } if ('type' in value) return { fault: unsupportedNodeShape(`the ${carryFencePrefix}${type} fence names its node's type: this JSON holds a type as well; ${fenceEscape(type)}`) }
return { value: { ...value, type } } return { value: { ...value, type } }
} }
+2 -2
View File
@@ -13,11 +13,11 @@ export function readDirectiveMark(name: string, attributes: DirectiveAttributes,
if (spelling === undefined) return undefined if (spelling === undefined) return undefined
const markdown = markdownForm(spelling) const markdown = markdownForm(spelling)
if (markdown !== undefined) return failure('unsupported-node-shape', `${name} is spelled ${markdown}, never as a directive`, path) if (markdown !== undefined) return failure('unsupported-node-shape', `${name} is spelled ${markdown}, never as a directive`, path)
const empty = readEmptyKeys(attributes, ['attrs']) const empty = readEmptyKeys(name, attributes, ['attrs'], false)
if (empty.fault !== undefined) return faulted(empty.fault, path) if (empty.fault !== undefined) return faulted(empty.fault, path)
const attrs = readVocabulary(name, empty.value.rest, spelling.attributes, undefined, path) const attrs = readVocabulary(name, empty.value.rest, spelling.attributes, undefined, path)
if (!attrs.ok) return attrs if (!attrs.ok) return attrs
if (empty.value.empty.has('attrs')) return Object.keys(attrs.value).length === 0 ? success({ attrs: {}, type: name }) : failure('unsupported-node-shape', `${name} spells attrs=empty beside an attribute it holds`, path) if (empty.value.empty.has('attrs')) return success({ attrs: {}, type: name })
return success(Object.keys(attrs.value).length === 0 ? { type: name } : { attrs: attrs.value, type: name }) return success(Object.keys(attrs.value).length === 0 ? { type: name } : { attrs: attrs.value, type: name })
} }
+14 -19
View File
@@ -3,7 +3,7 @@ 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, isUnmarkedBareText } from '../../adf/document.ts'
import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts' import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts' import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts' import { blockNodeModel } from '../../adf/block-nodes.ts'
@@ -13,12 +13,11 @@ import { inlineMarkSpellingFault } from './directive-marks.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { readEmptyKeys, spellsEmpty } from '../empty-keys.ts' import { readEmptyKeys, spellsEmpty } from '../empty-keys.ts'
import { readVocabulary } from './directive-attributes.ts' import { readVocabulary } from './directive-attributes.ts'
import { slotLineEndingFault } from '../directive-syntax.ts' import { slotFault } from '../directive-syntax.ts'
import { textBreakName } from '../text-break.ts' import { textBreakName } from '../adjacent-text.ts'
import { textDirectiveName } from '../text-directive.ts' import { textDirectiveName } from '../text-directive.ts'
// `emptyContent` is whether the directive spells content=empty, which holds no body. export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; node: AdfNode }
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; emptyContent: boolean; node: AdfNode }
export function readBlockDirectiveNode( export function readBlockDirectiveNode(
name: string, name: string,
@@ -33,7 +32,7 @@ export function readBlockDirectiveNode(
if (model === 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 spelled = attributes.get(marksAttribute) const spelled = attributes.get(marksAttribute)
const empty = readEmptyKeys(attributes, spellsEmpty(spelled) ? ['attrs', 'content', 'marks'] : ['attrs', 'content']) const empty = readEmptyKeys(name, attributes, spellsEmpty(spelled) ? ['attrs', 'content', 'marks'] : ['attrs', 'content'], argument !== undefined)
if (empty.fault !== undefined) return faulted(empty.fault, path) if (empty.fault !== undefined) return faulted(empty.fault, path)
const { rest } = empty.value const { rest } = empty.value
rest.delete(marksAttribute) rest.delete(marksAttribute)
@@ -46,9 +45,7 @@ export function readBlockDirectiveNode(
} }
const marks: Result<AdfMark[] | undefined> = spelled === undefined || spellsEmpty(spelled) ? success(undefined) : readMarks(name, spelled, path) const marks: Result<AdfMark[] | undefined> = spelled === undefined || spellsEmpty(spelled) ? success(undefined) : readMarks(name, spelled, path)
if (!marks.ok) return marks if (!marks.ok) return marks
const node = namedNode(name, attrs.value, marks.value, empty.value.empty, path) return success({ contentModel: model.contentModel, node: namedNode(name, attrs.value, marks.value, empty.value.empty) })
if (!node.ok) return node
return success({ contentModel: model.contentModel, emptyContent: empty.value.empty.has('content'), node: node.value })
} }
export function readInlineDirectiveNode( export function readInlineDirectiveNode(
@@ -62,7 +59,7 @@ export function readInlineDirectiveNode(
const slot = model.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 empty = readEmptyKeys(attributes, ['attrs', 'content', 'marks']) const empty = readEmptyKeys(name, attributes, ['attrs', 'content', 'marks'], slot !== undefined && content !== undefined)
if (empty.fault !== undefined) return faulted(empty.fault, path) if (empty.fault !== undefined) return faulted(empty.fault, path)
const attrs = readVocabulary(name, empty.value.rest, model.attributes, elsewhere, path) const attrs = readVocabulary(name, empty.value.rest, model.attributes, elsewhere, path)
if (!attrs.ok) return attrs if (!attrs.ok) return attrs
@@ -71,11 +68,11 @@ export function readInlineDirectiveNode(
if (text === undefined) { if (text === undefined) {
return failure('unsupported-node-shape', `the ${name} content slot holds one text node carrying neither marks, attributes nor content: this one holds something else`, path) return failure('unsupported-node-shape', `the ${name} content slot holds one text node carrying neither marks, attributes nor content: this one holds something else`, path)
} }
const spans = slotLineEndingFault(name, text) const fault = slotFault(name, text)
if (spans !== undefined) return faulted(spans, path) if (fault !== undefined) return faulted(fault, path)
attrs.value[slot] = text attrs.value[slot] = text
} }
return namedNode(name, attrs.value, undefined, empty.value.empty, path) return success(namedNode(name, attrs.value, undefined, empty.value.empty))
} }
// 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). // 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).
@@ -95,7 +92,7 @@ function blockSpellingFault(name: string): ConvertFault | undefined {
function slotText(content: readonly AdfNode[]): string | undefined { function slotText(content: readonly AdfNode[]): string | undefined {
if (content.length === 0) return '' if (content.length === 0) return ''
const only = content.length === 1 ? content[0] : undefined const only = content.length === 1 ? content[0] : undefined
if (only?.type !== 'text' || nodeMarks(only).length > 0 || Object.keys(nodeAttrs(only)).length > 0 || nodeContent(only).length > 0 || typeof only.text !== 'string') return undefined if (only === undefined || !isUnmarkedBareText(only) || typeof only.text !== 'string') return undefined
return only.text return only.text
} }
@@ -109,11 +106,9 @@ function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath
return success(marks) return success(marks)
} }
function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined, empty: ReadonlySet<EmptyKey>, path: ConvertErrorPath): Result<AdfNode> { function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined, empty: ReadonlySet<EmptyKey>): AdfNode {
const held = Object.keys(attrs).length > 0 const node: AdfNode = Object.keys(attrs).length > 0 || empty.has('attrs') ? { attrs, type } : { type }
if (held && empty.has('attrs')) return failure('unsupported-node-shape', `${type} spells attrs=empty beside an attribute it holds`, path)
const node: AdfNode = held || empty.has('attrs') ? { attrs, type } : { type }
if (empty.has('content')) node.content = [] if (empty.has('content')) node.content = []
if (marks !== undefined || empty.has('marks')) node.marks = [...(marks ?? [])] if (marks !== undefined || empty.has('marks')) node.marks = [...(marks ?? [])]
return success(node) return node
} }
+50 -51
View File
@@ -1,7 +1,7 @@
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 '../commonmark/emphasis-matching.ts' import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
import type { Flavour } from '../plain-conventions.ts' import type { Flavour } from '../plain/conventions.ts'
import type { LineContainer } from '../line-container.ts' import type { LineContainer } from '../line-container.ts'
import type { LinkDefinition } from '../commonmark/link-syntax.ts' import type { LinkDefinition } from '../commonmark/link-syntax.ts'
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark/grammar.ts' import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark/grammar.ts'
@@ -9,9 +9,10 @@ import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
import { commonMarkLink, linkHref } from '../mark-spellings.ts' import { commonMarkLink, linkHref } from '../mark-spellings.ts'
import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.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 { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts' import { highlightDelimiter, highlightFlanking } from '../plain/conventions.ts'
import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts' import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts'
import { noSpans, readInlineDirective } from '../directive-syntax.ts' import { noSpans, readInlineDirective } from '../directive-syntax.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/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'
@@ -19,16 +20,18 @@ import { readCarriedInline } from '../opaque-carry.ts'
import { readDirectiveMark } from './directive-marks.ts' import { readDirectiveMark } from './directive-marks.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'
import { readsAsOne, textBreakName, textBreakSpelling } from '../text-break.ts'
export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] } export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] }
// The text break leaf: it holds no marks, and stands between the nodes it parts until the outermost content checks and drops it. // The text break leaf: it holds no marks, and stands between the nodes it parts until the outermost content checks and drops it.
type TextBreak = { kind: 'textBreak' } type TextBreak = { kind: 'textBreak' }
type Inline = AdfNode | TextBreak // A node whose marks are its own — an opaque carry, or an inline node spelling marks=empty: no mark spelling wraps it, and it joins no neighbour.
type OwnMarks = { kind: 'ownMarks'; node: AdfNode }
type Scanned = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: Inline[] } type Inline = AdfNode | OwnMarks | TextBreak
type Scanned = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: Inline[] }
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition> export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
@@ -38,10 +41,9 @@ type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; lin
type Pairing = EmphasisPairing<Run> type Pairing = EmphasisPairing<Run>
// A carry piece holds a node whose marks are its own: an opaque carry, or an inline node spelling marks=empty.
type Piece = type Piece =
| Bracket | Bracket
| { kind: 'carry'; node: AdfNode } | OwnMarks
| { alt: string; kind: 'image'; node: AdfNode } | { alt: string; kind: 'image'; node: AdfNode }
| { kind: 'nodes'; nodes: Inline[] } | { kind: 'nodes'; nodes: Inline[] }
| TextBreak | TextBreak
@@ -58,8 +60,6 @@ type Scan = {
definitions: LinkDefinitions definitions: LinkDefinitions
highlights: boolean highlights: boolean
openingSpellableLink: boolean openingSpellableLink: boolean
// The nodes carry pieces hold: none joins its neighbour.
ownMarks: Set<AdfNode>
path: ConvertErrorPath path: ConvertErrorPath
pending: string pending: string
pieces: Piece[] pieces: Piece[]
@@ -68,11 +68,11 @@ type Scan = {
} }
// What a directive's content slot inherits from the content holding it. // What a directive's content slot inherits from the content holding it.
type SharedScan = Pick<Scan, 'definitions' | 'ownMarks' | 'path'> type SharedScan = Pick<Scan, 'definitions' | 'path'>
type SlotContent = { carry: boolean; nodes: Inline[] } type SlotContent = { nodes: Inline[] }
const carriedInMark = 'no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own' const ownMarksInMark = 'move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks'
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' } const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
const hreflessLink = 'the link mark spells its href: this one spells none' 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'
@@ -82,7 +82,7 @@ const textBreak: TextBreak = { kind: 'textBreak' }
// The outermost content: only here does a text break see both its neighbours. // The outermost content: only here does a text break see both its neighbours.
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> { export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
const scan = freshScan(source, { definitions, ownMarks: new Set(), path }, { container, highlights: flavour === 'plain', spans: noSpans }) const scan = freshScan(source, { definitions, path }, { container, highlights: flavour === 'plain', spans: noSpans })
const scanned = scanInline(scan) const scanned = scanInline(scan)
if (!scanned.ok) return scanned if (!scanned.ok) return scanned
if (scanned.value.image !== undefined) return success({ image: scanned.value.image }) if (scanned.value.image !== undefined) return success({ image: scanned.value.image })
@@ -93,7 +93,7 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions,
if (!takesDirective.ok) return takesDirective if (!takesDirective.ok) return takesDirective
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path) if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path)
} }
return success({ carry: scanned.value.carry, nodes: nodes.value }) return success({ nodes: nodes.value })
} }
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'container' | 'highlights' | 'spans'>): Scan { function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'container' | 'highlights' | 'spans'>): Scan {
@@ -236,7 +236,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
const carried = readCarriedInline(span) const carried = readCarriedInline(span)
if (carried !== undefined) { if (carried !== undefined) {
if (carried.fault !== undefined) return faulted(carried.fault, scan.path) if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
return success(carryPiece(scan, carried.value)) return success({ kind: 'ownMarks', node: carried.value })
} }
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success(textBreak) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.path) if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success(textBreak) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.path)
const text = readTextDirective(span) const text = readTextDirective(span)
@@ -247,22 +247,17 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
const mark = readDirectiveMark(span.name, span.attributes, scan.path) const mark = readDirectiveMark(span.name, span.attributes, scan.path)
if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark
const content = slot.value?.nodes const content = slot.value?.nodes
if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: a content slot holds plain text`, scan.path) if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this content slot: a slot holds one text node`, scan.path)
const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.path) const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.path)
if (!node.ok) return node if (!node.ok) return node
return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : carryPiece(scan, node.value)) return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : { kind: 'ownMarks', node: node.value })
}
function carryPiece(scan: Scan, node: AdfNode): Piece {
scan.ownMarks.add(node)
return { kind: 'carry', node }
} }
function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result<Piece> { function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result<Piece> {
if (slot === undefined || slot.nodes.length === 0) { if (slot === undefined || slot.nodes.length === 0) {
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.nodes.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
const refused = mark.type === 'link' ? refuseLinkDirective(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) })
@@ -281,8 +276,8 @@ function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly Inline[]
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 { definitions, ownMarks, path } = scan const { definitions, path } = scan
const parsed = scanInline(freshScan(span.content, { definitions, ownMarks, path }, { container: undefined, highlights: false, spans: span.spans })) const parsed = scanInline(freshScan(span.content, { definitions, path }, { container: undefined, highlights: false, spans: span.spans }))
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)
@@ -304,7 +299,7 @@ function assemble(scan: Scan): Result<Scanned> {
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, true) const nodes = resolveNodes(scan.pieces, scan, true)
if (!nodes.ok) return nodes if (!nodes.ok) return nodes
return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value }) return success({ nodes: nodes.value })
} }
// spec/flavour.md, Inline nodes: the leaf builds no node, so only the pair it parts spells it. // spec/flavour.md, Inline nodes: the leaf builds no node, so only the pair it parts spells it.
@@ -312,41 +307,45 @@ function partText(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
const parted: AdfNode[] = [] const parted: AdfNode[] = []
for (const [index, item] of items.entries()) { for (const [index, item] of items.entries()) {
if (!isTextBreak(item)) { if (!isTextBreak(item)) {
parted.push(item) parted.push(isNode(item) ? item : item.node)
continue continue
} }
const previous = items[index - 1] const previous = items[index - 1]
const next = items[index + 1] const next = items[index + 1]
if (previous === undefined || next === undefined || isTextBreak(previous) || isTextBreak(next) || !readJoins(scan)(previous, next)) { if (previous === undefined || next === undefined || !isNode(previous) || !isNode(next) || !joinsWhenRead(previous, next)) {
return failure('unsupported-node-shape', `${textBreakName} parts two text nodes CommonMark reads back as one: this one parts something else`, scan.path) return failure('unsupported-node-shape', `delete ${textBreakSpelling} here: it stands only between two runs of text with the same formatting, which would otherwise read as one`, scan.path)
} }
} }
return success(parted) return success(parted)
} }
function readJoins(scan: Scan): (previous: AdfNode, node: AdfNode) => boolean { function isNode(item: Inline): item is AdfNode {
return (previous, node) => !scan.ownMarks.has(previous) && !scan.ownMarks.has(node) && readsAsOne(previous, node) return !('kind' in item)
} }
function isTextBreak(item: Inline): item is TextBreak { function isTextBreak(item: Inline): item is TextBreak {
return 'kind' in item return 'kind' in item && item.kind === 'textBreak'
} }
// The nodes the items hold, with text breaks dropped.
function adfNodes(items: readonly Inline[]): AdfNode[] { function adfNodes(items: readonly Inline[]): AdfNode[] {
const nodes: AdfNode[] = [] const nodes: AdfNode[] = []
for (const item of items) if (!isTextBreak(item)) nodes.push(item) for (const item of items) {
if (isNode(item)) nodes.push(item)
else if (!isTextBreak(item)) nodes.push(item.node)
}
return nodes return nodes
} }
function holdsCarry(pieces: readonly Piece[]): boolean { function holdsOwnMarks(item: Inline | Piece): boolean {
return pieces.some((piece) => piece.kind === 'carry') return 'kind' in item && item.kind === 'ownMarks'
} }
function holdsImage(pieces: readonly Piece[]): boolean { 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. // A node whose marks are its own rides its own piece, its marks restoring with it rather than riding a spelling, so the guard below answers for it.
function holdsLink(pieces: readonly Piece[]): boolean { function holdsLink(pieces: readonly Piece[]): boolean {
return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes)) return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes))
} }
@@ -448,7 +447,7 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition:
return success(false) 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 (inner.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
const resolved = resolveNodes(inner, scan, true) const resolved = resolveNodes(inner, scan, true)
if (!resolved.ok) return resolved if (!resolved.ok) return resolved
const nodes = resolved.value const nodes = resolved.value
@@ -489,7 +488,7 @@ function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition:
function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> { function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> {
const nodes = resolveNodes(inner, scan, false) const nodes = resolveNodes(inner, scan, false)
if (!nodes.ok) return nodes if (!nodes.ok) return nodes
if (nodes.value.some(isTextBreak)) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: an image description holds plain text`, scan.path) if (nodes.value.some(isTextBreak)) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this image description: the description reads as plain alt text`, scan.path)
return success(adfNodes(nodes.value).map(altText).join('')) return success(adfNodes(nodes.value).map(altText).join(''))
} }
@@ -507,25 +506,25 @@ function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean)
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, scan.path) if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : []) markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
return success(mergeText(nodes.flat(), readJoins(scan))) return success(mergeReadText(nodes.flat()))
} }
// A text break is a wall: the text on either side joins only its own side. // A text break and a node whose marks are its own are walls: the text on either side joins only its own side.
function mergeText(items: readonly Inline[], joins: (previous: AdfNode, node: AdfNode) => boolean): Inline[] { function mergeReadText(items: readonly Inline[]): Inline[] {
const merged: Inline[] = [] const merged: Inline[] = []
let run: AdfNode[] = [] let run: AdfNode[] = []
for (const item of items) { for (const item of items) {
if (!isTextBreak(item)) { if (isNode(item)) {
run.push(item) run.push(item)
continue continue
} }
for (const node of mergeAdjacentText(run, joins)) merged.push(node) for (const node of mergeAdjacentText(run, joinsWhenRead)) merged.push(node)
merged.push(item) merged.push(item)
run = [] run = []
} }
for (const node of mergeAdjacentText(run, joins)) merged.push(node) for (const node of mergeAdjacentText(run, joinsWhenRead)) merged.push(node)
return merged return merged
} }
@@ -533,8 +532,8 @@ function mergeText(items: readonly Inline[], joins: (previous: AdfNode, node: Ad
// A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it. // A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it.
function pieceNodes(piece: Piece): Inline[] { function pieceNodes(piece: Piece): Inline[] {
switch (piece.kind) { switch (piece.kind) {
case 'carry': case 'ownMarks':
return [piece.node] return [piece]
case 'highlight': case 'highlight':
return [{ text: '', type: 'text' }] return [{ text: '', type: 'text' }]
case 'image': case 'image':
@@ -578,7 +577,7 @@ function markPairings(pieces: readonly Piece[], nodes: Inline[][], pairings: rea
for (const pairing of pairings) { for (const pairing of pairings) {
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) } const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) { for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) {
if (pieces[index]?.kind === 'carry') return false if (pieces[index]?.kind === 'ownMarks') return false
nodes[index] = applyMark(nodes[index] ?? [], mark) nodes[index] = applyMark(nodes[index] ?? [], mark)
} }
} }
@@ -637,7 +636,7 @@ function markHighlights(pieces: readonly Piece[], nodes: Inline[][], paired: rea
} }
function highlighted(node: Inline): Inline { function highlighted(node: Inline): Inline {
if (isTextBreak(node)) return node if (!isNode(node)) return node
const marks = nodeMarks(node) 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 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] } return { ...node, marks: [editorHighlight, ...marks] }
@@ -651,7 +650,7 @@ function markType(character: string, used: number): string {
// A node cannot carry one mark type twice (docs/decisions.md §No schema validation). // A node cannot carry one mark type twice (docs/decisions.md §No schema validation).
function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] { function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] {
return nodes.map((node) => { return nodes.map((node) => {
if (isTextBreak(node)) return node if (!isNode(node)) return node
const marks = nodeMarks(node) const marks = nodeMarks(node)
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] } return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
}) })
+65 -30
View File
@@ -92,9 +92,12 @@ test('builds an empty document from input holding no block, and one holding no c
const form = 'unsupported-node-shape: doc spells the one form !adf:doc {content=none}: this one spells another' const form = 'unsupported-node-shape: doc spells the one form !adf:doc {content=none}: this one spells another'
assert.equal(content(markdownToAdf('!adf:doc\n')), form) assert.equal(content(markdownToAdf('!adf:doc\n')), form)
assert.equal(content(markdownToAdf('!adf:doc x {content=none}\n')), form) assert.equal(content(markdownToAdf('!adf:doc x {content=none}\n')), form)
assert.equal(content(markdownToAdf('!adf:doc {content=empty}\n')), form) assert.equal(
content(markdownToAdf('!adf:doc {content=empty}\n')),
'unsupported-node-shape: an empty document is empty markdown, and !adf:doc {content=none} spells a document holding no content key: this one spells content=empty',
)
assert.equal(content(markdownToAdf('!adf:doc {content="none"}\n')), form) assert.equal(content(markdownToAdf('!adf:doc {content="none"}\n')), form)
const alone = 'unsupported-node-shape: !adf:doc {content=none} spells a whole document holding no content key, alone: this one stands among other blocks' const alone = 'unsupported-node-shape: delete the !adf:doc {content=none} line to give the document content: it stands only as the whole document'
assert.equal(content(markdownToAdf('x\n\n!adf:doc {content=none}\n')), alone) assert.equal(content(markdownToAdf('x\n\n!adf:doc {content=none}\n')), alone)
assert.equal(content(markdownToAdf('!adf:panel info\n!adf:doc {content=none}\n!adf:/panel\n')), alone) assert.equal(content(markdownToAdf('!adf:panel info\n!adf:doc {content=none}\n!adf:/panel\n')), alone)
assert.equal(content(markdownToAdf('!adf:doc\n!adf:/doc\n')), 'malformed-directive: doc takes no body, so no !adf:/doc closes it; \\!adf: keeps the prefix literal') assert.equal(content(markdownToAdf('!adf:doc\n!adf:/doc\n')), 'malformed-directive: doc takes no body, so no !adf:/doc closes it; \\!adf: keeps the prefix literal')
@@ -163,7 +166,11 @@ test('reads the codeBlock directive body as the node content, the info string it
test('names the slot a codeBlock spells its language outside of', () => { test('names the slot a codeBlock spells its language outside of', () => {
const slot = 'unsupported-node-shape: codeBlock spells its language in the fence info string, or in the attribute where no info string carries it back' const slot = 'unsupported-node-shape: codeBlock spells its language in the fence info string, or in the attribute where no info string carries it back'
assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust wrap=true}\n```\nx\n```\n!adf:/codeBlock\n')), slot) assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust wrap=true}\n```\nx\n```\n!adf:/codeBlock\n')), "unsupported-node-shape: move language=rust to the fences' info strings: they can spell this language")
assert.equal(
content(markdownToAdf('!adf:codeBlock {language="c sharp"}\n```\nx\n```\n```\ny\n```\n!adf:/codeBlock\n')),
"unsupported-node-shape: move language=\"c sharp\" to the fences' info strings: they can spell this language",
)
assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust}\n```sql\nx\n```\n!adf:/codeBlock\n')), slot) assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust}\n```sql\nx\n```\n!adf:/codeBlock\n')), slot)
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```adf:x\nx\n```\n!adf:/codeBlock\n')), slot) assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```adf:x\nx\n```\n!adf:/codeBlock\n')), slot)
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```a\\b\nx\n```\n!adf:/codeBlock\n')), slot) assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```a\\b\nx\n```\n!adf:/codeBlock\n')), slot)
@@ -297,10 +304,8 @@ test('names the directive form a node CommonMark spells refuses', () => {
assert.deepEqual(content(markdownToAdf('!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')), [bulletList(item({ type: 'rule' }))]) assert.deepEqual(content(markdownToAdf('!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')), [bulletList(item({ type: 'rule' }))])
}) })
// The spelling the emitter refuses gives the emitter's own error, never a second name for it. test('refuses the directive form of a blockquote opening with a code span', () => {
test('gives back the refusal the CommonMark spelling itself raises', () => { assert.equal(content(markdownToAdf('!adf:blockquote\n` `` `\n!adf:/blockquote\n')), 'unsupported-node-shape: blockquote takes the CommonMark spelling, not the directive form')
const lineStart = '!adf:blockquote\n` `` `\n!adf:/blockquote\n'
assert.equal(content(markdownToAdf(lineStart)), 'unspellable-line-start: block parsing would claim the emitted line "``` `` ```"')
}) })
test('names the directive name no node reads back to', () => { test('names the directive name no node reads back to', () => {
@@ -371,11 +376,11 @@ test('reads the carry fence back to the node its info string names and its JSON
assert.deepEqual(content(markdownToAdf('```adf:\n{\n "type": "a`b"\n}\n```\n')), [{ type: 'a`b' }]) assert.deepEqual(content(markdownToAdf('```adf:\n{\n "type": "a`b"\n}\n```\n')), [{ type: 'a`b' }])
assert.equal( assert.equal(
content(markdownToAdf('```adf:blockCard\n{\n "type": "blockCard"\n}\n```\n')), content(markdownToAdf('```adf:blockCard\n{\n "type": "blockCard"\n}\n```\n')),
"unsupported-node-shape: the adf:blockCard fence names its node's type: this JSON holds a type as well", 'unsupported-node-shape: the adf:blockCard fence names its node\'s type: this JSON holds a type as well; !adf:codeBlock {language="adf:blockCard"} around a bare fence keeps it a code block',
) )
assert.equal(content(markdownToAdf('```adf:\n{\n "type": "blockCard"\n}\n```\n')), 'unsupported-node-shape: the carry fence names a type its info string carries: spell it adf:blockCard') assert.equal(content(markdownToAdf('```adf:\n{\n "type": "blockCard"\n}\n```\n')), 'unsupported-node-shape: the adf: fence holds a type its info string can carry: spell the fence adf:blockCard and drop type from the JSON')
assert.equal(content(markdownToAdf('```adf:\n{}\n```\n')), "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node") assert.equal(content(markdownToAdf('```adf:\n{}\n```\n')), "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node; !adf:codeBlock {language=\"adf:\"} around a bare fence keeps it a code block")
const unnamed = 'unsupported-node-shape: the carry fence names a type no info string carries back: spell it adf: with the type in the JSON' const unnamed = 'unsupported-node-shape: the carry fence names a type no info string carries back: spell it adf: with the type in the JSON; !adf:codeBlock {language="adf:\\\\"} around a bare fence keeps it a code block'
assert.equal(content(markdownToAdf('```adf:\\\\\n{}\n```\n')), unnamed) assert.equal(content(markdownToAdf('```adf:\\\\\n{}\n```\n')), unnamed)
}) })
@@ -385,26 +390,31 @@ test('reads the inline carry back to the node its json attribute holds', () => {
]) ])
}) })
test('names the invalid JSON no opaque carry holds', () => { test('names the invalid JSON no opaque carry holds, and the spelling that keeps it literal', () => {
const invalid = 'malformed-directive: the opaque carry holds invalid JSON' const invalid = 'malformed-directive: the opaque carry holds invalid JSON; '
assert.equal(content(markdownToAdf('```adf:x\n{"attrs":\n```\n')), invalid) const fence = `${invalid}!adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block`
assert.equal(content(markdownToAdf('```adf:x\n```\n')), invalid) assert.equal(content(markdownToAdf('```adf:x\n{"attrs":\n```\n')), fence)
assert.equal(content(markdownToAdf('!adf:carry{json="{"}\n')), invalid) assert.equal(content(markdownToAdf('```adf:x\n```\n')), fence)
assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), invalid) assert.equal(content(markdownToAdf('!adf:carry{json="{"}\n')), `${invalid}\\!adf: keeps the prefix literal`)
assert.equal(content(markdownToAdf('!adf:carry{json=abc}\n')), `${invalid}\\!adf: keeps the prefix literal`)
}) })
test('names the canonical spelling a carried JSON reads alone', () => { test('names the canonical spelling a carried JSON reads alone', () => {
const canonically = "unsupported-node-shape: the opaque carry spells its node's JSON canonically: " const canonically = "unsupported-node-shape: the opaque carry spells its node's JSON canonically: "
assert.equal(content(markdownToAdf('```adf:blockCard\n{"attrs":{}}\n```\n')), `${canonically}two-space indent, keys sorted`) const literal = '; \\!adf: keeps the prefix literal'
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted`) assert.equal(
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted`) content(markdownToAdf('```adf:blockCard\n{"attrs":{}}\n```\n')),
`${canonically}two-space indent, keys sorted; !adf:codeBlock {language="adf:blockCard"} around a bare fence keeps it a code block`,
)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\": \\"blockCard\\"}"}\n')), `${canonically}compact, keys sorted${literal}`)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted${literal}`)
}) })
test('names the node JSON an opaque carry restores alone', () => { test('names the node JSON an opaque carry restores alone', () => {
const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node" const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node; "
assert.equal(content(markdownToAdf('```adf:x\n[]\n```\n')), node) assert.equal(content(markdownToAdf('```adf:x\n[]\n```\n')), `${node}!adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block`)
assert.equal(content(markdownToAdf('!adf:carry{json=null}\n')), node) assert.equal(content(markdownToAdf('!adf:carry{json=null}\n')), `${node}\\!adf: keeps the prefix literal`)
assert.equal(content(markdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n')), node) assert.equal(content(markdownToAdf('!adf:carry{json="{\\"kind\\":\\"x\\"}"}\n')), `${node}\\!adf: keeps the prefix literal`)
}) })
test('names the shape the inline carry reads alone', () => { test('names the shape the inline carry reads alone', () => {
@@ -421,7 +431,7 @@ test('holds a carried JSON value to the nesting its position leaves', () => {
assert.equal(content(markdownToAdf(`!adf:carry{json="${nested(largestNesting + 2)}"}\n`)), deeper(largestNesting)) assert.equal(content(markdownToAdf(`!adf:carry{json="${nested(largestNesting + 2)}"}\n`)), deeper(largestNesting))
assert.equal( assert.equal(
content(markdownToAdf(fence('', largestNesting + 1))), content(markdownToAdf(fence('', largestNesting + 1))),
"unsupported-node-shape: the opaque carry spells its node's JSON canonically: two-space indent, keys sorted", 'unsupported-node-shape: the opaque carry spells its node\'s JSON canonically: two-space indent, keys sorted; !adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block',
) )
assert.equal(content(markdownToAdf(fence('> ', largestNesting + 1))), deeper(largestNesting - 1)) assert.equal(content(markdownToAdf(fence('> ', largestNesting + 1))), deeper(largestNesting - 1))
}) })
@@ -433,7 +443,7 @@ test('names the number no JSON spelling carries in an opaque carry', () => {
}) })
test('names the mark spelling no opaque carry sits inside', () => { test('names the mark spelling no opaque carry sits inside', () => {
const named = 'unsupported-node-shape: no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own' const named = 'unsupported-node-shape: move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks'
assert.equal(content(markdownToAdf(`_a ${carried} b_\n`)), named) assert.equal(content(markdownToAdf(`_a ${carried} b_\n`)), named)
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)
@@ -995,9 +1005,15 @@ test('names the content slot no lone plain text node reads back from', () => {
assert.equal(content(markdownToAdf('!adf:status[!adf:text{text="\\n"}]{color=yellow}\n')), spans) assert.equal(content(markdownToAdf('!adf:status[!adf:text{text="\\n"}]{color=yellow}\n')), spans)
assert.equal(content(markdownToAdf('!adf:status[a&#10;b]{color=yellow}\n')), spans) assert.equal(content(markdownToAdf('!adf:status[a&#10;b]{color=yellow}\n')), spans)
assert.equal(content(markdownToAdf('!adf:status[a&#13;b]{color=yellow}\n')), spans) assert.equal(content(markdownToAdf('!adf:status[a&#13;b]{color=yellow}\n')), spans)
assert.equal(content(markdownToAdf('!adf:status[!adf:text{text="\\u0000"}]{color=yellow}\n')), 'unsupported-node-shape: the status content slot holds a null character: write the node as !adf:carry')
assert.equal(content(markdownToAdf('Part !adf:mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}') assert.equal(content(markdownToAdf('Part !adf:mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}')
}) })
test('reads the characters CommonMark rewrites from their spellings', () => {
assert.deepEqual(content(markdownToAdf('a&#13;b!adf:text{text="\\u0000\\u0000"}\n')), [paragraph('a\rb\u0000\u0000')])
assert.equal(content(markdownToAdf('!adf:text{text="\\u0000a"}\n')), 'unsupported-node-shape: text spells one run of spaces and tabs, one run of newlines, or one run of null characters: this one spells none of them')
})
test('reads the whitespace the reserved text directive carries', () => { test('reads the whitespace the reserved text directive carries', () => {
assert.deepEqual(content(markdownToAdf('!adf:text{text=" "}a\n')), [paragraph(' a')]) assert.deepEqual(content(markdownToAdf('!adf:text{text=" "}a\n')), [paragraph(' a')])
assert.deepEqual(content(markdownToAdf('a!adf:text{text="\\n"}b\n')), [paragraph('a\nb')]) assert.deepEqual(content(markdownToAdf('a!adf:text{text="\\n"}b\n')), [paragraph('a\nb')])
@@ -1006,7 +1022,7 @@ test('reads the whitespace the reserved text directive carries', () => {
}) })
test('names the text directive spelling no whitespace run reads back from', () => { test('names the text directive spelling no whitespace run reads back from', () => {
const named = 'unsupported-node-shape: text spells one run of spaces and tabs, or one run of newlines: this one spells neither' const named = 'unsupported-node-shape: text spells one run of spaces and tabs, one run of newlines, or one run of null characters: this one spells none of them'
assert.equal(content(markdownToAdf('!adf:text{text=hi}\n')), named) assert.equal(content(markdownToAdf('!adf:text{text=hi}\n')), named)
assert.equal(content(markdownToAdf('!adf:text{text=" \\n"}\n')), named) assert.equal(content(markdownToAdf('!adf:text{text=" \\n"}\n')), named)
assert.equal(content(markdownToAdf('!adf:text{text=""}\n')), named) assert.equal(content(markdownToAdf('!adf:text{text=""}\n')), named)
@@ -1082,13 +1098,32 @@ test('names the directive mark left without the content it wraps', () => {
test('reads the text break only between two text nodes CommonMark joins, building no node', () => { test('reads the text break only between two text nodes CommonMark joins, building no node', () => {
assert.deepEqual(content(markdownToAdf('a!adf:textBreak{}b\n')), [{ content: [text('a'), text('b')], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('a!adf:textBreak{}b\n')), [{ content: [text('a'), text('b')], type: 'paragraph' }])
assert.deepEqual(content(markdownToAdf('==a!adf:textBreak{}b==\n')), [{ content: [text('==a'), text('b==')], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('==a!adf:textBreak{}b==\n')), [{ content: [text('==a'), text('b==')], type: 'paragraph' }])
const parts = 'unsupported-node-shape: textBreak parts two text nodes CommonMark reads back as one: this one parts something else' const parts = 'unsupported-node-shape: delete !adf:textBreak{} here: it stands only between two runs of text with the same formatting, which would otherwise read as one'
const carried = '!adf:carry{json="{\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}' const carried = '!adf:carry{json="{\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}'
for (const markdown of ['!adf:textBreak{}a\n', 'a!adf:textBreak{}\n', 'a!adf:textBreak{}!adf:textBreak{}b\n', '**a**!adf:textBreak{}b\n', '[!adf:textBreak{}](/u)\n', `a!adf:textBreak{}${carried}\n`]) { for (const markdown of ['!adf:textBreak{}a\n', 'a!adf:textBreak{}\n', 'a!adf:textBreak{}!adf:textBreak{}b\n', '**a**!adf:textBreak{}b\n', '[!adf:textBreak{}](/u)\n', `a!adf:textBreak{}${carried}\n`]) {
assert.equal(content(markdownToAdf(markdown)), parts, markdown) assert.equal(content(markdownToAdf(markdown)), parts, markdown)
} }
assert.equal(content(markdownToAdf('!adf:status[a!adf:textBreak{}b]\n')), 'unsupported-node-shape: textBreak parts two text nodes: a content slot holds plain text') assert.equal(content(markdownToAdf('!adf:status[a!adf:textBreak{}b]\n')), 'unsupported-node-shape: delete !adf:textBreak{} from this content slot: a slot holds one text node')
assert.equal(content(markdownToAdf('![a!adf:textBreak{}b](/i)\n')), 'unsupported-node-shape: textBreak parts two text nodes: an image description holds plain text') assert.equal(content(markdownToAdf('![a!adf:textBreak{}b](/i)\n')), 'unsupported-node-shape: delete !adf:textBreak{} from this image description: the description reads as plain alt text')
assert.equal(content(markdownToAdf('a!adf:textBreak{x=y}b\n')), 'unsupported-node-shape: textBreak spells the bare leaf form, !adf:textBreak{}: this one spells more') assert.equal(content(markdownToAdf('a!adf:textBreak{x=y}b\n')), 'unsupported-node-shape: textBreak spells the bare leaf form, !adf:textBreak{}: this one spells more')
assert.deepEqual(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"textBreak\\"}"}\n')), [{ content: [{ type: 'textBreak' }], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"textBreak\\"}"}\n')), [{ content: [{ type: 'textBreak' }], type: 'paragraph' }])
}) })
test('leads a refusal ordinary editing meets with the edit that fixes it', () => {
assert.equal(
content(markdownToAdf('!adf:paragraph {content=empty}\nText.\n!adf:/paragraph\n')),
'unsupported-node-shape: remove content=empty to give the paragraph a body: content=empty holds none, and this one holds one',
)
assert.equal(
content(markdownToAdf('!adf:codeBlock\n```\na\n```\n```\n```\n!adf:/codeBlock\n')),
'unsupported-node-shape: delete the empty fence: a fence beside another holds code, and this one holds none',
)
assert.equal(
content(markdownToAdf('!adf:codeBlock {attrs=empty}\n```js\na\n```\n```js\nb\n```\n!adf:/codeBlock\n')),
"unsupported-node-shape: remove attrs=empty to give the codeBlock the fence's language: attrs=empty holds no language, and this fence names one",
)
assert.equal(
content(markdownToAdf('!adf:panel info {attrs=empty}\nText.\n!adf:/panel\n')),
'unsupported-node-shape: panel spells attrs=empty beside another attribute, an argument or a [content] slot: drop attrs=empty, or the rest',
)
})
+28 -16
View File
@@ -2,23 +2,24 @@ 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 { Flavour } from '../plain-conventions.ts' import type { Flavour } from '../plain/conventions.ts'
import type { LineContainer } from '../line-container.ts' import type { LineContainer } from '../line-container.ts'
import type { LinkDefinitions } from './inline-content.ts' import type { LinkDefinitions } from './inline-content.ts'
import { carryFencePrefix, readCarriedBlock } from '../opaque-carry.ts' import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts' import { documentAttribute, documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts'
import { emptyKeys, nodeAttrs, nodeContent } from '../../adf/document.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 { inlineLeaves } from '../plain/inline-reduction.ts'
import { languageSlot } from '../code-language.ts' import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain-conventions.ts' import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain/conventions.ts'
import { mintTaskIds } from './task-ids.ts' import { mintTaskIds } from '../plain/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 { spellStringAttribute, unsupportedNodeShape } from '../directive-syntax.ts'
import { spellsEmpty } from '../empty-keys.ts'
type Paragraph = Extract<Block, { kind: 'paragraph' }> type Paragraph = Extract<Block, { kind: 'paragraph' }>
@@ -58,7 +59,7 @@ function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErr
for (const [index, block] of blocks.entries()) { for (const [index, block] of blocks.entries()) {
const nodePath = [...path, 'content', content.length] const nodePath = [...path, 'content', content.length]
if (block.kind === 'directive' && block.name === documentName) { if (block.kind === 'directive' && block.name === documentName) {
const fault = documentFault(block) ?? unsupportedNodeShape(`${documentSpelling} spells a whole document holding no content key, alone: this one stands among other blocks`) const fault = documentFault(block) ?? unsupportedNodeShape(`delete the ${documentSpelling} line to give the document content: it stands only as the whole document`)
return positioned(faulted(fault, nodePath), block.position) return positioned(faulted(fault, nodePath), block.position)
} }
if (block.kind === 'directive' && block.name === listBreakName) { if (block.kind === 'directive' && block.name === listBreakName) {
@@ -83,8 +84,11 @@ function listBreakFault(block: DirectiveBlock, previous: Block | undefined, next
} }
function documentFault(block: DirectiveBlock): ConvertFault | undefined { function documentFault(block: DirectiveBlock): ConvertFault | undefined {
const content = block.attributes.get('content') const spelled = block.attributes.get(documentAttribute.key)
if (block.argument === undefined && block.attributes.size === 1 && content?.spelling === 'none') return undefined if (block.argument === undefined && block.attributes.size === 1 && spelled?.spelling === documentAttribute.value) return undefined
if (block.argument === undefined && block.attributes.size === 1 && spellsEmpty(spelled)) {
return unsupportedNodeShape(`an empty document is empty markdown, and ${documentSpelling} spells a document holding no content key: this one spells ${documentAttribute.key}=empty`)
}
return unsupportedNodeShape(`${documentName} spells the one form ${documentSpelling}: this one spells another`) return unsupportedNodeShape(`${documentName} spells the one form ${documentSpelling}: this one spells another`)
} }
@@ -229,9 +233,10 @@ function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErr
} }
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> { function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const { contentModel, emptyContent, node } = read const { contentModel, node } = read
if (blocks === undefined) return success(node) if (blocks === undefined) return success(node)
if (emptyContent) return blocks.length === 0 ? success(node) : failure('unsupported-node-shape', `${node.type} spells content=empty, which holds no body: this one holds one`, path) // A directive builds a content key only from content=empty, which holds no body.
if (node.content !== undefined) return blocks.length === 0 ? success(node) : failure('unsupported-node-shape', `remove content=empty to give the ${node.type} a body: content=empty holds none, and this one holds one`, path)
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path) if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path) if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path)
return containerNode(node, blocks, reading, path, depth) return containerNode(node, blocks, reading, path, depth)
@@ -247,13 +252,19 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver
const [first] = fences const [first] = fences
if (first === undefined) return failure('unsupported-node-shape', `${node.type} takes code blocks as its body: this body holds none`, path) if (first === undefined) return failure('unsupported-node-shape', `${node.type} takes code blocks as its body: this body holds none`, path)
if (fences.some((fence) => fence.language !== first.language)) return failure('unsupported-node-shape', `${node.type} holds one language, so its fences carry one info string: these differ`, path) if (fences.some((fence) => fence.language !== first.language)) return failure('unsupported-node-shape', `${node.type} holds one language, so its fences carry one info string: these differ`, path)
if (fences.length > 1 && fences.some((fence) => fence.text === '')) return failure('unsupported-node-shape', `a fence beside another spells a text node, which holds text: this one is empty`, path) if (fences.length > 1 && fences.some((fence) => fence.text === '')) return failure('unsupported-node-shape', `delete the empty fence: a fence beside another holds code, and this one holds none`, path)
const attribute = nodeAttrs(node)['language'] const attribute = nodeAttrs(node)['language']
const fromFence = first.language !== '' const fromFence = first.language !== ''
const slot = languageSlot(fromFence ? first.language : attribute) const slot = languageSlot(fromFence ? first.language : attribute)
if (!fromFence && slot.kind === 'fence') {
return failure('unsupported-node-shape', `move language=${spellStringAttribute(slot.info)} to the fences' info strings: they can spell this language`, path)
}
if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) { if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) {
return failure('unsupported-node-shape', `${node.type} spells its language in the fence info string, or in the attribute where no info string carries it back`, path) return failure('unsupported-node-shape', `${node.type} spells its language in the fence info string, or in the attribute where no info string carries it back`, path)
} }
if (fromFence && emptyKeys(node).includes('attrs')) {
return failure('unsupported-node-shape', `remove attrs=empty to give the ${node.type} the fence's language: attrs=empty holds no language, and this fence names one`, path)
}
const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: first.language } } : node const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: first.language } } : node
return success(withContent(spelled, first.text === '' ? [] : fences.map((fence): AdfNode => ({ text: fence.text, type: 'text' })))) return success(withContent(spelled, first.text === '' ? [] : fences.map((fence): AdfNode => ({ text: fence.text, type: 'text' }))))
} }
@@ -302,8 +313,9 @@ function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, pa
} }
function codeBlockNode(language: string, text: string, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> { function codeBlockNode(language: string, text: string, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
if (language.startsWith(carryFencePrefix)) { const carriedType = carryFenceType(language)
const carried = readCarriedBlock(language.slice(carryFencePrefix.length), text, depth) if (carriedType !== undefined) {
const carried = readCarriedBlock(carriedType, 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) reading.carried.add(carried.value)
return success(carried.value) return success(carried.value)
@@ -3,7 +3,7 @@ import test from 'node:test'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import { adfToMarkdown } from '../emit/adf-to-markdown.ts' import { adfToMarkdown } from '../emit/adf-to-markdown.ts'
import { adfToPlainMarkdown } from '../emit/plain-reduction.ts' import { adfToPlainMarkdown } from '../plain/adf-to-plain-markdown.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts' import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
@@ -30,10 +30,6 @@ function read(markdown: string): readonly AdfNode[] | string {
return blocks return blocks
} }
function blocks(...content: AdfNode[]): readonly AdfNode[] {
return content
}
function document(...content: AdfNode[]): AdfDocument { function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 } return { content, type: 'doc', version: 1 }
} }
@@ -100,7 +96,7 @@ test('reads the rest of an alert marker line as the panel first body paragraph,
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('> [!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> Broken.\n'), [panel('info', said('Broken.'))])
assert.deepEqual(read('> [!NOTE]\n'), blocks(panel('info', paragraph()))) assert.deepEqual(read('> [!NOTE]\n'), [panel('info', paragraph())])
assert.deepEqual(read('> > [!WARNING]\n> > Inner.\n'), [bare('blockquote', panel('warning', said('Inner.')))]) assert.deepEqual(read('> > [!WARNING]\n> > Inner.\n'), [bare('blockquote', panel('warning', said('Inner.')))])
}) })
@@ -119,27 +115,27 @@ test('reads a folded callout to an expand titled by the rest of its marker line,
node('expand', { title: 'Why?' }, paragraph(text('See '), text('the docs', link), text(', '), text('now', strong), text('.'))), 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('> [!NOTE]- Two\\\n> lines\n'), [node('expand', { title: 'Two' }, said('lines'))])
assert.deepEqual(read('> [!faq]- See [x](http://y)\n'), blocks(node('expand', { title: 'See x (http://y)' }, paragraph()))) assert.deepEqual(read('> [!faq]- See [x](http://y)\n'), [node('expand', { title: 'See x (http://y)' }, paragraph())])
assert.deepEqual(read('> [!faq]- [a **b**](u "t")[c](u) and [d](v)\n'), blocks(node('expand', { title: 'a bc (u) and d (v)' }, paragraph()))) assert.deepEqual(read('> [!faq]- [a **b**](u "t")[c](u) and [d](v)\n'), [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'), blocks(node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph()))) assert.deepEqual(read('> [!faq]- <http://y> or <a@b.c> or <http://a\\b>\n'), [node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph())])
assert.deepEqual(read('> [!faq]- [a&#10;b](u)\n'), blocks(node('expand', { title: 'a\nb (u)' }, paragraph()))) assert.deepEqual(read('> [!faq]- [a&#10;b](u)\n'), [node('expand', { title: 'a\nb (u)' }, paragraph())])
assert.deepEqual(read('> [!faq]- [!adf:mention[@M]{id=5}](u) !adf:inlineCard{url="http://y"}\n'), blocks(node('expand', { title: '@M (u) http://y' }, paragraph()))) assert.deepEqual(read('> [!faq]- [!adf:mention[@M]{id=5}](u) !adf:inlineCard{url="http://y"}\n'), [node('expand', { title: '@M (u) http://y' }, paragraph())])
assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), blocks(node('expand', { title: 'Set ==x== here' }, paragraph()))) assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), [node('expand', { title: 'Set ==x== here' }, paragraph())])
assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))]) assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))])
}) })
test('reads a folded callout inside an expand to a nested expand', () => { 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' const markdown = '> [!NOTE]- Outer\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n>\n> > [!TIP]\n> >\n> > > [!NOTE]-\n'
assert.deepEqual(read(markdown), blocks(node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph()))))) assert.deepEqual(read(markdown), [node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph())))])
assert.deepEqual(read('- > [!NOTE]-\n'), blocks(bare('bulletList', bare('listItem', bare('expand', paragraph()))))) assert.deepEqual(read('- > [!NOTE]-\n'), [bare('bulletList', bare('listItem', bare('expand', paragraph())))])
assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), blocks(bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph())))) assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), [bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph()))])
}) })
test('reads a bullet list whose every item leads with a task marker to a task list', () => { 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'), [ 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'))), 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'), blocks(bare('taskList', task('DONE'), task('TODO', text('after'))))) assert.deepEqual(read('- [x]\n- [ ]\\\n after\n'), [bare('taskList', task('DONE'), task('TODO', text('after')))])
const minted = plainMarkdownToAdf('- [x] Parent\n - [ ] Child\n') const minted = plainMarkdownToAdf('- [x] Parent\n - [ ] Child\n')
assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [ assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [
node( node(
@@ -201,7 +197,7 @@ test('reads what the reduction wrote back to the node it reduced, less the attri
assert.deepEqual(roundTripped(node('panel', { localId, panelType }, said('Check.'))), [panel(panelType, said('Check.'))], panelType) 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.'))) 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())), blocks(panel('tip', paragraph()), node('expand', { title: 'Empty' }, paragraph()))) assert.deepEqual(roundTripped(node('panel', { panelType: 'tip' }, paragraph()), node('expand', { title: 'Empty' }, paragraph())), [panel('tip', paragraph()), node('expand', { title: 'Empty' }, paragraph())])
assert.deepEqual(roundTripped(expand), [node('expand', { title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))]) assert.deepEqual(roundTripped(expand), [node('expand', { title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))])
const tasks = bare( const tasks = bare(
'taskList', 'taskList',
@@ -265,3 +261,8 @@ test('keeps what markdownToAdf reads that no row reads, and refuses only what it
for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}` for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}`
assert.equal(typeof read(deep), 'object') assert.equal(typeof read(deep), 'object')
}) })
test('joins a carried text node to no neighbour, inside a highlight too', () => {
const carried = '!adf:carry{json="{\\"marks\\":[],\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}'
assert.deepEqual(read(`==a${carried}==\n`), [paragraph(text('a', highlight), { marks: [], text: 'b', type: 'text' })])
})
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import { adfToPlainMarkdown, reduceToPlain } from './plain-reduction.ts' import { adfToPlainMarkdown, reduceToPlain } from './adf-to-plain-markdown.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
const code: AdfMark = { type: 'code' } const code: AdfMark = { type: 'code' }
@@ -313,12 +313,13 @@ test('breaks a line at a newline and trims whitespace at every edge CommonMark s
assert.equal(plain(paragraph(text(' x ', code))), '` x `\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(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n')
assert.equal(plain(paragraph(text('a\r\u0000b'))), 'ab\n') assert.equal(plain(paragraph(text('a\r\u0000b'))), 'ab\n')
assert.equal(plain(paragraph(text('x', link('/u', 't\r\u0000')))), '[x](/u "t")\n')
assert.equal(plain(paragraph({ content: [text('dropped')], text: 'a', type: 'text' }, text('b'))), 'ab\n')
}) })
test('drops the code mark of a span opening a line with backticks that read as a fence', () => { test('keeps a code span opening a line, whose backticks open no fence', () => {
assert.equal(plain(paragraph(text('``` x', code))), '\\`\\`\\` x\n') 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\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', () => { test('drops an empty paragraph and merges adjacent lists of one type', () => {
@@ -344,3 +345,9 @@ test('writes what the editor-normal form of the document writes', () => {
assert.equal(plain({ content: [], text: 'hi', type: 'futureInline' }), 'hi\n') assert.equal(plain({ content: [], text: 'hi', type: 'futureInline' }), 'hi\n')
assert.equal(plain(paragraph({ marks: [{ attrs: {}, type: 'code' }], text: 'a', type: 'text' }, { marks: [{ type: 'code' }], text: '|b', type: 'text' })), '`a|b`\n') assert.equal(plain(paragraph({ marks: [{ attrs: {}, type: 'code' }], text: 'a', type: 'text' }, { marks: [{ type: 'code' }], text: '|b', type: 'text' })), '`a|b`\n')
}) })
test('writes a text node holding content the same in either order beside its neighbour', () => {
const holding: AdfNode = { content: [text('inner')], text: 'a', type: 'text' }
assert.equal(plain(paragraph(holding, text('b'))), 'ab\n')
assert.equal(plain(paragraph(text('b'), holding)), 'ba\n')
})
@@ -1,14 +1,14 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts' import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from './adf-to-markdown.ts' import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts' import { blockNodeModel } from '../../adf/block-nodes.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './plain-inline.ts' import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './inline-reduction.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { languageSlot } from '../code-language.ts' import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { taskMarker } from '../plain-conventions.ts' import { taskMarker } from './conventions.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts' import { toEditorNormal } from './editor-normal.ts'
// depth: the level the node reduced stands at, counted as the emitter counts it. // depth: the level the node reduced stands at, counted as the emitter counts it.
type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath } type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
@@ -54,11 +54,11 @@ export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
// docs/decisions.md §Equality is deep: the reduction reads and writes editor-normal ADF. // docs/decisions.md §Equality is deep: the reduction reads and writes editor-normal ADF.
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] }) const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
if (!blocks.ok) { if (!blocks.ok) {
// Merging text renumbers siblings, so the caller's document names the refusal's path. // Merging text renumbers siblings, so a refusal's path comes from the caller's document.
const raw = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] }) const raw = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
return raw.ok ? blocks : raw return !raw.ok && raw.error.code === blocks.error.code ? raw : blocks
} }
return success({ content: nodeContent(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 })).slice(), type: 'doc', version: 1 }) return success(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 }))
} }
function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> { function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
@@ -1,4 +1,4 @@
import { isWordCharacter } from './commonmark/emphasis-matching.ts' import { isWordCharacter } from '../commonmark/emphasis-matching.ts'
export type Flavour = 'lossless' | 'plain' export type Flavour = 'lossless' | 'plain'
@@ -1,8 +1,8 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { AdfNode } from './document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../../json-value.ts'
import { toEditorNormal } from './editor-normal.ts' import { toEditorNormal } from './editor-normal.ts'
test('merges adjacent text nodes carrying identical marks, at every level', () => { test('merges adjacent text nodes carrying identical marks, at every level', () => {
@@ -50,14 +50,15 @@ test('reads negative zero as zero, as JSON does', () => {
) )
}) })
test('reads an empty attrs object, marks array or content array as the absent key', () => { test('reads an empty attrs object, marks array or content array as the absent key, except on doc', () => {
const paragraph: AdfNode = { attrs: {}, content: [{ attrs: {}, marks: [], text: 'a', type: 'text' }, { marks: [{ attrs: {}, type: 'em' }], text: 'b', type: 'text' }], marks: [], type: 'paragraph' } const paragraph: AdfNode = { attrs: {}, content: [{ attrs: {}, marks: [], text: 'a', type: 'text' }, { marks: [{ attrs: {}, type: 'em' }], text: 'b', type: 'text' }], marks: [], type: 'paragraph' }
assert.deepEqual(toEditorNormal({ content: [paragraph, { content: [], type: 'rule' }], type: 'doc', version: 1 }), { assert.deepEqual(toEditorNormal({ content: [paragraph, { content: [], type: 'rule' }], type: 'doc', version: 1 }), {
content: [{ content: [{ text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: 'b', type: 'text' }], type: 'paragraph' }, { type: 'rule' }], content: [{ content: [{ text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: 'b', type: 'text' }], type: 'paragraph' }, { type: 'rule' }],
type: 'doc', type: 'doc',
version: 1, version: 1,
}) })
assert.deepEqual(toEditorNormal({ content: [], type: 'doc', version: 1 }), { type: 'doc', version: 1 }) assert.deepEqual(toEditorNormal({ content: [], type: 'doc', version: 1 }), { content: [], type: 'doc', version: 1 })
assert.deepEqual(toEditorNormal({ type: 'doc', version: 1 }), { type: 'doc', version: 1 })
}) })
test('normalizes blocks and mark attributes nesting far past the levels a recursive walk survives', () => { test('normalizes blocks and mark attributes nesting far past the levels a recursive walk survives', () => {
@@ -75,3 +76,10 @@ test('normalizes blocks and mark attributes nesting far past the levels a recurs
const merged = toEditorNormal({ content: [{ content: [{ marks, text: 'a', type: 'text' }, { marks, text: 'b', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }) const merged = toEditorNormal({ content: [{ content: [{ marks, text: 'a', type: 'text' }, { marks, text: 'b', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 })
assert.deepEqual(merged.content?.[0]?.content?.map((text) => text.text), ['ab']) assert.deepEqual(merged.content?.[0]?.content?.map((text) => text.text), ['ab'])
}) })
test('drops the content a text node holds, and joins the node as any other', () => {
const holding: AdfNode = { content: [{ text: 'dropped', type: 'text' }], text: 'a', type: 'text' }
const normal = (content: AdfNode[]): AdfNode[] | undefined => toEditorNormal({ content: [{ content, type: 'paragraph' }], type: 'doc', version: 1 }).content
assert.deepEqual(normal([holding, { text: 'b', type: 'text' }]), [{ content: [{ text: 'ab', type: 'text' }], type: 'paragraph' }])
assert.deepEqual(normal([{ text: 'b', type: 'text' }, holding]), [{ content: [{ text: 'ba', type: 'text' }], type: 'paragraph' }])
})
@@ -1,32 +1,37 @@
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../../json-value.ts'
import { identicalMark, identicalMarks, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from './document.ts' import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { joinsWhenRead } from '../adjacent-text.ts'
type JsonContainer = JsonValue[] | { [key: string]: JsonValue } type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
type NodeHolder = { content?: AdfNode[] } type NodeHolder = { content?: AdfNode[] }
export function sameMark(left: AdfMark, right: AdfMark): boolean { // The editor joins two text nodes where the reader would join their editor-normal forms.
return identicalMark(normalMark(left), normalMark(right)) export function joinsWhenEditorNormal(previous: AdfNode, node: AdfNode): boolean {
if (previous.type !== 'text' || node.type !== 'text') return false
return joinsWhenRead(normalNode(previous), normalNode(node))
} }
export function joinsNormally(previous: AdfNode, node: AdfNode): boolean { export function sameMarkWhenEditorNormal(left: AdfMark, right: AdfMark): boolean {
if (!mergesText(previous) || !mergesText(node)) return false return identicalMark(normalMark(left), normalMark(right))
return identicalMarks(nodeMarks(previous).map(normalMark), nodeMarks(node).map(normalMark))
} }
export function toEditorNormal(document: AdfDocument): AdfDocument { export function toEditorNormal(document: AdfDocument): AdfDocument {
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version } const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }] const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) { for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
const content = mergeAdjacentText(nodeContent(entry.source), joinsNormally) const content = mergeAdjacentText(nodeContent(entry.source), joinsWhenEditorNormal)
if (content.length === 0) continue if (content.length === 0) continue
entry.holder.content = content.map((source) => { entry.holder.content = content.map((source) => {
const holder = normalNode(source) const holder = normalNode(source)
pending.push({ holder, source }) // Atlassian's schema forbids content on a text node, so the editor drops it.
if (source.type !== 'text') pending.push({ holder, source })
return holder return holder
}) })
} }
// ADF's schema requires content on doc, so an empty one stays.
if (document.content !== undefined && normal.content === undefined) normal.content = []
return normal return normal
} }
@@ -63,7 +68,3 @@ function normalValue(value: JsonValue, pending: JsonContainer[]): JsonValue {
pending.push(copy) pending.push(copy)
return copy return copy
} }
function mergesText(node: AdfNode): boolean {
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
}
@@ -1,12 +1,12 @@
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
import type { LineContainer } from '../line-container.ts' import type { LineContainer } from '../line-container.ts'
import type { MarkRun } from './line-escaping.ts' import type { MarkRun } from '../emit/line-escaping.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts' import { blockNodeModel } from '../../adf/block-nodes.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { joinsNormally, sameMark } from '../../adf/editor-normal.ts' import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { plainLineFallback, type PlainLineFallback } from './inline-line.ts' import { plainLineFallback, type PlainLineFallback } from '../emit/inline-line.ts'
import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
const highlight = 'backgroundColor' const highlight = 'backgroundColor'
@@ -149,7 +149,7 @@ function plainMarks(marks: readonly AdfMark[], container: LineContainer, text: s
function plainLink(mark: AdfMark, container: LineContainer): AdfMark | undefined { function plainLink(mark: AdfMark, container: LineContainer): AdfMark | undefined {
const attrs = nodeAttrs(mark) const attrs = nodeAttrs(mark)
const held = attrs['href'] const held = attrs['href']
const title = typeof attrs['title'] === 'string' ? attrs['title'].replace(/\r/g, '').replace(/\n/g, ' ') : undefined const title = typeof attrs['title'] === 'string' ? attrs['title'].replace(/[\r\0]/g, '').replace(/\n/g, ' ') : undefined
if (typeof held !== 'string') return undefined if (typeof held !== 'string') return undefined
const href = writableHref(container === 'table-cell' ? held.replaceAll('|', '%7C') : held) 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' } if (title === undefined || spellLinkTarget(href, title) === undefined || (container === 'table-cell' && title.includes('|'))) return { attrs: { href }, type: 'link' }
@@ -175,11 +175,11 @@ function highlighted(leaves: readonly AdfNode[]): AdfNode[] {
const marks = nodeMarks(leaf) const marks = nodeMarks(leaf)
if (marks[0]?.type === highlight) { if (marks[0]?.type === highlight) {
const held = marks.slice(1) const held = marks.slice(1)
shared = run.length === 0 ? held : shared.filter((mark) => held.some((other) => sameMark(other, mark))) shared = run.length === 0 ? held : shared.filter((mark) => held.some((other) => sameMarkWhenEditorNormal(other, mark)))
run.push(leaf) run.push(leaf)
continue continue
} }
for (const held of run) spelled.push(withMarks(held, [...shared, highlightMark, ...nodeMarks(held).slice(1).filter((mark) => !shared.some((other) => sameMark(other, mark)))])) for (const held of run) spelled.push(withMarks(held, [...shared, highlightMark, ...nodeMarks(held).slice(1).filter((mark) => !shared.some((other) => sameMarkWhenEditorNormal(other, mark)))]))
run = [] run = []
spelled.push(leaf) spelled.push(leaf)
} }
@@ -193,7 +193,7 @@ function withMarks(leaf: AdfNode, marks: readonly AdfMark[]): AdfNode {
function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] { function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] {
for (let current = leaves; ; ) { for (let current = leaves; ; ) {
const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsNormally)) const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsWhenEditorNormal))
let changed = false let changed = false
const trimmed: AdfNode[] = [] const trimmed: AdfNode[] = []
for (const [index, leaf] of merged.entries()) { for (const [index, leaf] of merged.entries()) {
@@ -251,7 +251,7 @@ function edgeDepth(marks: readonly AdfMark[], neighbour: AdfNode | undefined, wh
function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index: number): boolean { function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index: number): boolean {
const mark = marks[index] const mark = marks[index]
const other = others[index] const other = others[index]
return mark !== undefined && other !== undefined && sameMark(mark, other) return mark !== undefined && other !== undefined && sameMarkWhenEditorNormal(mark, other)
} }
function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<AdfNode[]> { function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<AdfNode[]> {
@@ -267,21 +267,13 @@ function spellableLine(leaves: AdfNode[], container: LineContainer, path: Conver
function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback): AdfNode[] | undefined { function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback): AdfNode[] | undefined {
if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, fallback.runs) 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[0] ?? {})[0]
const mark = nodeMarks(leaves[first] ?? {})[0] if (mark === undefined || mark.type !== 'link') return undefined
if (mark === undefined || mark.type !== (fallback.kind === 'opening-link' ? 'link' : 'code')) return undefined let last = 0
let last = first
while (sameMarkAt(nodeMarks(leaves[last + 1] ?? {}), [mark], 0)) last += 1 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. // 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') const spans = leaves.slice(0, 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 spans ? leaves.map((leaf, index) => (index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code')))) : undefined
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. // The runs cover disjoint leaves, so one pass drops them all.
-16
View File
@@ -1,16 +0,0 @@
import type { AdfNode } from '../adf/document.ts'
import { identicalMarks, nodeMarks } from '../adf/document.ts'
import { spellInlineLeafDirective } from './directive-syntax.ts'
export const textBreakName = 'textBreak'
export const textBreakSpelling = spellInlineLeafDirective(textBreakName, '')
// Whether CommonMark reads the pair back as one text node, where neither rides the carry (spec/flavour.md, Inline nodes).
export function readsAsOne(previous: AdfNode, node: AdfNode): boolean {
return spelledAsText(previous) && spelledAsText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node))
}
function spelledAsText(node: AdfNode): boolean {
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
}
+2 -2
View File
@@ -2,7 +2,7 @@ import type { DirectiveSpan, Read } from './directive-syntax.ts'
import { readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' import { readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
const name = 'text' const name = 'text'
const whitespaceRun = /^(?:[ \t]+|\n+)$/ const spelledRun = /^(?:[ \t]+|\n+|\0+)$/
export const textDirectiveName = name export const textDirectiveName = name
@@ -14,6 +14,6 @@ export function readTextDirective(span: DirectiveSpan): Read<string> | undefined
if (span.name !== name) return undefined if (span.name !== name) return undefined
const spelled = readSoleStringAttribute(span, name) const spelled = readSoleStringAttribute(span, name)
if (spelled.fault !== undefined) return spelled if (spelled.fault !== undefined) return spelled
if (!whitespaceRun.test(spelled.value)) return { fault: unsupportedNodeShape(`${name} spells one run of spaces and tabs, or one run of newlines: this one spells neither`) } if (!spelledRun.test(spelled.value)) return { fault: unsupportedNodeShape(`${name} spells one run of spaces and tabs, one run of newlines, or one run of null characters: this one spells none of them`) }
return spelled return spelled
} }
-2
View File
@@ -5,8 +5,6 @@ export type ConvertErrorCode =
| 'unknown-directive-name' | 'unknown-directive-name'
| 'unmappable-html' | 'unmappable-html'
| 'unmappable-image' | 'unmappable-image'
| 'unspellable-character'
| 'unspellable-line-start'
| 'unspellable-whitespace' | 'unspellable-whitespace'
| 'unsupported-document-version' | 'unsupported-document-version'
| 'unsupported-nesting-depth' | 'unsupported-nesting-depth'
+205 -77
View File
@@ -6,7 +6,7 @@
`Bar = 9` `Bar = 9`
`Next ID = 55` `Next ID = 74`
| Goal | W | | Goal | W |
|---|---| |---|---|
@@ -24,34 +24,56 @@
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score | | ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|
| 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 3 | 25.8 | | 47 | 0.2.0 | | **Open the README with what the package is, what it does and for whom.** | 1 | 4 | 7 | 9 | 3 | 26.0 |
| 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 4 | 25.8 |
| 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 | | 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
| 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 3 | 24.7 | | 67 | 0.2.0 | defect | **Push an expand title's leaves one at a time, so a title carrying many text nodes returns a result.** | 1 | 1 | 4 | 9 | 1 | 24.8 |
| 43 | 0.2.0 | | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 3, 4 | 22.3 | | 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 4 | 24.7 |
| 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 | | 64 | 0.2.0 | defect | **Read back unchanged on V8 every JSON key the emitter writes, with a fixture whose key holds `\`, `"` or a control character.** | 4 | 4 | 6 | 8 | 1, 8 | 23.0 |
| 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 5 | 13.0 | | 66 | 0.2.0 | defect | **Refuse an attribute value that is not a plain object, array or JSON primitive.** | 3 | 2 | 5 | 8 | 1 | 22.5 |
| 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 3, 4 | 12.4 | | 71 | 0.2.0 | | **Add a README table comparing the package with the other ADF converters, each cell checked against that package's own docs or behaviour.** | 1 | 3 | 6 | 7 | 3 | 21.2 |
| 54 | 0.2.0 | | **Make `docs/decisions.md` §The source parts by ADF and format name every import `parse/` takes from `emit/`.** | 2 | 2 | 2 | 5 | 1 | 11.5 | | 43 | 0.2.0 | decision | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 4, 5 | 20.3 |
| 53 | 0.2.0 | | **Bench a block's `marks` spelling with the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 5 | 11.5 | | 68 | 0.2.0 | | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 3 | 3 | 4 | 7 | 1 | 18.2 |
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 3, 4, 6 | 10.4 | | 65 | 0.2.0 | defect | **Read an image not alone in its paragraph as its alt text linked to its URL, and a titled image alone in its paragraph as the image captioned with its title.** | 5 | 5 | 7 | 7 | 4, 5, 7 | 17.1 |
| 70 | 0.2.0 | | **Give `package.json` the `keywords` someone searching npm for an ADF converter types.** | 1 | 1 | 4 | 6 | 3 | 16.1 |
| 72 | 0.2.0 | | **Make GitHub the canonical repository, with `gitea.larvit.se/larvit/adf-codec` a read-only pull mirror of it.** | 5 | 6 | 5 | 8 | 3 | 16.0 |
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`.** | 4 | 5 | 5 | 8 | 4, 5 | 15.5 |
| 46 | 0.2.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 3, 8 | 14.8 |
| 61 | 0.2.0 | decision | **Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.** | 6 | 8 | 3 | 8 | 1 | 14.0 |
| 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 6 | 11.5 |
| 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 4, 5 | 10.9 |
| 60 | 0.2.0 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 2 | 6 | 2 | 10.7 |
| 59 | 0.2.0 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` to `markdown/flavour.ts`.** | 3 | 5 | 2 | 6 | 2 | 10.4 |
| 53 | 0.2.0 | | **Put a block's `marks` spelling to the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 6 | 10.0 |
| 62 | 0.2.0 | decision | **Move the plain flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/plain/`, so `emit/` no longer imports `plain/`.** | 4 | 5 | 2 | 6 | 2 | 9.4 |
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 4, 5, 7 | 9.0 |
| 73 | 0.3.0 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 6 | 8 | 3 | 23.0 |
| 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 | 3 | 21.0 |
| 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 | | 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 | 4, 5 | 11.3 |
| 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 | | 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 | | 42 | 0.3.0 | defect | **Trim a text leaf's trailing blanks in linear time.** | 1 | 2 | 5 | 9 | 9 | 10.5 |
| 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 | | 33 | 0.3.0 | defect | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 9 | 8.7 |
| 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 | | 56 | 0.3.0 | principle | **Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.** | 4 | 5 | 2 | 4 | 1 | 6.8 |
| 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 9 | 10.6 | | 57 | 0.3.0 | principle | **Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.** | 2 | 3 | 1 | 3 | 8 | 0.6 |
| 58 | 0.3.0 | principle | **Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.** | 4 | 6 | 1 | 2 | 8 | -2.6 |
| 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 3 | 18.6 |
## Details ## Details
### 47. Open the README with what the package is, what it does and for whom.
Lands after item 72: the badges are npm's version and the GitHub Actions status. Today the README
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 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.
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF. ### 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 Lands after items 6, 59, 60, 61 and 62. The CommonMark spec suite also runs against
documents HTML as it documents markdown, and its tagline and `package.json`'s `description` regain `markdownToHtml`. The README documents HTML as it documents markdown.
HTML.
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`. ### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
@@ -59,30 +81,124 @@ Goal 1 has every call return a result; the boolean guard is the one export that
cannot say which branch refused, where `not-an-adf-document`'s message already does. Breaking: cannot say which branch refused, where `not-an-adf-document`'s message already does. Breaking:
`MIGRATION.md` shows the guard's replacement. `MIGRATION.md` shows the guard's replacement.
### 67. Push an expand title's leaves one at a time, so a title carrying many text nodes returns a result.
`quoteNode`'s title loop (`parse/markdown-to-adf.ts`) spreads `inlineLeaves`'s result into
`push`, against `docs/decisions.md` §Nothing spreads an unbounded array: an inline carry in a
folded callout's title holding an unknown node with ~125k text children throws a `RangeError` from
`plainMarkdownToAdf`. Found by the README-goals audit, 2026-10-04.
### 6. Specify the HTML dialect. ### 6. Specify the HTML dialect.
Element-by-element mapping, the `data-*` fidelity scheme, the opaque-carry form, and the documented 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 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. HTML in input). The set sorts per `docs/decisions.md` §Foreign HTML sorts three ways.
### 64. Read back unchanged on V8 every JSON key the emitter writes, with a fixture whose key holds `\`, `"` or a control character.
`property-harness.ts` strips `\`, `"` and control characters from generated JSON keys, citing V8's
`JSON.parse` returning a wrong key for an escaped backslash. The library reads `json` attributes
(`directive-syntax.ts`) and both carries (`opaque-carry.ts`) through that `JSON.parse`, so on Node,
Deno and Chrome the round-trip would refuse its own output or read back a different key; the fixture
tells which. Confirm with a fixture first; then read JSON with our own parser or record the gap with
an ending item. Found by the README-goals audit, 2026-10-03.
### 66. Refuse an attribute value that is not a plain object, array or JSON primitive.
`isJsonValue` (`json-value.ts`) walks any object by `Object.values`, so a `Date`, `Map`, `Set` or
typed array in `attrs`, as `structuredClone` keeps it, passes the guard and is written as `{}` or a
key per element: `ok: true` with content lost. Accept an object whose prototype is
`Object.prototype` or `null`, and an array. Found by the stability review of item 63, 2026-10-04.
### 71. Add a README table comparing the package with the other ADF converters, each cell checked against that package's own docs or behaviour.
Lands after items 7, 46 and 47: the table names HTML, compares item 46's size figure, and sits in
the README item 47 reshapes. Its columns: which directions each converts, whether the round-trip
holds, what an unknown node does, the formats, runtime dependencies and size. Candidates are the
packages an npm search for ADF and markdown returns, marklassian among them. Every cell cites that
package's README or a run against its published version, and the table names each version and the
date it was checked. Found while planning discoverability, 2026-10-04.
### 43. Give each markdown input its own reader, strict to its own standard. ### 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 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 directive, a pipe table or a `~~` pair becomes a flavour node where CommonMark reads plain text, and
a code fence whose info string opens `adf:` becomes the block carry where CommonMark reads code. A
caller names the markdown it hands in: CommonMark, read as its spec says, or the lossless flavour, 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. 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. ### 68. Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.
`refuseContentAndText` (`emit/inline-line.ts`) and `emitDirectiveBlock` (`emit/adf-to-markdown.ts`)
refuse these with `unsupported-node-shape`, though `isAdfDocument` accepts them and the carry
round-trips them; `docs/decisions.md` §The code list gives a cause the carry answers no code. Drop
them from the emitting half of the README's `unsupported-node-shape` row. Found by the README-goals
audit, 2026-10-04.
### 65. Read an image not alone in its paragraph as its alt text linked to its URL, and a titled image alone in its paragraph as the image captioned with its title.
Both are refused today with `unmappable-image`: 14 examples in
`corpus/commonmark-spec/refusals.json`, a bot's `See ![diagram](url) here` among them. `mediaInline`
takes only Atlassian media ids, never a URL. An image not alone in its paragraph reads as its alt
text, linked to its URL. Its title becomes the link's `title`. An empty alt text reads as the URL,
as item 50 reads an empty link. A titled image alone in its paragraph reads as the image, with its
title as a `caption` (the maintainer, 2026-10-04). By Goal 5, the lossless flavour reads both as
CommonMark does. An image inside a link: `[![moon](moon.jpg)](/uri)` (spec example 517). Alone in
its paragraph, it reads as the image with a `link` mark to `/uri`, which ADF's `media` takes. Not
alone in its paragraph, the image reads as its alt text linked to `/uri`, and its URL drops. A
writer panel chose this reading 3 of 3 (2026-10-04), by Goal 6, which outranks Goal 7.
### 70. Give `package.json` the `keywords` someone searching npm for an ADF converter types.
`package.json` has none, and npm's search ranks on them. Take the terms the other ADF converters on
npm list and the names the README uses — ADF, Atlassian Document Format, markdown, CommonMark, HTML
— so a search for any of them can reach the package. Found while planning discoverability,
2026-10-04.
### 72. Make GitHub the canonical repository, with `gitea.larvit.se/larvit/adf-codec` a read-only pull mirror of it.
The maintainer's call, 2026-10-04: GitHub is where an ADF converter is searched for, and the Gitea
copy keeps the move back open. The workflows move to `.github/workflows/` with `ci.sh` unchanged.
Publishing moves to npm's trusted publishing over OIDC, which GitHub-hosted Actions support, and
which outlives the bypass-2FA token npm retires around January 2027: `publish.sh` drops `NPM_TOKEN`
for the Actions OIDC variables, and AGENTS.md §7 reserves the trusted-publisher setup in place of
the secret. `package.json`'s `repository` moves and it gains `bugs` and `homepage`. The links in
README, CHANGELOG and MIGRATION follow, as do AGENTS.md's forge commands and merge style.
`docs/decisions.md` gains the entry "GitHub is canonical, and Gitea a pull mirror of it" (Goal 3),
and §Public on npm and §Publish on a version bump are revised: the source is public on GitHub, and
CI publishes through npm's trusted publisher. Gitea's open issues and its releases move to GitHub
first, since a mirror carries only git refs; the mirror disables issues and its description points
at GitHub. Creating the GitHub repository, configuring npm's trusted publisher, and re-creating the
Gitea copy as a mirror (Gitea cannot convert an existing repository) are the maintainer's.
### 49. Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`.
Lands after item 43. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads 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 two (spec examples 301 and 302). Goal 5 settles it for the lossless flavour too: CommonMark spells
the lossless flavour on purpose and parts two adjacent lists with `!adf:listBreak`. The chunk adjacent lists by changing the marker, so `adfToMarkdown` and `adfToPlainMarkdown` alternate `-` and
settles by Goals 3 and 4 whether the flavour follows, and asks where the Goals do not decide. That `*` between adjacent bullet lists and `.` and `)` between adjacent ordered lists, and
answer also settles what `adfToMarkdown` and `adfToPlainMarkdown` write for two adjacent lists, and `!adf:listBreak` retires. `*` is the maintainer's pick (2026-10-04). Breaking, so it ships beside
whether `!adf:listBreak` still reads. Breaking, so it ships beside item 43: `MIGRATION.md`'s item 43: `MIGRATION.md`'s Readings and Spellings tables gain their rows. Examples 301 and 302 lose
Readings table gains its row, and its Spellings table one if `!adf:listBreak` retires. Examples 301 their `pending` exceptions, and the spelling leaves the README's "Four CommonMark spellings" bullet,
and 302 lose their `pending` exceptions, and the spelling leaves the README's "Four CommonMark which counts one fewer.
spellings" bullet, which counts one fewer.
### 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.
### 61. Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.
The comprehension panel's worst place: `escapeClaims` and `escapeClosedRuns` re-implement the
reader's view — flanking, code-span closers, link-definition openings, highlight flanking — and
only the property tests catch drift. It caps the panel's Locality score. The replacement runs in
time linear in the line: today `mergesWithSyntax`, `touchesSyntax` and `closesHeading` rescan a run
of one character from each of its characters.
### 52. Spell `colwidth` as a comma list, `colwidth="340,420"`. ### 52. Spell `colwidth` as a comma list, `colwidth="340,420"`.
@@ -96,67 +212,58 @@ example 540); lowercasing and then uppercasing folds it. Breaking, so it ships b
`MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go, and its spelling leaves `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. the README's "Four CommonMark spellings" bullet, which counts one fewer.
### 54. Make `docs/decisions.md` §The source parts by ADF and format name every import `parse/` takes from `emit/`. ### 60. Collect the questions `parse/` asks `emit/` into one named module.
The entry says `parse/` imports only `commonMarkSpelling` and `openingLinkTakesDirective` from `parse/` asks `emit/` through `commonMarkSpelling` and `openingLinkTakesDirective`, each imported
`emit/`; `parse/markdown-to-adf.ts` also imports `inlineLeaves` from `emit/plain-inline.ts`. Either from where it happens to live. One module naming the questions keeps `docs/decisions.md` §The source
the entry names it with its reason, or the reader stops importing it. parts by ADF and format true as they grow.
### 53. Bench a block's `marks` spelling with the writer panel and adopt its pick. ### 59. Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` to `markdown/flavour.ts`.
Eight directive files sit across three directories, and the `markdown/` root holds 14 entries. HTML
needs `Read<T>` and `Flavour` out of the plain flavour and `markdown/`; it needs the carry and the
mark spellings too, which item 7 moves where it learns what HTML shares.
### 53. Put a block's `marks` spelling to the writer panel and adopt its pick.
Today `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"`, the marks array as Today `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"`, the marks array as
escaped JSON. Breaking where the panel picks another spelling: `MIGRATION.md`'s Spellings table escaped JSON. Breaking where the panel picks another spelling: `MIGRATION.md`'s Spellings table
gains its row. gains its row.
### 62. Move the plain flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/plain/`, so `emit/` no longer imports `plain/`.
Reading: the alert and task-marker reads in `parse/markdown-to-adf.ts`, the `mintTaskIds` call and
the `inlineLeaves` use. Writing: `spellPlainBlock`, `quotedUnder`, `tryTaskList` and `taskBlocks` in
`emit/adf-to-markdown.ts`. And `highlightDelimiter` and `highlightFlanking` move out of
`plain/conventions.ts`, which `emit/` imports them from; item 59 moves `Flavour`. Every
comprehension reader on 2026-10-03 named the plain flavour's spread across three directories.
### 50. Read `[](/url)` and `[]()` as CommonMark's empty link. ### 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 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 link mark, so a link whose text is empty takes its URL as its text (the maintainer, 2026-10-04):
not decide; whatever it builds, both still parse, since a bot relies on plain CommonMark being valid `[](/url)` reads as `/url` linked to `/url`. `[]()` has no URL to show; the chunk settles it by Goals
input. Breaking, so it ships beside item 43: `MIGRATION.md`'s Readings table gains its row. Its 4 and 7. 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, `pending` exceptions go, and its spelling leaves the README's "Four CommonMark spellings" bullet,
which counts one fewer. which counts one fewer.
### 73. Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.
Lands after `0.2.0` is published with items 47, 71 and 72, so each post links the GitHub repository,
the comparison and a README that opens with what the package is. We draft each post for the place it
goes; the maintainer posts them. Found while planning discoverability, 2026-10-04.
### 38. Spell a lone surrogate in a text node so it survives a UTF-8 encode. ### 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 `adfToMarkdown` emits it verbatim, so markdown stored as UTF-8 reads back U+FFFD; attribute values
already escape it. 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. ### 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 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 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. 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. ### 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 Three Node test legs over one unchanged tree reported `emit/inline-line.ts` at 95.83%, 96.23% and
@@ -168,21 +275,42 @@ moves upward, so the first raise to the measured figure reddens a run that chang
measurement repeatable, or state the number the floor may be raised to and why it is not the measurement repeatable, or state the number the floor may be raised to and why it is not the
measured one. measured one.
### 42. Trim a text leaf's trailing blanks in linear time.
`plain/inline-reduction.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.
### 33. Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`. ### 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 `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 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. reduction's `spellableLine` drops one mark per re-emit the same way. An inline node whose
attributes no spelling writes re-emits the line the same way before riding the carry. Make all
three linear.
### 46. Publish the bundle size in the README, failing the release pipeline when it drifts. ### 56. Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.
Lands after item 7, which changes it. The quantity is what a consumer downloads and loads: the Technical principle "One owner per value": the `Walk` record passes through twelve functions that
tarball `npm pack` produces, its unpacked `dist`, and the built JavaScript minified + gzipped — the mutate it and return `void`; `walk.leaf` alone is written in seven places. `ContainerStack` in the
figure the competitors advertise (marklassian's "12kb") and the only apples-to-apples one, since same file shows the shape to follow. `inline-content.ts`'s `Scan` has the same shape: about fifteen
ours ships tsc's unminified output and no minifier yet (decide here whether to minify for the build functions write `pending`, `pieces`, `deactivatedBefore` and `openingSpellableLink` and return
or report the unminified gzip). The figure lands in README §The package beside the "no runtime `void`, and `parseInlineContent` reads a flag `scanInline` leaves on it. The same shape recurs in
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6 `mintTaskIds` (`plain/task-ids.ts`), which writes `attrs` on the document `readDocument` built;
kB. `takeFallback` (`emit/inline-line.ts`), which writes the record the next `lineSegments` pass reads;
`writeUnpaired`, `markPairings` and `markHighlights` (`inline-content.ts`), whose order changes the
output; and `nestIn` (`plain/adf-to-plain-markdown.ts`), which pushes into its caller's array.
### 57. Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.
Technical principle "Compose, do not entangle": Deno, Bun and the tests read the install leg's
`node_modules`, the floor and consumer legs the pack leg's, and the browser leg whatever `dist/` the
build leg left, so a leg run alone can pass on stale output.
### 58. Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.
Technical principle "Prefer a standalone Python script over a shell script": `AGENTS.md` §3 teaches
bash footguns (`&&` chaining under `||`, `tee /dev/stderr`) the port removes.
### 8. Ship a CLI. ### 8. Ship a CLI.