139 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
lilleman 9f0f520afd Give the comprehension panel its fill-ins
CI / gate (push) Successful in 52s
CI / publish (push) Has been skipped
2026-10-03 14:34:28 +02:00
lilleman 45f1bee94b Set up every inline scan through one freshScan
CI / gate (push) Successful in 53s
CI / publish (push) Has been skipped
2026-10-03 14:33:06 +02:00
lilleman 20a191e7e9 Write the same plain markdown for documents the editor holds equal: normalize the reduction's input again, naming a refusal by the caller's document 2026-10-03 14:32:34 +02:00
lilleman 3da45a9da7 File item 54: the source-parting decision misses an import parse/ takes from emit/
CI / gate (push) Successful in 45s
CI / publish (push) Has been skipped
2026-10-03 14:23:13 +02:00
lilleman 0bc19238b4 Say only the plain writer is editor-normal, and read plain markdown in the tests without normalizing 2026-10-03 14:23:04 +02:00
lilleman 1458b09449 Index a plain refusal's path in the caller's document: normalize only the reduction's output 2026-10-03 14:22:14 +02:00
lilleman eab28ae8ff Refuse a carry fence naming a type no info string carries back 2026-10-03 14:20:47 +02:00
lilleman afb53ca594 Keep an empty stored document empty in the migration recipe 2026-10-03 14:20:03 +02:00
lilleman 70e2639982 Carry the text break as its own item, not a fake node, and check it where the outermost content resolves 2026-10-03 14:19:55 +02:00
lilleman fdbf43398c Keep one merge loop and one mark key: editor-normal compares normalized marks with identicalMark 2026-10-03 14:17:22 +02:00
lilleman 0c507f981f Name the writer panel behind the doc spelling
CI / gate (push) Successful in 1m46s
CI / publish (push) Has been skipped
2026-10-03 12:31:49 +02:00
lilleman 1d0ae695da Name the code block the carry now holds in the migration's error codes 2026-10-03 12:31:10 +02:00
lilleman f180943edf Record deep equality, the text break, empty keys, -0, the typed carry fence and per-node code fences; file items 52 and 53 2026-10-03 12:28:20 +02:00
lilleman 9ab7286e6d Make markdownToAdf(adfToMarkdown(doc)) deep-equal doc: textBreak, empty keys, -0, typed carry fences, code blocks of several text nodes 2026-10-03 12:25:01 +02:00
lilleman bfc4b2ce00 Name the markdown's readers and writers in the audience, and the panel rule 2026-10-03 12:03:20 +02:00
lilleman 6f689a51b0 Answer the prose review's third round
CI / gate (push) Successful in 41s
CI / publish (push) Successful in 5s
2026-10-02 23:20:49 +02:00
lilleman ddc5dfa1be Answer the prose and product-owner reviews' second round
CI / gate (push) Successful in 42s
CI / publish (push) Has been skipped
2026-10-02 23:10:23 +02:00
lilleman 6c589ecabc Answer the prose and product-owner reviews' first round
CI / gate (push) Successful in 43s
CI / publish (push) Has been cancelled
2026-10-02 23:08:47 +02:00
lilleman da63faa4a4 File the three pending CommonMark gaps, hold item 48 until 2027, and give item 8 its own goal and persona
CI / gate (push) Successful in 42s
CI / publish (push) Has been skipped
2026-10-02 23:02:05 +02:00
lilleman d0f0873ca9 Answer the prose review's first round
CI / gate (push) Successful in 41s
CI / publish (push) Successful in 5s
2026-10-02 22:46:15 +02:00
lilleman 058a5fd2f8 Drop the retired milestone wording from the corpus README
CI / gate (push) Successful in 41s
CI / publish (push) Has been skipped
2026-10-02 22:45:03 +02:00
lilleman e84cd47f08 Answer the prose review's third round
CI / gate (push) Successful in 41s
CI / publish (push) Successful in 6s
2026-10-02 22:38:44 +02:00
lilleman 7adab8e19e Answer the prose review's second round
CI / gate (push) Successful in 43s
CI / publish (push) Waiting to run
2026-10-02 22:37:59 +02:00
lilleman dff4983d4a Answer the prose and product-owner reviews' first round
CI / gate (push) Successful in 43s
CI / publish (push) Has been skipped
2026-10-02 22:36:37 +02:00
lilleman d9bacc4072 Give the README's HTML documentation to item 7
CI / gate (push) Successful in 48s
CI / publish (push) Has been skipped
2026-10-02 22:32:29 +02:00
lilleman 58a7bf91b0 Move items 31, 33, 34, 38, 42, 46 and 47 to 0.3.0 2026-10-02 22:30:58 +02:00
lilleman 91adec5797 Score and rank todo.md in one table, and leave the empty-release and weighing rules to the global loop 2026-10-02 22:17:03 +02:00
lilleman 2252232fd6 Goals judged in order, Goal 7 names no runtime dependencies, file the isAdfDocument result
CI / gate (push) Successful in 41s
CI / publish (push) Successful in 5s
2026-10-02 21:57:03 +02:00
lilleman 4d3231c86b Answer the product-owner review's third round
CI / gate (push) Successful in 41s
CI / publish (push) Has been skipped
2026-10-02 17:41:54 +02:00
lilleman 95af770e0a Answer the prose review's third round
CI / gate (push) Successful in 40s
CI / publish (push) Has been skipped
2026-10-02 17:41:47 +02:00
lilleman 361139a4d4 Rewrap two lines past 100 columns
CI / gate (push) Successful in 40s
CI / publish (push) Has been cancelled
2026-10-02 17:41:07 +02:00
lilleman dccd8dcf3a Answer the prose review's second round and the product-owner review's second
CI / gate (push) Successful in 40s
CI / publish (push) Has been skipped
2026-10-02 17:41:00 +02:00
lilleman cd2b573372 Answer the prose and product-owner reviews' first round
CI / gate (push) Successful in 41s
CI / publish (push) Has been skipped
2026-10-02 17:39:25 +02:00
lilleman 94eaee6bae Goals become one-line aims, their detail moves to the sections that meet them
CI / gate (push) Successful in 1m43s
CI / publish (push) Has been skipped
2026-10-02 17:34:17 +02:00
lilleman e949f2099c review: the notes bullet states when each note fires, syncBlock named
CI / gate (push) Successful in 41s
CI / publish (push) Successful in 6s
2026-09-30 22:30:09 +02:00
lilleman 32df104649 review: Goal 5 keeps content the document holds, the notes bullet names when each fires
CI / gate (push) Successful in 44s
CI / publish (push) Has been cancelled
2026-09-30 22:29:38 +02:00
lilleman 1df58cf33e Goal 5's omission-note sentence moves to the plain flavour's spellings decision
CI / gate (push) Successful in 40s
CI / publish (push) Has been cancelled
2026-09-30 22:28:40 +02:00
lilleman e17e247351 review: Goal 5 names the round trip and drops the minted-ids clause
CI / gate (push) Successful in 45s
CI / publish (push) Successful in 6s
2026-09-30 20:01:29 +02:00
lilleman 5f3705c430 Goal 5 states that a document's identity survives only the lossless pair
CI / gate (push) Successful in 47s
CI / publish (push) Has been cancelled
2026-09-30 20:00:47 +02:00
lilleman 7b344334b6 41 - review: Goal 6 breaks ties only, the mailto: rule stated plainly
CI / gate (push) Successful in 43s
CI / publish (push) Successful in 5s
2026-09-30 19:49:40 +02:00
lilleman 8d9d1ecb5b 41 - review: title text keeps its own spelling, only a node without text reads as the reduction does
CI / gate (push) Successful in 41s
CI / publish (push) Has been skipped
2026-09-30 17:01:15 +02:00
lilleman 3f2f63aac0 41 - review: a callout title reads its nodes as the plain reduction does, one goal reference renumbered
CI / gate (push) Successful in 42s
CI / publish (push) Has been cancelled
2026-09-30 16:57:54 +02:00
lilleman 8cecf27757 41 - plainMarkdownToAdf keeps a callout title's link targets; Goal 6, what happens is what the audience expects
CI / gate (push) Successful in 1m44s
CI / publish (push) Has been skipped
2026-09-30 16:32:13 +02:00
116 changed files with 2630 additions and 895 deletions
+34 -22
View File
@@ -10,8 +10,14 @@ In `docs/decisions.md`:
- Plain markdown is a flavour of the grammar - Plain markdown is a flavour of the grammar
- The round-trip is the product - The round-trip is the product
- Markdown in is a canonical fixpoint - Markdown in is a canonical fixpoint
- Equality is editor-normal - Equality is deep
- `!adf:textBreak{}` parts text CommonMark would join
- An empty key spells `empty`
- `-0` is spelled `-0`
- Empty markdown is a document of no blocks
- Unknown nodes ride the carry - Unknown nodes ride the carry
- The carry fence names the node type
- A code block is a fence per text node
- Foreign HTML sorts three ways - Foreign HTML sorts three ways
- Names stay text - Names stay text
- Directives under `!adf:` - Directives under `!adf:`
@@ -42,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
@@ -130,13 +138,10 @@ One-line commit messages and PR titles; short PR summaries. No AI-attribution ma
## 7. The working loop ## 7. The working loop
`todo.md` lists what is left under the release that ships it, in shipping order. One item per A session works one chunk, starting from the first item in `todo.md` not waiting on an unmet "Lands
session — the first under the earliest release — in the smallest PR-able chunk; split a big item after", and stops when that chunk merges, whatever it was asked to finish: a release is a chain of
into sub-items in `todo.md` before starting it. A chunk running a little over or under that is not sessions, so an instruction to work until a release is done names the chain, not the session. An
worth deliberating; what matters is that nothing is left undone in the end. The session stops there open PR is a chunk already in flight, and finishing it is the session.
whatever it was asked to finish: a release is a chain of sessions, so an instruction to work until a
release is done names the chain, not the session. An open PR is a chunk already in flight, and
finishing it is the session.
Per chunk: Per chunk:
1. Fresh worktree off updated `origin/main`; implement tests-first (§3). 1. Fresh worktree off updated `origin/main`; implement tests-first (§3).
@@ -145,8 +150,7 @@ Per chunk:
result exists for the commit under review, or when the diff since that result cannot affect result exists for the commit under review, or when the diff since that result cannot affect
it (docs-only) — re-run only what its own findings or fixes invalidate. it (docs-only) — re-run only what its own findings or fixes invalidate.
3. Merge the PR (standing authorization, this repo only, granted by the maintainer through the 3. Merge the PR (standing authorization, this repo only, granted by the maintainer through the
`0.2.0` release), delete the item from `todo.md` — what a consumer sees of it is `0.2.0` release), report, stop.
reworded for them into `CHANGELOG.md`'s `## Unreleased` — report, stop.
Reserved for the maintainer whatever any rule here says: changing `version` in `package.json` (a Reserved for the maintainer whatever any rule here says: changing `version` in `package.json` (a
bump on `main` publishes, `docs/decisions.md` §Publish on a version bump — every release is the bump on `main` publishes, `docs/decisions.md` §Publish on a version bump — every release is the
@@ -165,24 +169,21 @@ 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 conversion 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`.
### Findings, numbers and empty releases A writer panel settles every new or changed markdown or HTML spelling: a reader panel whose readers
are the people who read and write that format (README `## Audience`). A panel of the developer
personas settles a question about what an app relies on.
- A finding inside the chunk's item is fixed in the chunk. Outside it, a new `todo.md` item, always ### Stated numbers
in a release, weighed against every item on that release by the personas and `docs/decisions.md`
§Plain markdown is a flavour of the grammar through §Names stay text — an item it outweighs moves A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks, naming
later. A weighing no entry decides is asked as a gap. the number it can reach. A number the code needs and no entry states is a gap.
- A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks,
naming the number it can reach. A number the code needs and no entry states is a gap.
- An earliest release with no items left and nothing shipped toward it is planned as the chunk:
every later item weighed as above, the order written in `todo.md`, and the maintainer's approval
taken before any code. With work shipped toward it, it is ready to cut: report that and stop.
### The continuous loop ### The continuous loop
@@ -191,3 +192,14 @@ and `todo.md` and trusting them over anything remembered from earlier iterations
is a thin driver: each chunk's work runs in a fresh-context subagent holding this file as its is a thin driver: each chunk's work runs in a fresh-context subagent holding this file as its
charter, and the driver only relays maintainer questions, runs the review flow, merges, and cleans charter, and the driver only relays maintainer questions, runs the review flow, merges, and cleans
up. The loop stops when only maintainer-reserved acts remain. up. The loop stops when only maintainer-reserved acts remain.
## 8. Scoring run
The comprehension panel's fill-ins:
- Language: TypeScript.
- Kind: a pure-function document converter with hand-written parsers and emitters.
- Domain: Atlassian Document Format and CommonMark parsing.
- Domain docs: the CommonMark spec, ADF's JSON schema and `spec/flavour.md`.
- 3am question: a viewer/editor app reports that a document it saved comes back with two text
nodes merged and a mark gone after `markdownToAdf(adfToMarkdown(doc))`.
+26 -4
View File
@@ -2,10 +2,30 @@
## Unreleased ## Unreleased
- **Breaking:** directives, the opaque carry among them (now `carry`), are spelled under an `!adf:` - **Breaking:** directives, the inline opaque carry among them (now `!adf:carry{json="…"}`), are
prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`, `!adf:name arg {attrs}`) in place spelled under an `!adf:` prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`,
of the `:::`/`::`/`:name` forms: text holding an unescaped `!adf:` is claimed, and `adf` is an `!adf:name arg {attrs}`) in place of the `:::`/`::`/`:name` forms: text holding an unescaped
ordinary code block language. Convert stored markdown per `MIGRATION.md`. `!adf:` is claimed. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** the block carry is a code fence whose info string `adf:<type>` names the node's
type, its body the node's JSON without `type`, and a code fence whose info string opens `adf:` is
claimed, and `adf` is an ordinary code block language. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` read markdown holding no block as a
document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}`
spells a document holding no `content` key.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it: two adjacent text
nodes CommonMark would read back as one are parted by `!adf:textBreak{}`, an empty `attrs`,
`content` or `marks` is spelled `{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is
spelled `-0`, and a `codeBlock` of several text nodes is a fence per node. A `codeBlock` holding
other than plain text nodes rides the block carry, where it was refused.
- **Breaking:** `unspellable-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
@@ -20,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
+23 -9
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,15 +23,17 @@ import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1'
function migrateMarkdown(stored: string) { function migrateMarkdown(stored: string) {
const parsed = markdownToAdf010(stored) const parsed = markdownToAdf010(stored)
return parsed.ok ? adfToMarkdown(parsed.value) : parsed // 0.1.0 dropped an empty content array, so a document with no content key meant an empty one.
return parsed.ok ? adfToMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed
} }
``` ```
- Convert each document once: a second pass can return ok while turning the directives into text. - Convert each document once: a second pass can return ok while turning the directives into text.
Stop `0.1.0` writing first, and record which documents are converted. Stop `0.1.0` writing first, and record which documents are converted.
- A refusal carrying `position` is `0.1.0`'s parse, which refused that markdown before too. One - A refusal carrying `position` is `0.1.0`'s parse, which refused that markdown before too. One
without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF rather than keeping without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF, with
the unconverted markdown. `content: parsed.value.content ?? []` as the recipe gives it, rather than keeping the unconverted
markdown.
### Spellings ### Spellings
@@ -40,12 +43,12 @@ function migrateMarkdown(stored: string) {
| `::media {id=a type=file}` | `!adf:media {id=a type=file}` | | `::media {id=a type=file}` | `!adf:media {id=a type=file}` |
| `::taskItem TODO {localId=i}`: an empty `caption`, `decisionItem`, `paragraph` or `taskItem`, or an empty `heading` carrying `localId` | `!adf:taskItem TODO {localId=i}` then `!adf:/taskItem` | | `::taskItem TODO {localId=i}`: an empty `caption`, `decisionItem`, `paragraph` or `taskItem`, or an empty `heading` carrying `localId` | `!adf:taskItem TODO {localId=i}` then `!adf:/taskItem` |
| `:mention[@Mikael]{id=5b10a2}` | `!adf:mention[@Mikael]{id=5b10a2}` | | `:mention[@Mikael]{id=5b10a2}` | `!adf:mention[@Mikael]{id=5b10a2}` |
| the `adf` code fence and `:adf{json="…"}` | the `carry` code fence and `!adf:carry{json="…"}` | | the `adf` code fence and `:adf{json="…"}` | the `adf:<type>` code fence, its JSON without `type`, and `!adf:carry{json="…"}` |
| `\:` keeps a directive literal | `\!adf:` keeps a directive literal | | `\:` keeps a directive literal | `\!adf:` keeps a directive literal |
| `:adf{json="…"}` carrying a link for its `collection`, `id` or `occurrenceKey` | `!adf:link[text]{attrs}` | | `:adf{json="…"}` carrying a link for its `collection`, `id` or `occurrenceKey` | `!adf:link[text]{attrs}` |
A colon run and `:name[` are plain text now, and `adf` an ordinary code block language; text A colon run and `:name[` are plain text now, and `adf` an ordinary code block language; text
holding an unescaped `!adf:` and a `carry` fence are claimed instead. holding an unescaped `!adf:` and a code fence whose info string opens `adf:` are claimed instead.
### Readings ### Readings
@@ -54,15 +57,26 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
| Input | `0.1.0` | `0.2.0` | | Input | `0.1.0` | `0.2.0` |
| --- | --- | --- | | --- | --- | --- |
| a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them | | a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them |
| markdown holding no block (`markdownToAdf("")`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former |
| a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | the block carry, refusing a body that is not one node's canonical JSON; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block |
### Error codes ### Error codes
`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 leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` | | a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` |
| a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` | | a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` |
| an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` | | an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` |
+65 -75
View File
@@ -5,7 +5,8 @@ an HTML dialect.
**Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at **Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at
`0.2.0`.** `0.2.0`.**
Plan: `todo.md`. Decisions: Plan:
[`todo.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/todo.md). Decisions:
[`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md). Changes: [`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md). Changes:
[`CHANGELOG.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/CHANGELOG.md). The lossless flavour's grammar: [`CHANGELOG.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/CHANGELOG.md). The lossless flavour's grammar:
[`spec/flavour.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/spec/flavour.md). [`spec/flavour.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/spec/flavour.md).
@@ -23,48 +24,23 @@ represent.
## Goals ## Goals
In priority order. The most useful ADF conversion library available, judged by these goals, in priority order:
1. **Lossless, and every call returns.** The round-trip holds for every document the lossless 1. **Lossless, and every call returns a result, never a throw.**
conversions take, node types this version does not know included; one that has no spelling is 2. **ADF is the hub.**
refused and says where, never silently reduced. No input makes a call loop forever or overflow 3. **Easy to find, and clear at a glance what it does.**
the stack. Every goal below gives way to this one. 4. **Each format reads and writes as its standard says.**
2. **ADF is the hub.** Every format and flavour converts to and from ADF, and no two others 5. **Our markdown is CommonMark, extended only where CommonMark has no spelling.**
convert directly: markdown↔HTML composes through ADF. Adding a format or flavour costs one 6. **No surprises: output reads and edits the way its audience expects.**
reader and one writer. A flavour of a grammar shares that grammar's reader and writer and adds 7. **Lossy conversion drops form, never content.**
only its own spellings. 8. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.**
3. **Plain CommonMark is input.** Markdown written for something else converts — the exceptions 9. **Fast, and linear in the document's size as JSON.**
below are the whole of them — and every spelling a flavour claims on top of CommonMark is
escapable, so each flavour is opt-in.
4. **Output a person can edit.** A node CommonMark can spell gets that spelling; the lossless
flavour's directive form carries only what CommonMark cannot hold.
5. **Lossy conversion keeps the content.** `adfToPlainMarkdown` and `plainMarkdownToAdf` drop what
plain markdown cannot hold — format, design, structure — never content: what a reader of the
rendered document sees or follows, its text, images and link targets. Content the document only
references is marked where it stood, by a note that reads as the converter's and names what was
left out. What is dropped goes the way the audience expects. The lossy pair creates and
exports; it never saves back over the document it read — identity (task, mention, media ids)
lives only in the lossless pair.
6. **Failures are values.** Nothing throws, and `code` is a closed list — as much a contract as
the emitted formats are.
7. **Nothing in the way.** No runtime dependencies, no I/O, no configuration, no host API: ESM on
any ES2022 engine, in a browser as readily as on a server, installed from public npm. The public
surface is the conversions, their types, `isAdfDocument`, and what a consumer needs to check a
guarantee this README makes; a helper is exported only when a persona cannot do without it.
8. **Correct before fast.** Each format means what its own specification says — markdown as the
CommonMark spec reads it, well-formed HTML as the HTML standard parses it — both in what this
library reads and in what a conforming parser reads from what it writes. A call takes a whole
document and returns a whole result.
9. **Fast once correct.** Conversion time grows linearly with the document wherever the goals above
allow it; a faster path that risks one of them is not taken.
10. **Source a contributor can hold.** Any one function reads in one sitting, and no change makes
the longest one longer.
## Audience ## Audience
Application developers embedding the library, addressed as personas rather than named consumers Application developers embedding the library, in four personas. All four rely on the guarantees
(AGENTS.md §1). All four rely on the guarantees below and on `code` being a closed list; none may below and on an error's `code` being a closed list; none may rely on an error message's wording,
rely on an error message's wording, which is free text. which is free text.
- **Viewer/editor app** — shows a document, lets a human edit, posts it back. Relies on the - **Viewer/editor app** — shows a document, lets a human edit, posts it back. Relies on the
round-trip holding for whatever the site's editor wrote, unknown node types included, and on a round-trip holding for whatever the site's editor wrote, unknown node types included, and on a
@@ -76,6 +52,10 @@ rely on an error message's wording, which is free text.
- **LLM/agent pipeline** — hands documents to a model as markdown and writes the edits back. - **LLM/agent pipeline** — hands documents to a model as markdown and writes the edits back.
Relies on the round-trip and on markdown a reader half-knowing the lossless flavour can still edit. Relies on the round-trip and on markdown a reader half-knowing the lossless flavour can still edit.
Behind those apps, the people who read and write the markdown, and later the HTML: product
managers, engineers and support agents working in Atlassian products through a plugin or another
UI. They know markdown and not ADF, and rely on every spelling saying what it means to them.
## The shape ## The shape
```sh ```sh
@@ -94,7 +74,9 @@ if (result.ok) {
} }
``` ```
Pure functions, no I/O, no configuration. 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:
they keep only what ADF holds, and refuse what `markdownToAdf` or `htmlToAdf` refuses.
```ts ```ts
adfToMarkdown(doc: AdfDocument): Result<string> adfToMarkdown(doc: AdfDocument): Result<string>
@@ -106,17 +88,18 @@ plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
adfToHtml(doc: AdfDocument): Result<string> // 0.2.0 adfToHtml(doc: AdfDocument): Result<string> // 0.2.0
htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.2.0 htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.2.0
markdownToHtml(markdown: string): Result<string> // 0.2.0, via ADF markdownToHtml(markdown: string): Result<string> // 0.2.0
htmlToMarkdown(html: string): Result<string> // 0.2.0, via ADF htmlToMarkdown(html: string): Result<string> // 0.2.0
``` ```
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws. `Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
## Plain markdown ## Plain markdown
Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` writes markdown other Serves Goal 7. Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` writes
tools render — GitHub, GitLab, Obsidian and the like — keeping the content and dropping the rest: markdown other tools render — GitHub, GitLab, Obsidian and the like — keeping the content and
attributes, colours, layout, identity. It refuses only dropping the rest: attributes, colours, layout, identity. Content is what a reader of the rendered
document sees or follows: its text, images and link targets. It refuses only
`not-an-adf-document`, `unsupported-document-version` and `unsupported-nesting-depth`, and writes `not-an-adf-document`, `unsupported-document-version` and `unsupported-nesting-depth`, and writes
no directive. no directive.
@@ -131,26 +114,29 @@ read replaces mentions, attachments and macros with text.
| ADF | Written | Read back | | ADF | Written | Read back |
| --- | --- | --- | | --- | --- | --- |
| `panel` | a GitHub alert, `> [!WARNING]`: info `NOTE`, note `IMPORTANT`, tip and success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE` | `NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error, and Obsidian's: hint tip; success, check, done success; attention warning; danger, failure, fail, missing, bug, error error; any other word info — in any case; the rest of the marker's line is the first paragraph | | `panel` | a GitHub alert, `> [!WARNING]`: info `NOTE`, note `IMPORTANT`, tip and success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE` | `NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error, and Obsidian's: hint tip; success, check, done success; attention warning; danger, failure, fail, missing, bug, error error; any other word info — in any case; the rest of the marker's line is the first paragraph |
| `expand`, `nestedExpand` | Obsidian's folded callout, `> [!NOTE]- Title` | `-` or `+` after any word, the rest of the marker's line the title; an expand inside an expand is a `nestedExpand` | | `expand`, `nestedExpand` | Obsidian's folded callout, `> [!NOTE]- Title` | `-` or `+` after any word, the rest of the marker's line the title; a link reads `text (target)`, or its text alone where the text is the target with or without `mailto:`; an expand inside an expand is a `nestedExpand` |
| `taskList` | `- [x] Done`, `- [ ] Todo` | a bullet list whose every item is so marked, `[X]` too | | `taskList` | `- [x] Done`, `- [ ] Todo` | a bullet list whose every item is so marked, `[X]` too |
| `backgroundColor` | `==text==` | `==text==` on one line, the text touching both delimiters, bounded outside by whitespace, punctuation or a line edge, or touching a Han, Hangul, Hiragana, Katakana, Thai, Lao, Khmer or Myanmar character on either side, in the editor's default highlight `#f8e6a0` | | `backgroundColor` | `==text==` | `==text==` on one line, the text touching both delimiters, bounded outside by whitespace, punctuation or a line edge, or touching a Han, Hangul, Hiragana, Katakana, Thai, Lao, Khmer or Myanmar character on either side, in the editor's default highlight `#f8e6a0` |
| `table` | a pipe table: the first row its header, a cell's blocks on one line, a span kept under its header by empty cells | — | | `table` | a pipe table: the first row its header, a cell's blocks on one line, a span kept under its header by empty cells | — |
| `decisionList` | a bullet list | — | | `decisionList` | a bullet list | — |
| `mention`, `status`, `emoji`, `date` | their text: `@` kept, a mention with none `@` and its id, an emoji its `shortName` without, a date `2026-09-13` in UTC | — | | `mention`, `status`, `emoji`, `date` | their text: `@` kept, a mention with none `@` and its id, an emoji its `shortName` without, a date `2026-09-13` in UTC | — |
| `inlineCard`, `blockCard`, `embedCard` | a link to the card's URL | — | | `inlineCard`, `blockCard`, `embedCard` | a link to the card's URL, else its name | — |
| external `media` | `![alt](url)` in a block, `[alt](url)` inline | — | | external `media` | `![alt](url)` in a block, `[alt](url)` inline | — |
| stored `media`, `mediaInline`, `extension`, `inlineExtension` | their `alt` or `text` | — | | stored `media`, `mediaInline`, `extension`, `inlineExtension` | their `alt` or `text` | — |
| `layoutSection`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`, `extensionFrame`, `caption`, a node this version does not know | its blocks or its text | — | | `layoutSection`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`, `extensionFrame`, `caption`, a node this version does not know | its blocks or its text | — |
| `placeholder` | nothing | — | | `placeholder` | nothing | — |
- Content the document only references leaves an italic note naming it where it stood: - Content the document only references, with no text of its own to keep, leaves an italic note
`_(image not included)_`, `_(jira-issues-table not included)_`, `_(synced block not included)_`, naming it where it stood: `_(image not included)_` for stored media with no `alt`,
`_(link card not included)_`, `_(extension not included)_`. `_(link card not included)_` for a card with neither URL nor name, an extension with no `text`
its key, `_(jira-issues-table not included)_`, or `_(extension not included)_` without one, and
`_(synced block not included)_` for a `syncBlock`.
- `backgroundColor`, `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops, - `backgroundColor`, `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops,
keeping its text, and so does a mark the flavour cannot spell where it stands. keeping its text, and so does a mark the flavour cannot spell where it stands.
- A newline in text is a hard break and in an expand's title a space, edge whitespace outside a - 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
@@ -165,8 +151,8 @@ read replaces mentions, attachments and macros with text.
## The errors ## The errors
An ADF node type this version does not know is not an error: the lossless pair carries it opaquely Serves Goal 1. An ADF node type this version does not know is not an error: the lossless pair
and restores it unchanged ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)). carries it opaquely and restores it unchanged ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
`ConvertError` is `{ code, message, path, position? }`. `code` is the exported `ConvertErrorCode`, `ConvertError` is `{ code, message, path, position? }`. `code` is the exported `ConvertErrorCode`,
stable across minors and safe to `switch` on exhaustively with no `default`; `message` is free text stable across minors and safe to `switch` on exhaustively with no `default`; `message` is free text
@@ -190,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 a `carry` | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text | | `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in an opaque carry | write the spelling the message names, or keep it literal: escape the prefix — `\!adf:`, block and inline alike — or, for a code fence whose info string opens `adf:`, drop the info string and wrap the fence in `!adf:codeBlock {language="adf:…"}` |
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way | | `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning | | `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
| `unmappable-html` | the input holds an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the 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
@@ -209,24 +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
- `markdownToAdf(adfToMarkdown(doc))` equals `doc` — unknown node types included, carried opaquely Serves Goals 1, 4 and 5.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as JSON, for a document of plain objects
as `JSON.parse` builds them — every key and value as `doc` holds it, adjacent text nodes, an
empty `attrs`, `content` or `marks` and `-0` included, and unknown node types carried opaquely
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)). ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
- Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from
`0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
name every exception.
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html` - Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
names, with three carve-outs — literal text matching directive, pipe-table or strikethrough names, with four carve-outs — literal text matching directive, pipe-table or strikethrough syntax,
syntax is claimed (escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as and a code fence whose info string opens `adf:`, are claimed (each can be kept literal —
its own title-less paragraph; mid-text and titled images are error results, save an image inside `spec/flavour.md`) — and one gap: a CommonMark image fits only as its own title-less paragraph;
another's description, which flattens into the alt text. Converting back yields the library's mid-text and titled images are error results, save an image inside another's description, which
canonical spelling, which round-trips byte-identically — where it converts back at all: a parse flattens into the alt text. Converting back yields the library's canonical spelling, which
succeeding is no promise of that, so keep the source until the way back succeeds. round-trips byte-identically.
``` ` `` ` ``` 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
@@ -235,28 +225,28 @@ emit refuses:
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. - A document nested deeper than 500 levels is an error result, not a stack overflow, and no call
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))` equals `doc`; fidelity HTML cannot express rides - **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` deep-equals `doc`; fidelity HTML cannot express rides
`data-*` attributes. Foreign HTML maps a documented element set, which markdown's raw HTML reads `data-*` attributes. Foreign HTML maps a documented element set, which markdown's raw HTML reads
through as well, and a construct outside it is an error; well-formed HTML only — no tag-soup through as well, and a construct outside it is an error; well-formed HTML only — no tag-soup
recovery. recovery.
## The package ## The package
ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts` beside it. Serves Goal 8. ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts`
Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs under Node, beside it. Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs
Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint. under Node, Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
Contract: [`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
ES2022 engine to §Public on npm. ES2022 engine to §Public on npm.
+5 -3
View File
@@ -1,16 +1,18 @@
try { try {
const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js') const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js')
// WebDriver's JSON reads -0 back as 0, so a document crosses as JSON text with -0 tagged; run.js revives it.
const spelled = (result) => (result.ok ? { ok: true, value: JSON.stringify(result.value, (_, value) => (Object.is(value, -0) ? '\u0000-0' : value)) } : result)
window.convertCorpus = (corpus) => ({ window.convertCorpus = (corpus) => ({
errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })), errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })), normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: spelled(markdownToAdf(markdown)) })),
realPayloads: corpus.realPayloads.map(({ json, name }) => { realPayloads: corpus.realPayloads.map(({ json, name }) => {
const adf = JSON.parse(json) const adf = JSON.parse(json)
const emitted = adfToMarkdown(adf) const emitted = adfToMarkdown(adf)
return { emitted, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? markdownToAdf(emitted.value) : undefined } return { emitted, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(markdownToAdf(emitted.value)) : undefined }
}), }),
roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => { roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
const adf = JSON.parse(json) const adf = JSON.parse(json)
return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: markdownToAdf(markdown) } return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: spelled(markdownToAdf(markdown)) }
}), }),
}) })
} catch (cause) { } catch (cause) {
+8 -4
View File
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict'
import { createServer } from 'node:http' import { createServer } from 'node:http'
import { extname, join } from 'node:path' import { extname, join } from 'node:path'
import { readFileSync, readdirSync } from 'node:fs' import { readFileSync, readdirSync } from 'node:fs'
import { toEditorNormal } from '../dist/adf/editor-normal.js'
const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' } const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' }
const driver = 'http://127.0.0.1:4444' const driver = 'http://127.0.0.1:4444'
@@ -50,6 +49,11 @@ function fixtureNames(kind, extension) {
.sort() .sort()
} }
// convert-corpus.js tags -0 so it survives WebDriver's JSON.
function revived(text) {
return JSON.parse(text, (_, value) => (value === '\u0000-0' ? -0 : value))
}
function refusal(result) { function refusal(result) {
return result.ok ? '' : `${result.error.code}: ${result.error.message}` return result.ok ? '' : `${result.error.code}: ${result.error.message}`
} }
@@ -107,7 +111,7 @@ for (const [index, { json, markdown, name }] of corpus.roundTrip.entries()) {
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`) assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
assert.equal(result.emitted.value, markdown) assert.equal(result.emitted.value, markdown)
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`) assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(json)) assert.deepEqual(revived(result.parsed.value), JSON.parse(json))
}) })
} }
@@ -115,7 +119,7 @@ for (const [index, { name }] of corpus.normalization.entries()) {
const result = results.normalization[index] const result = results.normalization[index]
checking(name, result, () => { checking(name, result, () => {
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`) assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(fixture(name, '.json'))) assert.deepEqual(revived(result.parsed.value), JSON.parse(fixture(name, '.json')))
}) })
} }
@@ -125,7 +129,7 @@ for (const [index, { json, name }] of corpus.realPayloads.entries()) {
assert.ok(result.isDocument, `${name}.json is no ADF document`) assert.ok(result.isDocument, `${name}.json is no ADF document`)
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`) assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
assert.ok(result.parsed.ok, `it did not parse back — ${refusal(result.parsed)}`) assert.ok(result.parsed.ok, `it did not parse back — ${refusal(result.parsed)}`)
assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(json)) assert.deepEqual(revived(result.parsed.value), JSON.parse(json))
}) })
} }
+8 -8
View File
@@ -1,6 +1,6 @@
# The corpus # The corpus
One directory per contract kind, each landing with its milestone: One directory per contract kind:
- `round-trip/` — `<name>.json` + `<name>.md`: the markdown `adfToMarkdown` must emit for that - `round-trip/` — `<name>.json` + `<name>.md`: the markdown `adfToMarkdown` must emit for that
document, byte for byte, and that `markdownToAdf` must read back to it (`docs/decisions.md` §The document, byte for byte, and that `markdownToAdf` must read back to it (`docs/decisions.md` §The
@@ -16,11 +16,11 @@ One directory per contract kind, each landing with its milestone:
is the suite; `refusals.json` pins each refusing example to its error `code`; `exceptions.json` is the suite; `refusals.json` pins each refusing example to its error `code`; `exceptions.json`
pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a
`reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node `reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node
model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap a later model) or `pending` (a parser gap).
milestone may close).
JSON is editor-normal (`docs/decisions.md` §Equality is editor-normal), two-space indent, keys JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's
sorted. `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,5 @@
```adf:
{
"type": "blockCard"
}
```
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,3 @@
```adf:\\
{}
```
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+5
View File
@@ -0,0 +1,5 @@
```adf:blockCard
{
"type": "blockCard"
}
```
+2 -2
View File
@@ -1,3 +1,3 @@
```carry ```adf:blockCard
{"type": {"attrs":
``` ```
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,8 @@
!adf:codeBlock {attrs=empty}
```js
a
```
```js
b
```
!adf:/codeBlock
@@ -0,0 +1 @@
unsupported-node-shape
+7
View File
@@ -0,0 +1,7 @@
!adf:codeBlock
```
a
```
```
```
!adf:/codeBlock
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,8 @@
!adf:codeBlock
```js
a
```
```ts
b
```
!adf:/codeBlock
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1 @@
!adf:status[!adf:carry{json="{\"marks\":[],\"text\":\"x\",\"type\":\"text\"}"}]
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
!adf:doc
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,3 @@
!adf:doc {content=none}
Text.
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,3 @@
!adf:panel info {attrs=empty}
Text.
!adf:/panel
@@ -0,0 +1 @@
unsupported-node-shape
+3
View File
@@ -0,0 +1,3 @@
!adf:paragraph {content=empty}
Text.
!adf:/paragraph
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+3
View File
@@ -0,0 +1,3 @@
!adf:paragraph {attrs=none}
Text.
!adf:/paragraph
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
**!adf:date{marks=empty}**
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
a!adf:textBreak{x=y}b
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
a!adf:textBreak[x]b
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
**a**!adf:textBreak{}b
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
!adf:textBreak{}Hello
@@ -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"
}
]
}
```
@@ -14,7 +14,19 @@
}, },
{ {
"attrs": { "attrs": {
"language": "carry" "language": "adf:blockCard"
},
"content": [
{
"text": "{\n \"attrs\": {}\n}",
"type": "text"
}
],
"type": "codeBlock"
},
{
"attrs": {
"language": "adf:"
}, },
"content": [ "content": [
{ {
@@ -1,12 +1,18 @@
!adf:codeBlock {language=carry} ```carry
```
{ {
"type": "blockCard" "type": "blockCard"
} }
``` ```
!adf:codeBlock {language="adf:blockCard"}
```
{
"attrs": {}
}
```
!adf:/codeBlock !adf:/codeBlock
!adf:codeBlock {language=carry} !adf:codeBlock {language="adf:"}
```` ````
``` ```
```` ````
@@ -0,0 +1,56 @@
{
"content": [
{
"attrs": {
"language": "js"
},
"content": [
{
"text": "const a = 1\n",
"type": "text"
},
{
"text": "const b = 2",
"type": "text"
}
],
"type": "codeBlock"
},
{
"content": [
{
"text": "a",
"type": "text"
},
{
"text": "```",
"type": "text"
},
{
"text": "\n",
"type": "text"
}
],
"type": "codeBlock"
},
{
"attrs": {
"language": "has`tick",
"wrap": true
},
"content": [
{
"text": "x",
"type": "text"
},
{
"text": " y ",
"type": "text"
}
],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,31 @@
!adf:codeBlock
```js
const a = 1
```
```js
const b = 2
```
!adf:/codeBlock
!adf:codeBlock
```
a
```
````
```
````
```
```
!adf:/codeBlock
!adf:codeBlock {language="has\u0060tick" wrap=true}
```
x
```
```
y
```
!adf:/codeBlock
@@ -0,0 +1,4 @@
{
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
!adf:doc {content=none}
@@ -0,0 +1,96 @@
{
"content": [
{
"content": [],
"type": "paragraph"
},
{
"attrs": {},
"content": [
{
"text": "Plain words.",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [],
"type": "rule"
},
{
"attrs": {
"level": 2
},
"content": [
{
"text": "Title",
"type": "text"
}
],
"marks": [],
"type": "heading"
},
{
"attrs": {
"panelType": "info"
},
"content": [],
"type": "panel"
},
{
"content": [
{
"attrs": {},
"content": [
{
"content": [
{
"text": "Item",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
},
{
"content": [
{
"content": [
{
"text": "Next",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "bulletList"
},
{
"content": [],
"type": "codeBlock"
},
{
"attrs": {
"language": "js"
},
"marks": [],
"type": "codeBlock"
},
{
"attrs": {
"language": "js"
},
"content": [],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,35 @@
!adf:paragraph {content=empty}
!adf:/paragraph
!adf:paragraph {attrs=empty}
Plain words.
!adf:/paragraph
!adf:rule {content=empty}
!adf:heading {level=2 marks=empty}
Title
!adf:/heading
!adf:panel info {content=empty}
!adf:/panel
!adf:bulletList
!adf:listItem {attrs=empty}
Item
!adf:/listItem
!adf:listItem
Next
!adf:/listItem
!adf:/bulletList
!adf:codeBlock {content=empty}
!adf:/codeBlock
!adf:codeBlock {marks=empty}
```js
```
!adf:/codeBlock
!adf:codeBlock {content=empty language=js}
!adf:/codeBlock
@@ -1,4 +1,4 @@
```carry ```adf:panel
{ {
"attrs": { "attrs": {
"rounded": true "rounded": true
@@ -13,12 +13,11 @@
], ],
"type": "paragraph" "type": "paragraph"
} }
], ]
"type": "panel"
} }
``` ```
```carry ```adf:panel
{ {
"attrs": { "attrs": {
"panelType": "extra info" "panelType": "extra info"
@@ -33,8 +32,7 @@
], ],
"type": "paragraph" "type": "paragraph"
} }
], ]
"type": "panel"
} }
``` ```
@@ -0,0 +1,83 @@
{
"content": [
{
"attrs": {
"order": -0
},
"content": [
{
"content": [
{
"content": [
{
"text": "First",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "orderedList"
},
{
"attrs": {
"level": -0
},
"content": [
{
"text": "Title",
"type": "text"
}
],
"type": "heading"
},
{
"attrs": {
"extensionKey": "toc",
"extensionType": "com.atlassian.confluence.macro.core",
"parameters": {
"maxLevel": -0
}
},
"type": "extension"
},
{
"content": [
{
"attrs": {
"data": [
-0
],
"url": "https://example.com/"
},
"type": "inlineCard"
},
{
"marks": [
{
"attrs": {
"color": "#000000",
"size": -0
},
"type": "border"
}
],
"text": "x",
"type": "text"
}
],
"type": "paragraph"
},
{
"attrs": {
"x": -0
},
"type": "blockCard"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,21 @@
!adf:orderedList {order=-0}
!adf:listItem
First
!adf:/listItem
!adf:/orderedList
!adf:heading {level=-0}
Title
!adf:/heading
!adf:extension {extensionKey=toc extensionType="com.atlassian.confluence.macro.core" parameters="{\"maxLevel\":-0}"}
!adf:inlineCard{data="[-0]" url="https://example.com/"}!adf:border[x]{color="#000000" size=-0}
```adf:blockCard
{
"attrs": {
"x": -0
}
}
```
@@ -0,0 +1,20 @@
{
"content": [
{
"content": [
{
"marks": [
{
"type": "code"
}
],
"text": "``",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
``` `` ```
@@ -1,4 +1,5 @@
{ {
"content": [],
"type": "doc", "type": "doc",
"version": 1 "version": 1
} }
@@ -0,0 +1,98 @@
{
"content": [
{
"content": [
{
"attrs": {},
"type": "date"
},
{
"text": " ",
"type": "text"
},
{
"content": [],
"type": "date"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "a",
"type": "text"
},
{
"marks": [],
"type": "hardBreak"
},
{
"text": "b",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"attrs": {
"id": "x",
"text": "@M"
},
"content": [],
"type": "mention"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "bare",
"type": "text"
},
{
"marks": [],
"text": "marked",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"attrs": {},
"type": "em"
}
],
"text": "x",
"type": "text"
},
{
"text": " and ",
"type": "text"
},
{
"attrs": {
"shortName": ":a:"
},
"marks": [
{
"attrs": {},
"type": "strong"
}
],
"type": "emoji"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,9 @@
!adf:date{attrs=empty} !adf:date{content=empty}
a!adf:hardBreak{marks=empty}b
!adf:mention[@M]{content=empty id=x}
bare!adf:carry{json="{\"marks\":[],\"text\":\"marked\",\"type\":\"text\"}"}
!adf:carry{json="{\"marks\":[{\"attrs\":{},\"type\":\"em\"}],\"text\":\"x\",\"type\":\"text\"}"} and !adf:carry{json="{\"attrs\":{\"shortName\":\":a:\"},\"marks\":[{\"attrs\":{},\"type\":\"strong\"}],\"type\":\"emoji\"}"}
@@ -0,0 +1,154 @@
{
"content": [
{
"content": [
{
"text": "Hello, ",
"type": "text"
},
{
"text": "world",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "strong"
}
],
"text": "Hello, ",
"type": "text"
},
{
"marks": [
{
"type": "strong"
}
],
"text": "world",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "code"
}
],
"text": "a",
"type": "text"
},
{
"marks": [
{
"type": "code"
}
],
"text": "b",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"attrs": {
"href": "https://example.com/"
},
"type": "link"
}
],
"text": "a",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/"
},
"type": "link"
}
],
"text": "b",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "Line\n",
"type": "text"
},
{
"text": "next",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "underline"
}
],
"text": "a",
"type": "text"
},
{
"marks": [
{
"type": "underline"
}
],
"text": "b",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"attrs": {},
"type": "underline"
}
],
"text": "a",
"type": "text"
},
{
"marks": [
{
"type": "underline"
}
],
"text": "b",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,13 @@
Hello, !adf:textBreak{}world
**Hello, !adf:textBreak{}world**
`a`!adf:textBreak{}`b`
[a!adf:textBreak{}b](https://example.com/)
Line!adf:text{text="\n"}!adf:textBreak{}next
!adf:underline[a!adf:textBreak{}b]
!adf:underline[a]{attrs=empty}!adf:underline[b]
@@ -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\"}"}
@@ -1,17 +1,15 @@
> ```carry > ```adf:blockCard
> { > {
> "attrs": { > "attrs": {
> "url": "https://example.com/quoted" > "url": "https://example.com/quoted"
> }, > }
> "type": "blockCard"
> } > }
> ``` > ```
- ```carry - ```adf:blockCard
{ {
"attrs": { "attrs": {
"url": "https://example.com/listed" "url": "https://example.com/listed"
}, }
"type": "blockCard"
} }
``` ```
@@ -0,0 +1,35 @@
{
"content": [
{
"attrs": {
"language": "js"
},
"content": [
{
"marks": [
{
"type": "strong"
}
],
"text": "const a = 1",
"type": "text"
}
],
"type": "codeBlock"
},
{
"content": [
{
"text": "a",
"type": "text"
},
{
"type": "hardBreak"
}
],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,32 @@
```adf:codeBlock
{
"attrs": {
"language": "js"
},
"content": [
{
"marks": [
{
"type": "strong"
}
],
"text": "const a = 1",
"type": "text"
}
]
}
```
```adf:codeBlock
{
"content": [
{
"text": "a",
"type": "text"
},
{
"type": "hardBreak"
}
]
}
```
@@ -1,23 +1,21 @@
```carry ```adf:blockCard
{ {
"attrs": { "attrs": {
"url": "https://example.com/roadmap" "url": "https://example.com/roadmap"
}, }
"type": "blockCard"
} }
``` ```
!adf:panel info !adf:panel info
The card below has no spelling yet. The card below has no spelling yet.
```carry ```adf:embedCard
{ {
"attrs": { "attrs": {
"layout": "wide", "layout": "wide",
"url": "https://example.com/board", "url": "https://example.com/board",
"width": 100 "width": 100
}, }
"type": "embedCard"
} }
``` ```
!adf:/panel !adf:/panel
@@ -0,0 +1,21 @@
{
"content": [
{
"attrs": {
"url": "https://example.com/"
},
"type": "has`tick"
},
{
"type": ""
},
{
"type": " padded"
},
{
"type": "two words"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,24 @@
```adf:
{
"attrs": {
"url": "https://example.com/"
},
"type": "has`tick"
}
```
```adf:
{
"type": ""
}
```
```adf:
{
"type": " padded"
}
```
```adf:two words
{}
```
+185 -86
View File
@@ -14,7 +14,7 @@ a backslash reach them intact; what the flavour cannot spell reduces ADF→ADF a
2026-08-23, real payloads 2026-09-15, the maintainer. Goal 1. Valid while a consumer saves back 2026-08-23, real payloads 2026-09-15, the maintainer. Goal 1. Valid while a consumer saves back
through the lossless pair. through the lossless pair.
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything `markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must deep-equal `doc` — anything
less silently destroys content an editor could not represent, in a document it did not author. less silently destroys content an editor could not represent, in a document it did not author.
When losslessness and readability conflict, losslessness wins. Round-trip equality is a property When losslessness and readability conflict, losslessness wins. Round-trip equality is a property
tested over a checked-in corpus (`corpus/README.md`), not a claim made in prose. Its real payloads tested over a checked-in corpus (`corpus/README.md`), not a claim made in prose. Its real payloads
@@ -23,40 +23,103 @@ 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 3. 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 editor-normal ## Equality is deep
2026-08-24, the maintainer. Goal 1. Valid while markdown cannot tell apart the ADF shapes this 2026-08-24, deep 2026-10-03, the maintainer. Goal 1. Valid while a pipeline or a bot can build a
merges. shape the editor would not.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks "Equals" is deep equality over the document's JSON values: `assert.deepStrictEqual` on plain objects
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content as `JSON.parse` builds them, since ADF is JSON. Every key and value in `doc` counts, including two
array the absent key — the only domain markdown can restore. adjacent text nodes, an empty `attrs`, `content` or `marks`, and `-0`. Neither side is normalized.
Replaced by deep equality with `todo.md` 40 (2026-09-28, the maintainer). CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader builds
what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction reads and
writes editor-normal ADF — adjacent text nodes of identical marks and no attributes merged, `-0` as
`0`, and an empty `attrs`, `content` or `marks` the absent key, except the doc's `content`, which
ADF's schema requires — so two documents the editor holds equal write the same plain markdown.
## `!adf:textBreak{}` parts text CommonMark would join
2026-10-03, the maintainer. Goal 1. Valid while CommonMark reads adjacent text as one run.
Two adjacent text nodes CommonMark would read back as one are parted by the reserved inline leaf
`!adf:textBreak{}`, mirroring `!adf:listBreak`: a leaf building no node keeps both nodes and asks
nothing of the text around it. A code span holds no directive, so the spans close and reopen
around the leaf. The grammar: `spec/flavour.md` §Inline nodes, **Adjacent text nodes**.
## An empty key spells `empty`
2026-10-03, a writer panel and the maintainer. Goals 1 and 6. Valid while no attribute value
spells an empty object or array.
An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the
bare value `empty`, so a container opener and closer with nothing between them stays the node
holding no `content` key. A writer panel chose the spelling, 5 of 7. The grammar: `spec/flavour.md`
§Directives, **Attributes**, and §Marks.
## `-0` is spelled `-0`
2026-10-03, the maintainer. Goal 1. Valid while JSON's own serialization writes `-0` as `0`.
`-0` is spelled `-0` wherever the flavour writes a number or a JSON value, since JSON's grammar
reads it back as `-0`. The grammar: `spec/flavour.md` §Block nodes and §The CommonMark blocks.
## Empty markdown is a document of no blocks
2026-10-03, a writer panel and the maintainer. Goals 1 and 6. Valid while ADF's schema requires
`content` on `doc`.
Markdown holding no block reads as `{ content: [], type: 'doc', version: 1 }`, the document
`spec/adf-schema/full.json` requires. A document holding no `content` key is
`!adf:doc {content=none}` as its only block, and a named error anywhere else. The writer panel split
4 for `none` and 3 for `absent`, and the maintainer chose `none`; all seven rejected a bare
`!adf:doc`.
## Unknown nodes ride the carry ## Unknown nodes ride the carry
2026-08-23, extended to misplaced known nodes 2026-08-26, the maintainer. Goal 1. Valid while ADF 2026-08-23, extended to misplaced known nodes 2026-08-26 and to code block children 2026-10-03, the
holds nodes, or node positions, this library does not spell. maintainer. Goal 1. Valid while ADF holds nodes, or node positions, this library does not spell.
An unknown ADF node is carried opaquely — raw JSON rides a dedicated syntax in both formats and An unknown ADF node is carried opaquely — raw JSON rides a dedicated syntax in both formats and
restores to a deep-equal node. The round-trip holds for documents newer than the library. So does restores to a deep-equal node. The round-trip holds for documents newer than the library. So does
a known node no section spells where it stands: a markdown serializer spells a node by type without a known node no section spells where it stands: a markdown serializer spells a node by type without
checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`. checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`. A
Where a container's own spelling cannot hold the child it has — a `bulletList` holding other than `codeBlock` holding a child no fence holds — anything but a text node carrying no marks, `attrs` or
`listItem`, a `codeBlock` other than text — the error result names that instead. `content` — rides the carry whole.
## The carry fence names the node type
2026-10-03, the maintainer. Goals 1 and 6. Valid while a code fence's info string reads back
verbatim.
The block carry is a code fence whose info string `adf:<type>` names the node's type, its body the
node's JSON without `type`: ```` ```adf:blockCard ````. A type no info string carries back — by the
rule a code language follows — leaves the info string `adf:` and keeps `type` in the body. Every
info string opening `adf:` is reserved, so a `codeBlock` whose language opens so takes the
`language` attribute, and `carry` is an ordinary language. A body holding `type` under a named type,
or a fence whose info string is `adf:` alone while its body's `type` could be spelled in the info
string, is `unsupported-node-shape`. The reservation claims a fence CommonMark reads as code until
`todo.md` item 43 gives CommonMark its own reader.
## A code block is a fence per text node
2026-10-03, the maintainer. Goal 1. Valid while ADF holds a code block's text in more than one
node.
A `codeBlock` holding several text nodes is the `!adf:codeBlock` container holding one fence per
node, so each node keeps its own text. The fences carry one info string, since ADF holds one
language, and none is empty beside another, since a text node holds text. The grammar:
`spec/flavour.md` §The CommonMark blocks, the `codeBlock` bullet.
## Foreign HTML sorts three ways ## Foreign HTML sorts three ways
2026-08-23, the sort 2026-09-20, the maintainer. Goals 1, 3 and 6. 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` 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
drop of content: drop of content:
@@ -76,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 3 and 4. 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:`:
@@ -94,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 3. 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 4. 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 4. 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
@@ -127,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 5 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`
@@ -139,15 +204,16 @@ ids are only skipped.
## A callout title keeps its link targets ## A callout title keeps its link targets
2026-09-29, the maintainer. Goal 5. Valid while an expand's `title` is a string. Lands with 2026-09-29, the maintainer. Goals 6 and 7. Valid while an expand's `title` is a string.
`todo.md` 41.
`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)`. parentheses: `> [!faq]- See [x](http://y)` reads to the title `See x (http://y)`. A link whose text
is its target, with or without `mailto:`, keeps its text alone: `<http://y>` titles `http://y`,
`<a@b.c>` `a@b.c` — three persona readers agreeing, 2026-09-30.
## The plain flavour's spellings ## The plain flavour's spellings
2026-09-14, panels 2026-09-25 and 2026-09-29, the maintainer. Goal 5. 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,
@@ -155,8 +221,9 @@ MkDocs, Docusaurus, Typora, Joplin, Logseq, Bear, Notion, Azure DevOps and Disco
renderer confirming each shape. Reader panels settled `error` as an error panel, the `==` bounds renderer confirming each shape. Reader panels settled `error` as an error panel, the `==` bounds
(3 of 3) and a Han, Hangul, kana, Thai, Lao, Khmer or Myanmar character on either side bounding a (3 of 3) and a Han, Hangul, kana, Thai, Lao, Khmer or Myanmar character on either side bounding a
delimiter, so `は==日本語==で` (3 of 3), `==한국어==에서만` and `iPhone==専用==` (6 of 7) highlight, delimiter, so `は==日本語==で` (3 of 3), `==한국어==에서만` and `iPhone==専用==` (6 of 7) highlight,
the external image's two forms (6 of 7), the omission notes and a rule opening a list item the external image's two forms (6 of 7), a rule opening a list item dropping and the omission
dropping (3 of 3), and a list's numbering overflowing into bullets (3 of 3, 5 of 7). notes (3 of 3), and a list's numbering overflowing into bullets (3 of 3, 5 of 7).
An omission note reads as the converter's, never as the author's.
Reading takes other tools' spellings, since it reads their output and writes none of them. Reading takes other tools' spellings, since it reads their output and writes none of them.
Rejected: `~sub~` and `^sup^` (`~2~` is a strike on GitHub, so `subsup` drops), underline and colour Rejected: `~sub~` and `^sup^` (`~2~` is a strike on GitHub, so `subsup` drops), underline and colour
spellings, raw HTML (`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family, spellings, raw HTML (`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family,
@@ -165,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 4 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-*`
@@ -173,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
@@ -183,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,
3 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.
@@ -206,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.
@@ -220,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
@@ -234,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,
@@ -243,7 +310,7 @@ for the hub rather than the formats around it.
## The formats are API ## The formats are API
2026-08-23, strict input 2026-09-01, content models 2026-09-16, the maintainer. Goals 1 and 6. 2026-08-23, strict input 2026-09-01, content models 2026-09-16, the maintainer. Goal 1.
Valid while consumers store what the library emits. Valid while consumers store what the library emits.
The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing
@@ -258,7 +325,7 @@ types in `src/result.ts` hold its shape.
## The code list ## The code list
2026-08-25, the maintainer; dated below where a rule came later. Goal 6. Valid while a consumer 2026-08-25, the maintainer; dated below where a rule came later. Goal 1. Valid while a consumer
switches on `code` with no `default`. switches on `code` with no `default`.
- Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose - Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose
@@ -269,34 +336,35 @@ 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
2026-08-28, the maintainer; dated below where a rule came later. Goal 6. Valid while a consumer 2026-08-28, the maintainer; dated below where a rule came later. Goal 1. Valid while a consumer
handles one cause alike whichever node, attribute or direction raised it. 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 two the flavour reserves part on form: a form the grammar does not have is a and the names the flavour reserves part on form: a form the grammar does not have is a claim code
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 (2026-09-01). `!adf:textBreak{}` anything but two adjacent text nodes CommonMark would read back as one,
`!adf:doc` standing beside another block (2026-09-01, the text break and `doc` 2026-10-03).
- A well-formed directive the node tables refuse — an attribute a node does not hold or spells - 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
@@ -307,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 3 and 6. 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
@@ -337,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.
@@ -353,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 7 and 8. 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
@@ -365,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
@@ -377,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
@@ -405,8 +473,8 @@ compared against `undefined` — have a half no valid document reaches.
## The size ratchet ## The size ratchet
2026-09-20, the maintainer. Goal 10. Valid while no measure picks out what readers find hard 2026-09-20, the maintainer. KISS, a technical principle. Valid while no measure picks out what
better than a function's length. readers find hard better than a function's length.
`.oxlintrc.json`'s single rule, over the files `tsconfig.build.json` builds, is a per-function line `.oxlintrc.json`'s single rule, over the files `tsconfig.build.json` builds, is a per-function line
ceiling, set at that set's worst and moving only downward. It covers the built files alone, since ceiling, set at that set's worst and moving only downward. It covers the built files alone, since
@@ -420,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.
@@ -429,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 8. 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
@@ -474,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.
@@ -535,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 8. 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
@@ -544,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 4. 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
@@ -566,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
@@ -580,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.
+95 -57
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
@@ -44,7 +46,8 @@ normalizes to it through the round-trip.
CommonMark admits no spelling — the end of a block, inside an ATX heading — or where the node CommonMark admits no spelling — the end of a block, inside an ATX heading — or where the node
carries an attribute, it is the inline directive. carries an attribute, it is the inline directive.
- An empty paragraph — real payloads carry them — is an `!adf:paragraph` … `!adf:/paragraph` pair - An empty paragraph — real payloads carry them — is an `!adf:paragraph` … `!adf:/paragraph` pair
holding nothing. holding nothing, and one whose `content` is an empty array the pair
`!adf:paragraph {content=empty}` … `!adf:/paragraph` (Attributes).
- Links `[text](url)`; `<…>` around a destination containing spaces, `<>` an empty one beside a - Links `[text](url)`; `<…>` around a destination containing spaces, `<>` an empty one beside a
title; title in double quotes. A backslash escapes a parenthesis the destination leaves title; title in double quotes. A backslash escapes a parenthesis the destination leaves
unbalanced, and a quote inside the title; a balanced pair stays bare. `<url>` autolink form only unbalanced, and a quote inside the title; a balanced pair stays bare. `<url>` autolink form only
@@ -67,7 +70,10 @@ normalizes to it through the round-trip.
matching below, which is what lets the emitter decide its own pairings. matching below, which is what lets the emitter decide its own pairings.
- Blocks separated by one blank line at document level, inside a blockquote and between CommonMark - Blocks separated by one blank line at document level, inside a blockquote and between CommonMark
blocks; inside a directive container a pair holding a directive block takes none. No trailing blocks; inside a directive container a pair holding a directive block takes none. No trailing
whitespace outside a code block's content, single trailing newline; a document with no blocks is the empty string. whitespace outside a code block's content, single trailing newline. A document whose `content` is
an empty array is the empty string, and markdown holding no block reads back to it; a document
holding no `content` key is the leaf `!adf:doc {content=none}` as its only block, which is a
named error anywhere else or spelled any other way.
## Directives ## Directives
@@ -79,9 +85,11 @@ result naming it at the opener, whatever follows it — so output an old emitter
escaped, and erroring input gaining meaning later is MINOR, never a reparse (`docs/decisions.md` escaped, and erroring input gaining meaning later is MINOR, never a reparse (`docs/decisions.md`
§The formats are API). Each name belongs to one position, and a name the other one spells — a mark §The formats are API). Each name belongs to one position, and a name the other one spells — a mark
or an inline node written as a block directive, a block node written inline — is a different error, or an inline node written as a block directive, a block node written inline — is a different error,
naming the spelling it takes. Two reserved names read back to no node: `carry` for the opaque carry, naming the spelling it takes. Four reserved names read back to no node: `carry` for the inline
as both directive name and fence info string, and `listBreak` for the leaf that parts two adjacent opaque carry, `listBreak` for the leaf that parts two adjacent lists and `doc` for a document
lists (Canonical form). holding no `content` key (Canonical form), and `textBreak` for the leaf that parts two text nodes
(Inline nodes). Every fence info string opening `adf:` is reserved for the block carry (The opaque
carry).
**Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name` **Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name`
closes a container, and a name picks by what follows it in turn — a space or the line's end a block closes a container, and a name picks by what follows it in turn — a space or the line's end a block
@@ -142,6 +150,13 @@ ends the name (`!adf:hardBreak{}`). Input reads that spelling alone: keys out of
quoted where bare carries it, an escape longer than it need be, an empty `{attrs}` on a block line quoted where bare carries it, an escape longer than it need be, an empty `{attrs}` on a block line
or after a `[content]`, and a number or `json` value outside its canonical JSON spelling are each a or after a `[content]`, and a number or `json` value outside its canonical JSON spelling are each a
named error naming the spelling to write instead. named error naming the spelling to write instead.
`attrs`, `content` and `marks` are reserved keys on every directive — block, inline node and mark —
whose bare value `empty` spells the node's or mark's key holding an empty object or array:
`!adf:underline[a]{attrs=empty}`, `!adf:date{content=empty}`, `!adf:hardBreak{marks=empty}`. A
container spelling `content=empty` closes with no body; `attrs=empty` stands beside no other
attribute, argument or content slot; and an inline node spelling `marks=empty` stands inside no
mark spelling. Any other value of a reserved key is a named error, except on a block's `marks`
(Block nodes).
**Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as **Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as
directive syntax — every literal `!adf:`, `]` inside content, a bracket a link's destination and directive syntax — every literal `!adf:`, `]` inside content, a bracket a link's destination and
@@ -163,21 +178,25 @@ and restores to a deep-equal node. A carry may hold a node the emitter spells na
unreinterpreted, and the next emit spells it canonically (`docs/decisions.md` §The round-trip is the unreinterpreted, and the next emit spells it canonically (`docs/decisions.md` §The round-trip is the
product). Block and inline positions canonicalize differently, each fitting where it sits: product). Block and inline positions canonicalize differently, each fitting where it sits:
- **Block position**: a fenced code block with info string `carry`, body = the node's JSON — - **Block position**: a fenced code block with info string `adf:` and the node's type, body = the
two-space indent, object keys sorted. node's JSON without its `type` — two-space indent, object keys sorted: ```` ```adf:blockCard ````.
A type no info string carries back, by the rule a `codeBlock`'s language follows, leaves the info
string `adf:` and keeps `type` in the body. A body holding `type` under a named type, or a fence
whose info string is `adf:` alone while its body's `type` could be spelled in the info string, is
a named error.
- **Inline position**: `!adf:carry{json="…"}` — compact serialization (keys sorted, no whitespace), - **Inline position**: `!adf:carry{json="…"}` — compact serialization (keys sorted, no whitespace),
JSON-string-escaped into the attribute. JSON-string-escaped into the attribute.
The info string `carry` is reserved: a genuine `codeBlock` whose `language` is exactly `carry` takes Every info string opening `adf:` is reserved: a genuine `codeBlock` whose `language` opens so takes
the attribute the section below keeps for a language no info string holds, so the reservation the attribute the section below keeps for a language no info string holds, so the reservation
stays absolute. stays absolute. In block-directive position `!adf:carry` is a named error — the carry's block form
In block-directive position `!adf:carry` is a named error — the carry's block form is the fence. is the fence.
## Raw HTML in input ## Raw HTML in input
CommonMark input may contain raw HTML. `markdownToAdf` routes each construct through the foreign CommonMark input may contain raw HTML. `markdownToAdf` routes each construct through the foreign
HTML element mapping (`docs/decisions.md` §Foreign HTML sorts three ways; specified with the HTML HTML element mapping (`docs/decisions.md` §Foreign HTML sorts three ways; specified with the HTML
dialect, todo.md milestone 6) — ADF has no raw-HTML node, so a construct without a mapping, comments dialect, `todo.md` item 6) — ADF has no raw-HTML node, so a construct without a mapping, comments
and processing instructions included, is an error result naming it. The flavour never emits raw and processing instructions included, is an error result naming it. The flavour never emits raw
HTML. HTML.
@@ -193,13 +212,14 @@ Each section lists attributes as `name (type)`. A parenthesized value set docume
payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by
type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare
(`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted), (`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted),
quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty; editor-normal quoted, `-0` spelled `-0`. `markdownToAdf` builds an `attrs`, `content` or `marks` key only where the
ADF reads an empty attrs object, marks array or content array as the absent key (`docs/decisions.md` markdown spells one, an empty one through its reserved key (Attributes), so a document reads back
§Equality is editor-normal) — the grammar's empty-`{attrs}` omission already collapses the two deep-equal (`docs/decisions.md` §Equality is deep). A node CommonMark spells takes the directive
spellings. form to hold an empty key.
Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a
`json` value: `!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`. `json` value, `marks=empty` where it is empty:
`!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`.
A section saying its body is inline takes at most one paragraph, whose inline content becomes A section saying its body is inline takes at most one paragraph, whose inline content becomes
the node's `content`; any other body is a named error, and an empty pair is a node holding none. the node's `content`; any other body is a named error, and an empty pair is a node holding none.
@@ -215,20 +235,24 @@ cannot — `localId` (string) on any of them, marks, and the values below — ta
form. form.
- `blockquote`, `bulletList`, `listItem` — containers, block body. Attributes: `localId` (string). - `blockquote`, `bulletList`, `listItem` — containers, block body. Attributes: `localId` (string).
- `codeBlock` — container, body one fenced code block whose info string is the language and whose - `codeBlock` — container, body one fenced code block per text node, each fence's info string the
content is the node's. Attributes: `hideLineNumbers` (boolean), `language` (string), `localId` language and its content the node's text; a node holding no `content` key is one empty fence.
(string), `uniqueId` (string), `wrap` (boolean). A language no info string carries back — empty, Attributes: `hideLineNumbers` (boolean), `language` (string), `localId` (string), `uniqueId`
the reserved `carry`, or holding a backtick, a backslash, a control character, edge whitespace or (string), `wrap` (boolean). A language no info string carries back — empty, opening the reserved
an entity reference — rides the `language` attribute instead and the fence carries no info `adf:`, or holding a backtick, a backslash, a control character, edge whitespace or an entity
string; writing it in the slot that rule leaves empty, or in both, is a named error. The body is reference — rides the `language` attribute instead and the fences carry no info string, and so
one ordinary code block, and a fence's info string decodes escapes and entity references as any does a language beside `content=empty`, which has no fence; writing it in the slot that rule
other does. leaves empty, or in both, is a named error, and so are fences whose info strings differ and an
empty fence beside another. Each fence is an ordinary code block, and its info string decodes
escapes and entity references as any other does. A node holding a child no fence holds — any but
a text node 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).
@@ -424,10 +448,11 @@ Right.
Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of
the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot as the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot as
plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
parsing to anything but one text node carrying neither marks, attributes nor content — adjacent text parsing to anything but one text node carrying neither marks, attributes nor content is a named
nodes with identical marks and no attributes merged first — is a named error, and so is a `text` key error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot.
in `{attrs}`. An enclosing mark spelling does not reach into the slot. The rest take no content, An `emoji`, `mention` or `status` whose `text` attribute holds a line ending or a null character
`!adf:text` included; content on a node that takes none is a named error. 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`
@@ -449,20 +474,32 @@ in `{attrs}`. An enclosing mark spelling does not reach into the slot. The rest
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; strike spelling's inner edges, a pipe cell's edges. The reserved key carries the node's text,
`markdownToAdf` merges adjacent text nodes carrying identical marks and no attributes escaped by the attribute grammar and never literal: pipe cells trim and pad. The emitter wraps the
(`docs/decisions.md` §Equality is editor-normal). Input reads that spelling alone: the value is one run alone and leaves the rest plain text, which the spelled run joins on reading. Input reads that
run of spaces and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark spelling alone: the value is one run of spaces and tabs, one run of newlines, or one run of null
carries plainly — is a named error. 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.
``` ```
**Adjacent text nodes.** CommonMark reads two adjacent text nodes back as one where neither is
carried, neither holds `attrs` or an empty key, and their marks are identical, attributes included.
The reserved leaf `!adf:textBreak{}` parts such a pair, inside every mark spelling the two share; a
code span holds no directive, so it closes and reopens. It builds no node and reads only between
two such nodes: elsewhere, or with `[content]` or `{attrs}`, it is a named error (`docs/decisions.md`
§`!adf:textBreak{}` parts text CommonMark would join). A text node holding `attrs`, `content` or an
empty key, or holding no text, rides the inline carry.
```
Hello, !adf:textBreak{}world — **Hello, !adf:textBreak{}world** — `a`!adf:textBreak{}`b`
```
## Marks ## Marks
An inline node's marks ride the spelling wrapped around them, never the block sections' reserved An inline node's marks ride the spelling wrapped around them, never the block sections' reserved
@@ -490,20 +527,21 @@ the directive form, open to no literal reading, is a named error.
A spelling adds its mark to every inline node it wraps, and nesting is the marks array in order, A spelling adds its mark to every inline node it wraps, and nesting is the marks array in order,
outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the
reverse. `adfToMarkdown` nests in the order the array holds rather than sorting it — reverse. `adfToMarkdown` nests in the order the array holds rather than sorting it —
`docs/decisions.md` §Equality is editor-normal restores the array, not a set — and opens each `docs/decisions.md` §Equality is deep restores the array, not a set — and opens each spelling once
spelling once over the longest run of adjacent inline nodes carrying an identical mark, attributes over the longest run of adjacent inline nodes carrying an identical mark, attributes included, at
included, at that depth. A run breaks at every node the emitter carries, so no emitted carry sits that depth: `attrs: {}` differs from no `attrs`, and a directive spells it `{attrs=empty}`. A run
inside a mark spelling. breaks at every node the emitter carries, so no emitted carry sits inside a mark spelling.
An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs spelling does not list, a value that is not the spelling's type, an attribute the spelling needs and
and the mark lacks, an order putting a code span outside another mark, `code` over anything but a the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside
text node or over text holding a newline, or a spelling CommonMark's flanking rules cannot open or another mark, `code` over anything but a text node 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')
})
+68 -2
View File
@@ -1,6 +1,7 @@
import type { ConvertFault } from '../result.ts' import type { ConvertFault } from '../result.ts'
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts' import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
import { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
export type AdfAttributes = { [key: string]: JsonValue } export type AdfAttributes = { [key: string]: JsonValue }
@@ -17,6 +18,8 @@ export type AdfNode = {
type: string type: string
} }
export type EmptyKey = 'attrs' | 'content' | 'marks'
export type AdfDocument = { export type AdfDocument = {
content?: AdfNode[] content?: AdfNode[]
type: 'doc' type: 'doc'
@@ -40,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)
} }
@@ -48,11 +52,49 @@ 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) return false if (nodeMarks(node).length > 0 || node.text !== undefined || emptyKeys(node).length > 0) return false
return holdsOnly(nodeAttrs(node), attributes) return holdsOnly(nodeAttrs(node), attributes)
} }
export function emptyKeys(held: { attrs?: AdfAttributes; content?: AdfNode[]; marks?: AdfMark[] }): EmptyKey[] {
const keys: EmptyKey[] = []
if (held.attrs !== undefined && Object.keys(held.attrs).length === 0) keys.push('attrs')
if (held.content?.length === 0) keys.push('content')
if (held.marks?.length === 0) keys.push('marks')
return keys
}
// 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 {
return marksKey([left]) === marksKey([right])
}
export function identicalMarks(left: readonly AdfMark[], right: readonly AdfMark[]): boolean {
return marksKey(left) === marksKey(right)
}
export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] {
const merged: AdfNode[] = []
for (const node of nodes) {
const previous = merged[merged.length - 1]
if (previous !== undefined && joins(previous, node)) {
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
continue
}
merged.push(node)
}
return merged
}
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do. // Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
export function isAdfDocument(value: unknown): value is AdfDocument { export function isAdfDocument(value: unknown): value is AdfDocument {
const fault = adfDocumentFault(value) const fault = adfDocumentFault(value)
@@ -81,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) {
@@ -99,6 +158,13 @@ function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
return true return true
} }
function marksKey(marks: readonly AdfMark[]): string {
return serializeCanonicalJson(
marks.map((mark) => (mark.attrs === undefined ? [mark.type] : [mark.type, mark.attrs])),
'compact',
)
}
function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined { function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
const pending: AdfNode[] = [...nodes] const pending: AdfNode[] = [...nodes]
while (pending.length > 0) { while (pending.length > 0) {
+1 -1
View File
@@ -18,7 +18,7 @@ export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling)
const { depth, value: held } = next const { depth, value: held } = next
if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth) if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth)
else if (held !== null && typeof held === 'object') schedule(pending, '{', objectMembers(held, indent), '}', indent, depth) else if (held !== null && typeof held === 'object') schedule(pending, '{', objectMembers(held, indent), '}', indent, depth)
else text.push(JSON.stringify(held)) else text.push(Object.is(held, -0) ? '-0' : JSON.stringify(held))
} }
return text.join('') return text.join('')
} }
+14 -5
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,14 +34,14 @@ 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(toEditorNormal(read.value), document, `reading ${JSON.stringify(emitted.value)}`) assert.deepEqual(read.value, document, `reading ${JSON.stringify(emitted.value)}`)
}), }),
propertyRuns(gateRuns), propertyRuns(gateRuns),
) )
@@ -62,3 +62,12 @@ test('a generated document writes plain markdown refusing only what the guard re
propertyRuns(gateRuns), propertyRuns(gateRuns),
) )
}) })
test('a generated document writes the plain markdown its editor-normal form writes', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
assert.deepEqual(adfToPlainMarkdown(document), adfToPlainMarkdown(toEditorNormal(document)))
}),
propertyRuns(gateRuns),
)
})
+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`)
} }
}) })
+4 -5
View File
@@ -9,7 +9,6 @@ import { isAdfDocument } from '../adf/document.ts'
import { isJsonValue } from '../json-value.ts' import { isJsonValue } from '../json-value.ts'
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts' import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus') const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus')
const errorsRoot = join(corpusRoot, 'errors') const errorsRoot = join(corpusRoot, 'errors')
@@ -97,7 +96,7 @@ for (const directory of roundTripDirectories) {
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8')) const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(toEditorNormal(result.value), expected) assert.deepEqual(result.value, expected)
}) })
} }
} }
@@ -125,12 +124,12 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8')) const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(toEditorNormal(result.value), expected) assert.deepEqual(result.value, expected)
const emitted = adfToMarkdown(result.value) const emitted = adfToMarkdown(result.value)
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`) assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
const again = markdownToAdf(emitted.value) const again = markdownToAdf(emitted.value)
assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`) assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`)
assert.deepEqual(toEditorNormal(again.value), expected) assert.deepEqual(again.value, expected)
}) })
} }
@@ -146,7 +145,7 @@ for (const name of names(realPayloadsRoot, '.json')) {
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`) assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
const parsed = markdownToAdf(emitted.value) const parsed = markdownToAdf(emitted.value)
assert.ok(parsed.ok, parsed.ok ? '' : `${parsed.error.code}: ${parsed.error.message}`) assert.ok(parsed.ok, parsed.ok ? '' : `${parsed.error.code}: ${parsed.error.message}`)
assert.deepEqual(toEditorNormal(parsed.value), payload) assert.deepEqual(parsed.value, payload)
}) })
} }
+3 -4
View File
@@ -31,7 +31,6 @@ import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { nodeContent, nodeMarks } from '../adf/document.ts' import { nodeContent, nodeMarks } from '../adf/document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
import { textDirectiveName } from '../markdown/text-directive.ts' import { textDirectiveName } from '../markdown/text-directive.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
type Choice = { arbitrary: Arbitrary<string>; hostile?: true; weight: number } type Choice = { arbitrary: Arbitrary<string>; hostile?: true; weight: number }
@@ -419,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
@@ -428,12 +427,12 @@ 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)
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`) assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
assert.deepEqual(toEditorNormal(read.value), toEditorNormal(parsed.value), `reading ${JSON.stringify(emitted.value)}`) assert.deepEqual(read.value, parsed.value, `reading ${JSON.stringify(emitted.value)}`)
const respelled = adfToMarkdown(read.value) const respelled = adfToMarkdown(read.value)
assert.ok(respelled.ok, respelled.ok ? '' : `${respelled.error.code}: ${respelled.error.message} — spelling ${JSON.stringify(emitted.value)} again`) assert.ok(respelled.ok, respelled.ok ? '' : `${respelled.error.code}: ${respelled.error.message} — spelling ${JSON.stringify(emitted.value)} again`)
assert.equal(respelled.value, emitted.value) assert.equal(respelled.value, emitted.value)
+51 -21
View File
@@ -2,16 +2,17 @@ import assert from 'node:assert/strict'
import { env } from 'node:process' import { env } from 'node:process'
import fc from 'fast-check' import fc from 'fast-check'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../adf/document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode, EmptyKey } from '../adf/document.ts'
import type { Arbitrary } from 'fast-check' import type { Arbitrary } from 'fast-check'
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { blockArgument } from '../markdown/block-directive.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 { joinsWhenRead } from '../markdown/adjacent-text.ts'
import { markAttributes } from '../adf/mark-attributes.ts' import { markAttributes } from '../adf/mark-attributes.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
type Positions = { block: AdfNode; inline: AdfNode } type Positions = { block: AdfNode; inline: AdfNode }
@@ -23,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)])
@@ -36,7 +37,11 @@ export function textOf(minLength: number): Arbitrary<string> {
const text = textOf(1) const text = textOf(1)
const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type)) const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type))
const numberValue = fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 }) const numberValue = fc.oneof(
{ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 6 },
{ arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 2 },
{ arbitrary: fc.constant(-0), weight: 1 },
)
// V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash (https://issues.chromium.org/issues/521080746); Bun is unaffected. // V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash (https://issues.chromium.org/issues/521080746); Bun is unaffected.
const keyPiece = fc const keyPiece = fc
@@ -73,19 +78,35 @@ function heldAttributes(held: Readonly<Record<string, JsonValue | undefined>>):
return attrs return attrs
} }
// An empty attrs, content or marks key, and adjacent text CommonMark would read back as one, stay occasional: each takes a spelling outside CommonMark.
// The copy gives fast-check's null-prototype records the prototype a parsed node has.
function occasionallyEmpty<T extends AdfMark | AdfNode>(arbitrary: Arbitrary<T>): Arbitrary<T> {
return fc.tuple(arbitrary, fc.nat({ max: 9 })).map(([held, roll]) => (roll === 0 ? { ...held } : withoutEmptyKeys(held)))
}
function withoutEmptyKeys<T extends AdfMark | AdfNode>(held: T): T {
const kept: Partial<Record<EmptyKey, unknown>> & T = { ...held }
for (const key of emptyKeys(held)) delete kept[key]
return kept
}
function occasionallyApart(arbitrary: Arbitrary<AdfNode[]>): Arbitrary<AdfNode[]> {
return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes, joinsWhenRead)))
}
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode { function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))] const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))]
return { content: rows.map((content): AdfNode => ({ content, type: 'tableRow' })), type: 'table' } return { content: rows.map((content): AdfNode => ({ content, type: 'tableRow' })), type: 'table' }
} }
const mark: Arbitrary<AdfMark> = fc.oneof( const mark: Arbitrary<AdfMark> = occasionallyEmpty(fc.oneof(
{ arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 }, { arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 },
{ arbitrary: attributes({ color: 'string' }).map((attrs) => ({ attrs, type: 'backgroundColor' })), weight: 2 }, { arbitrary: attributes({ color: 'string' }).map((attrs) => ({ attrs, type: 'backgroundColor' })), weight: 2 },
{ arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 }, { arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 },
) ))
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type }) const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
const textNode = fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' })) const textNode = occasionallyEmpty(fc.record({ marks, text: 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') }) })
@@ -96,7 +117,7 @@ const autolinkTextNode = fc
.map(({ href, marks: held }): AdfNode => ({ marks: [...held.filter((outer) => outer.type !== 'link'), { attrs: { href }, type: 'link' }], text: href, type: 'text' })) .map(({ href, marks: held }): AdfNode => ({ marks: [...held.filter((outer) => outer.type !== 'link'), { attrs: { href }, type: 'link' }], text: href, type: 'text' }))
const inlineArbitraries = Object.entries(inlineNodes).map(([type, model]) => const inlineArbitraries = Object.entries(inlineNodes).map(([type, model]) =>
fc.record({ attrs: attributes(model.attributes), marks }).map((held): AdfNode => ({ ...held, type })), occasionallyEmpty(fc.record({ attrs: attributes(model.attributes), marks }).map((held): AdfNode => ({ ...held, type }))),
) )
function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): { arbitrary: Arbitrary<AdfNode>; weight: number }[] { function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): { arbitrary: Arbitrary<AdfNode>; weight: number }[] {
@@ -105,10 +126,10 @@ function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): {
const positions = fc.letrec<Positions>((tie) => { const positions = fc.letrec<Positions>((tie) => {
const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 }) const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 })
const inlineContent = fc.array(tie('inline'), { depthIdentifier, maxLength: 4 }) const inlineContent = occasionallyApart(fc.array(tie('inline'), { depthIdentifier, maxLength: 4 }))
const contentByModel = { const contentByModel = {
block: blockContent, block: blockContent,
code: fc.array(text.map((held): AdfNode => ({ text: held, type: 'text' })), { maxLength: 2 }), code: occasionallyApart(fc.array(text.map((held): AdfNode => ({ text: held, type: 'text' })), { maxLength: 2 })),
inline: inlineContent, inline: inlineContent,
none: fc.constant<AdfNode[]>([]), none: fc.constant<AdfNode[]>([]),
} }
@@ -116,35 +137,40 @@ const positions = fc.letrec<Positions>((tie) => {
const blockArbitraries = Object.entries(blockNodes).map(([type, model]) => { const blockArbitraries = Object.entries(blockNodes).map(([type, model]) => {
const argument = blockArgument(type) const argument = blockArgument(type)
const vocabulary: AttributeVocabulary = argument === undefined ? model.attributes : { ...model.attributes, [argument]: 'string' } const vocabulary: AttributeVocabulary = argument === undefined ? model.attributes : { ...model.attributes, [argument]: 'string' }
const node = fc.record({ attrs: attributes(vocabulary), content: contentByModel[model.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type })) const node = occasionallyEmpty(fc.record({ attrs: attributes(vocabulary), content: contentByModel[model.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type })))
return { leaf: model.contentModel === 'code' || model.contentModel === 'none', node } return { leaf: model.contentModel === 'code' || model.contentModel === 'none', node }
}) })
const unknownNode = fc const unknownNode = occasionallyEmpty(
.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType }) fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType }),
.map((held): AdfNode => held) )
const leafBlocks = blockArbitraries.filter((entry) => entry.leaf).map((entry) => entry.node) const leafBlocks = blockArbitraries.filter((entry) => entry.leaf).map((entry) => entry.node)
const containerBlocks = blockArbitraries.filter((entry) => !entry.leaf).map((entry) => entry.node) const containerBlocks = blockArbitraries.filter((entry) => !entry.leaf).map((entry) => entry.node)
const misplacedWeight = 7 const misplacedWeight = 7
const paragraph = fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: fc.array(backtickRunNode, { maxLength: 4, minLength: 2 }), weight: 1 }).map((content): AdfNode => ({ content, type: 'paragraph' })) const paragraph = occasionallyEmpty(
fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: occasionallyApart(fc.array(backtickRunNode, { maxLength: 4, minLength: 2 })), weight: 1 }).map((content): AdfNode => ({ content, type: 'paragraph' })),
)
const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type })) const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type }))
const listItems = fc.array( const listItems = fc.array(
blockContent.map((content): AdfNode => ({ content, type: 'listItem' })), occasionallyEmpty(blockContent.map((content): AdfNode => ({ content, type: 'listItem' }))),
{ depthIdentifier, maxLength: 3, minLength: 1 }, { depthIdentifier, maxLength: 3, minLength: 1 },
) )
const flatCommonMarkShapes = [ const flatCommonMarkShapes = [
fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })), occasionallyEmpty(fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' }))),
paragraph, paragraph,
fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable), fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable),
] ]
const task = (type: string, content: Arbitrary<AdfNode[]>) => const task = (type: string, content: Arbitrary<AdfNode[]>) =>
fc.record({ content, state: fc.constantFrom('DONE', 'TODO') }).map(({ content: held, state }): AdfNode => ({ attrs: { state }, content: held, type })) occasionallyEmpty(fc.record({ content, state: fc.constantFrom('DONE', 'TODO') }).map(({ content: held, state }): AdfNode => ({ attrs: { state }, content: held, type })))
const taskItem = fc.oneof({ arbitrary: task('taskItem', inlineContent), weight: 3 }, { arbitrary: task('blockTaskItem', blockContent), weight: 1 }) const taskItem = fc.oneof({ arbitrary: task('taskItem', inlineContent), weight: 3 }, { arbitrary: task('blockTaskItem', blockContent), weight: 1 })
const nestingCommonMarkShapes = [ const nestingCommonMarkShapes = [
blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })), occasionallyEmpty(blockContent.map((content): AdfNode => ({ content, type: 'blockquote' }))),
fc.array(fc.oneof({ arbitrary: taskItem, weight: 3 }, { arbitrary: tie('block'), weight: 1 }), { depthIdentifier, maxLength: 3, minLength: 1 }).map((content): AdfNode => ({ content, type: 'taskList' })), fc.array(fc.oneof({ arbitrary: taskItem, weight: 3 }, { arbitrary: tie('block'), weight: 1 }), { depthIdentifier, maxLength: 3, minLength: 1 }).map((content): AdfNode => ({ content, type: 'taskList' })),
listItems.map((content): AdfNode => ({ content, type: 'bulletList' })), listItems.map((content): AdfNode => ({ content, type: 'bulletList' })),
fc fc
.record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) }) .record({
content: listItems,
order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 8 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 2 }, { arbitrary: fc.constant(-0), weight: 1 }),
})
.map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })), .map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })),
] ]
const flatBlocks = [...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)] const flatBlocks = [...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)]
@@ -167,7 +193,11 @@ const positions = fc.letrec<Positions>((tie) => {
} }
}) })
export const adfDocument = fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => toEditorNormal({ content, type: 'doc', version: 1 })) export const adfDocument = fc.oneof(
{ arbitrary: fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => ({ content, type: 'doc', version: 1 })), weight: 30 },
{ arbitrary: fc.constant<AdfDocument>({ content: [], type: 'doc', version: 1 }), weight: 1 },
{ arbitrary: fc.constant<AdfDocument>({ type: 'doc', version: 1 }), weight: 1 },
)
export function propertyRuns(gateRuns: number): { gate: boolean; numRuns: number; seed?: number } { export function propertyRuns(gateRuns: number): { gate: boolean; numRuns: number; seed?: number } {
const deepRuns = env[deepRunsVariable] const deepRuns = env[deepRunsVariable]
+2 -1
View File
@@ -1,7 +1,8 @@
// Export only the conversions, their types, isAdfDocument and what a guarantee or a persona needs.
export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts' export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts' export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
export type { JsonValue } from './json-value.ts' export type { JsonValue } from './json-value.ts'
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { adfToPlainMarkdown } from './markdown/emit/plain-reduction.ts' export { 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))
}
+11 -7
View File
@@ -2,9 +2,9 @@ import type { AdfMark } from '../adf/document.ts'
import type { BlockType } from '../adf/block-nodes.ts' import type { BlockType } from '../adf/block-nodes.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { blockNodeModel } from '../adf/block-nodes.ts' import { blockNodeModel } from '../adf/block-nodes.ts'
import { isAdfMark, nodeAttrs } 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({
@@ -14,6 +14,13 @@ const argumentByType = new Map(
} satisfies Partial<Record<BlockType, string>>), } satisfies Partial<Record<BlockType, string>>),
) )
export const documentName = 'doc'
// spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell.
export const documentAttribute = { key: 'content', value: 'none' }
export const documentSpelling = spellDirectiveOpener(documentName, undefined, spellAttributes([[documentAttribute.key, documentAttribute.value]]))
export const listBreakName = 'listBreak' export const listBreakName = 'listBreak'
export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '') export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
@@ -25,17 +32,14 @@ export function blockArgument(type: string): string | undefined {
} }
export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined { export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined {
if (name === listBreakName) return 'leaf' if (name === listBreakName || name === documentName) return 'leaf'
const model = blockNodeModel(name) const model = blockNodeModel(name)
if (model === undefined) return undefined if (model === undefined) return undefined
return model.contentModel === 'none' ? 'leaf' : 'container' return model.contentModel === 'none' ? 'leaf' : 'container'
} }
export function markValues(marks: readonly AdfMark[]): JsonValue { export function markValues(marks: readonly AdfMark[]): JsonValue {
return marks.map((mark) => { return marks.map((mark) => (mark.attrs === undefined ? { type: mark.type } : { attrs: mark.attrs, type: mark.type }))
const attrs = nodeAttrs(mark)
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
})
} }
export function readMarkValues(value: JsonValue): AdfMark[] | undefined { export function readMarkValues(value: JsonValue): AdfMark[] | undefined {
+3 -5
View File
@@ -1,14 +1,12 @@
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { carryName } from './opaque-carry.ts' import { carryFenceType } from './opaque-carry.ts'
import { holdsControlCharacter } from './commonmark/grammar.ts' import { infoStringCarries } from './commonmark/grammar.ts'
import { holdsEntityReference } from './commonmark/entity-references.ts'
export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' } export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' }
// spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides. // spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides.
export function languageSlot(language: JsonValue | undefined): LanguageSlot { export function languageSlot(language: JsonValue | undefined): LanguageSlot {
if (language === undefined) return { kind: 'none' } if (language === undefined) return { kind: 'none' }
if (typeof language !== 'string' || language === '' || language === carryName) return { kind: 'attribute' } if (typeof language !== 'string' || carryFenceType(language) !== undefined || !infoStringCarries(language)) return { kind: 'attribute' }
if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return { kind: 'attribute' }
return { info: language, kind: 'fence' } return { info: language, kind: 'fence' }
} }
+6 -5
View File
@@ -1,4 +1,4 @@
import { readEntityReference, replacementCharacter } from './entity-references.ts' import { holdsEntityReference, readEntityReference, replacementCharacter } from './entity-references.ts'
export type LinePosition = 'first' | 'later' export type LinePosition = 'first' | 'later'
@@ -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)
} }
@@ -133,6 +129,11 @@ export function holdsControlCharacter(text: string): boolean {
return controlCharacter.test(text) return controlCharacter.test(text)
} }
// What a backtick fence's info string reads back verbatim: escapes and entity references decode, and the edges trim.
export function infoStringCarries(text: string): boolean {
return text !== '' && !/[`\\]/.test(text) && !holdsControlCharacter(text) && text === text.trim() && !holdsEntityReference(text)
}
export function holdsNullCharacter(text: string): boolean { export function holdsNullCharacter(text: string): boolean {
return nullCharacter.test(text) return nullCharacter.test(text)
} }
+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('"', '\\"')}"`
} }
+6 -6
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 {
@@ -139,7 +139,7 @@ export function spellStringAttribute(text: string): string {
export function spellAttributeValue(value: VocabularyValue): string { export function spellAttributeValue(value: VocabularyValue): string {
if (value.kind === 'boolean') return `${value.value}` if (value.kind === 'boolean') return `${value.value}`
if (value.kind === 'json') return spellJsonAttribute(value.value) if (value.kind === 'json') return spellJsonAttribute(value.value)
if (value.kind === 'number') return spellStringAttribute(JSON.stringify(value.value)) if (value.kind === 'number') return spellStringAttribute(serializeCanonicalJson(value.value, 'compact'))
return spellStringAttribute(value.value) return spellStringAttribute(value.value)
} }
+74 -67
View File
@@ -6,14 +6,13 @@ import type { JsonValue } from '../../json-value.ts'
import type { Result } from '../../result.ts' import type { Result } from '../../result.ts'
import { adfToMarkdown, markdownToAdf } from '../../index.ts' import { adfToMarkdown, markdownToAdf } from '../../index.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
function document(...content: AdfNode[]): AdfDocument { function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 } return { content, type: 'doc', version: 1 }
} }
function paragraph(...content: AdfNode[]): AdfNode { function paragraph(...content: AdfNode[]): AdfNode {
return { content, type: 'paragraph' } return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' }
} }
function code(result: Result<string>): string { function code(result: Result<string>): string {
@@ -36,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)
}) })
@@ -148,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', () => {
@@ -168,21 +175,24 @@ test('parts two adjacent lists of the same kind, the marker spelling being what
const nested: AdfNode = { content: [{ content: [list, list], type: 'listItem' }], type: 'bulletList' } const nested: AdfNode = { content: [{ content: [list, list], type: 'listItem' }], type: 'bulletList' }
assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n !adf:listBreak\n\n - x\n') assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n !adf:listBreak\n\n - x\n')
const carried: AdfNode = { ...list, attrs: { unknown: 'x' } } const carried: AdfNode = { ...list, attrs: { unknown: 'x' } }
assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```carry\n')) assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```adf:bulletList\n'))
assert.ok(markdown(adfToMarkdown(document(carried, list))).endsWith('```\n\n- x\n')) assert.ok(markdown(adfToMarkdown(document(carried, list))).endsWith('```\n\n- x\n'))
assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```carry\n')) assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```adf:bulletList\n'))
}) })
test('carries a node type no section spells', () => { test('carries a node type no section spells, the fence naming the type wherever an info string carries it', () => {
assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```carry\n{\n "type": "blockCard"\n}\n```\n') assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```adf:blockCard\n{}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```carry\n{\n "type": "toString"\n}\n```\n') assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```adf:toString\n{}\n```\n')
assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), '!adf:carry{json="{\\"type\\":\\"blockCard\\"}"}\n') assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), '!adf:carry{json="{\\"type\\":\\"blockCard\\"}"}\n')
assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```carry\n{\n "text": "x",\n "type": "text"\n}\n```\n') assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```adf:text\n{\n "text": "x"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```carry\n{\n "type": "hardBreak"\n}\n```\n') assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```adf:hardBreak\n{}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'a&amp;b' }))), '```adf:\n{\n "type": "a&amp;b"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'a\\b' }))), '```adf:\n{\n "type": "a\\\\b"\n}\n```\n')
}) })
test('spells the code block whose language is the reserved info string', () => { test('spells the code block whose language opens with the reserved info string prefix', () => {
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '!adf:codeBlock {language=carry}\n```\n```\n!adf:/codeBlock\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf:x' }, type: 'codeBlock' }))), '!adf:codeBlock {language="adf:x"}\n```\n```\n!adf:/codeBlock\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '```carry\n```\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), '```adf\n```\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), '```adf\n```\n')
}) })
@@ -208,21 +218,17 @@ test('refuses a carried node nested deeper than the levels its position leaves',
assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth') assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth')
}) })
test('refuses a node whose content model the canonical form cannot emit', () => { test('carries a code block holding a node no fence holds', () => {
assert.equal( assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), '```adf:codeBlock\n{\n "content": [\n {\n "type": "paragraph"\n }\n ]\n}\n```\n')
markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), for (const child of [{ attrs: {}, text: 'x', type: 'text' }, { content: [], text: 'x', type: 'text' }, { marks: [], text: 'x', type: 'text' }, { content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }]) {
'unsupported-node-shape: a codeBlock holds plain text nodes only: this paragraph node is not one', assert.ok(markdown(adfToMarkdown(document({ content: [child], type: 'codeBlock' }))).startsWith('```adf:codeBlock\n'), JSON.stringify(child))
) }
assert.equal(
markdown(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))),
'unsupported-node-shape: a codeBlock holds plain text nodes only: this text node is not one',
)
}) })
test('spells a list its own content shape cannot hold as a directive', () => { test('spells a list its own content shape cannot hold as a directive', () => {
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), '!adf:bulletList\n!adf:paragraph\n!adf:/paragraph\n!adf:/bulletList\n') assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), '!adf:bulletList\n!adf:paragraph\n!adf:/paragraph\n!adf:/bulletList\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), '!adf:bulletList\n!adf:/bulletList\n') assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), '!adf:bulletList\n!adf:/bulletList\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {order=2}\n!adf:/orderedList\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {content=empty order=2}\n!adf:/orderedList\n')
}) })
test('spells an ordered list no marker fits as a directive', () => { test('spells an ordered list no marker fits as a directive', () => {
@@ -334,7 +340,7 @@ test('refuses a node carrying one mark type twice', () => {
}) })
test('parts a nested list the tight spelling would swallow from the block above it', () => { test('parts a nested list the tight spelling would swallow from the block above it', () => {
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' }) const item = (...content: AdfNode[]): AdfNode => (content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' })
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' }) const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' }) const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' })
const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' } const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' }
@@ -345,7 +351,7 @@ test('parts a nested list the tight spelling would swallow from the block above
const list: AdfNode = { content: [item(text('b'))], type: 'bulletList' } const list: AdfNode = { content: [item(text('b'))], type: 'bulletList' }
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [text('p')], type: 'panel' } const panel: AdfNode = { attrs: { panelType: 'info' }, content: [text('p')], type: 'panel' }
assert.equal(markdown(adfToMarkdown(outer(panel, list))), '- !adf:panel info\n p\n !adf:/panel\n\n - b\n') assert.equal(markdown(adfToMarkdown(outer(panel, list))), '- !adf:panel info\n p\n !adf:/panel\n\n - b\n')
assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```carry\n')) assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```adf:bulletList\n'))
}) })
test('refuses marks and attributes nested deeper than the emitter carries', () => { test('refuses marks and attributes nested deeper than the emitter carries', () => {
@@ -372,7 +378,7 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message) assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
const read = markdownToAdf(spelled.value) const read = markdownToAdf(spelled.value)
assert.ok(read.ok, read.ok ? '' : read.error.message) assert.ok(read.ok, read.ok ? '' : read.error.message)
assert.deepEqual(toEditorNormal(read.value), document(node)) assert.deepEqual(read.value, document(node))
} }
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em')) assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em'))
@@ -442,7 +448,7 @@ test('escapes a hyphen underline a hard break would expose', () => {
}) })
test('spells a list item whose marker completes a thematic break as a directive', () => { test('spells a list item whose marker completes a thematic break as a directive', () => {
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' }) const item = (...content: AdfNode[]): AdfNode => (content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' })
assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n') assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')
const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' } const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n') assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n')
@@ -458,31 +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 codeBlock holds plain text nodes only: this text node is not one',
)
}) })
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', () => {
@@ -501,11 +502,11 @@ test('pads a code span whose edges CommonMark would strip', () => {
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: ' \t ', type: 'text' })))), '` \t `\n') assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: ' \t ', type: 'text' })))), '` \t `\n')
}) })
test('spells one code span over a run of code-marked nodes', () => { test('spells a code span per code-marked node, parted by the text break', () => {
const code_ = { type: 'code' } const code_ = { type: 'code' }
assert.equal( assert.equal(
markdown(adfToMarkdown(document(paragraph({ marks: [code_], text: 'a', type: 'text' }, { marks: [code_], text: 'b', type: 'text' })))), markdown(adfToMarkdown(document(paragraph({ marks: [code_], text: 'a', type: 'text' }, { marks: [code_], text: 'b', type: 'text' })))),
'`ab`\n', '`a`!adf:textBreak{}`b`\n',
) )
}) })
@@ -545,7 +546,7 @@ test('emits an empty list item without trailing whitespace', () => {
test('spells a block directive as its node type, arg and attributes', () => { test('spells a block directive as its node type, arg and attributes', () => {
const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' }) const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' })
assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), '!adf:panel warning\nx\n!adf:/panel\n') assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), '!adf:panel warning\nx\n!adf:/panel\n')
assert.equal(markdown(adfToMarkdown(panel({}))), '!adf:panel\nx\n!adf:/panel\n') assert.equal(markdown(adfToMarkdown(panel({}))), '!adf:panel {attrs=empty}\nx\n!adf:/panel\n')
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), '!adf:caption\nx\n!adf:/caption\n') assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), '!adf:caption\nx\n!adf:/caption\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '!adf:caption\n!adf:/caption\n') assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '!adf:caption\n!adf:/caption\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '!adf:syncBlock {localId=a}\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '!adf:syncBlock {localId=a}\n')
@@ -553,20 +554,20 @@ test('spells a block directive as its node type, arg and attributes', () => {
test('carries a directive attribute no section spells', () => { test('carries a directive attribute no section spells', () => {
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node))) const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```carry\n{\n "attrs": {\n "rounded": true\n },\n "type": "panel"\n}\n```\n') assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "rounded": true\n }\n}\n```\n')
assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```carry\n{\n "attrs": {\n "toString": "x"\n },\n "type": "panel"\n}\n```\n') assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "toString": "x"\n }\n}\n```\n')
assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```carry\n{\n "attrs": {\n "localId": 4\n },\n "type": "panel"\n}\n```\n') assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "localId": 4\n }\n}\n```\n')
assert.equal( assert.equal(
carried({ attrs: { width: '50' }, type: 'layoutColumn' }), carried({ attrs: { width: '50' }, type: 'layoutColumn' }),
'```carry\n{\n "attrs": {\n "width": "50"\n },\n "type": "layoutColumn"\n}\n```\n', '```adf:layoutColumn\n{\n "attrs": {\n "width": "50"\n }\n}\n```\n',
) )
assert.equal( assert.equal(
carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }), carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }),
'```carry\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n },\n "type": "table"\n}\n```\n', '```adf:table\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n }\n}\n```\n',
) )
assert.equal( assert.equal(
carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }), carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }),
'!adf:mediaGroup\n```carry\n{\n "attrs": {\n "alt": 4\n },\n "type": "media"\n}\n```\n!adf:/mediaGroup\n', '!adf:mediaGroup\n```adf:media\n{\n "attrs": {\n "alt": 4\n }\n}\n```\n!adf:/mediaGroup\n',
) )
}) })
@@ -574,15 +575,16 @@ test('carries an arg slot value no bare token spells', () => {
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node))) const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
assert.equal( assert.equal(
carried({ attrs: { panelType: 'extra info' }, type: 'panel' }), carried({ attrs: { panelType: 'extra info' }, type: 'panel' }),
'```carry\n{\n "attrs": {\n "panelType": "extra info"\n },\n "type": "panel"\n}\n```\n', '```adf:panel\n{\n "attrs": {\n "panelType": "extra info"\n }\n}\n```\n',
) )
assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```carry\n{\n "attrs": {\n "state": 2\n },\n "type": "taskItem"\n}\n```\n') assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```adf:taskItem\n{\n "attrs": {\n "state": 2\n }\n}\n```\n')
}) })
test('carries a block node mark in the reserved attribute', () => { test('carries a block node mark in the reserved attribute', () => {
const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' }) const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' })
assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n') assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n') assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"attrs\\":{},\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
assert.equal(markdown(adfToMarkdown(section())), '!adf:layoutSection {marks=empty}\n!adf:/layoutSection\n')
}) })
test('refuses the content a directive body has no room for', () => { test('refuses the content a directive body has no room for', () => {
@@ -604,7 +606,7 @@ test('separates blocks in a container body by a blank line only where a directiv
test('spells the image form for exactly the centered external media shape', () => { test('spells the image form for exactly the centered external media shape', () => {
const url = 'https://example.com/moon.png' const url = 'https://example.com/moon.png'
const single = (attrs: AdfAttributes, ...content: AdfNode[]): AdfDocument => const single = (attrs: AdfAttributes, ...content: AdfNode[]): AdfDocument =>
document({ attrs: { layout: 'center' }, content: [{ attrs, content, type: 'media' }], type: 'mediaSingle' }) document({ attrs: { layout: 'center' }, content: [content.length === 0 ? { attrs, type: 'media' } : { attrs, content, type: 'media' }], type: 'mediaSingle' })
assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `![The moon](${url})\n`) assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `![The moon](${url})\n`)
assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `![](${url})\n`) assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `![](${url})\n`)
assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`) assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`)
@@ -692,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', () => {
@@ -701,7 +707,8 @@ test('spells the directive marks around the longest run they cover', () => {
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content)))) const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
const underline: AdfMark = { type: 'underline' } const underline: AdfMark = { type: 'underline' }
assert.equal(emitted(marked('x', underline)), '!adf:underline[x]\n') assert.equal(emitted(marked('x', underline)), '!adf:underline[x]\n')
assert.equal(emitted(marked('a', underline), marked('b', underline)), '!adf:underline[ab]\n') assert.equal(emitted(marked('a', underline), marked('b', underline)), '!adf:underline[a!adf:textBreak{}b]\n')
assert.equal(emitted(marked('a', { attrs: {}, type: 'underline' }), marked('b', underline)), '!adf:underline[a]{attrs=empty}!adf:underline[b]\n')
assert.equal(emitted(marked('x', { attrs: { type: 'sub' }, type: 'subsup' })), '!adf:subsup[x]{type=sub}\n') assert.equal(emitted(marked('x', { attrs: { type: 'sub' }, type: 'subsup' })), '!adf:subsup[x]{type=sub}\n')
assert.equal(emitted(marked('x', { attrs: { color: '#ae2e24' }, type: 'textColor' })), '!adf:textColor[x]{color="#ae2e24"}\n') assert.equal(emitted(marked('x', { attrs: { color: '#ae2e24' }, type: 'textColor' })), '!adf:textColor[x]{color="#ae2e24"}\n')
assert.equal(emitted(marked('x', { attrs: { color: '#091e42', size: 2 }, type: 'border' })), '!adf:border[x]{color="#091e42" size=2}\n') assert.equal(emitted(marked('x', { attrs: { color: '#091e42', size: 2 }, type: 'border' })), '!adf:border[x]{color="#091e42" size=2}\n')
@@ -746,5 +753,5 @@ test('carries whitespace CommonMark strips in the reserved text directive', () =
test("joins a mark run's segments as a walk rather than as one call's arguments", () => { test("joins a mark run's segments as a walk rather than as one call's arguments", () => {
const run = Array.from({ length: 200000 }, (): AdfNode => ({ marks: [{ type: 'strong' }], text: 'a', type: 'text' })) const run = Array.from({ length: 200000 }, (): AdfNode => ({ marks: [{ type: 'strong' }], text: 'a', type: 'text' }))
assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${'a'.repeat(200000)}**\n`) assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${Array.from({ length: 200000 }, () => 'a').join('!adf:textBreak{}')}**\n`)
}) })
+45 -52
View File
@@ -1,16 +1,16 @@
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, nodeMarks } 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, 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'
import { emitInlineLine } from './inline-line.ts' import { emitInlineLine } from './inline-line.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { fencedCodeBlock } from '../commonmark/backtick-runs.ts' import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts' import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts'
import { languageSlot } from '../code-language.ts' import { languageSlot, type LanguageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { spellBlockDirectiveOpener } from './block-directive-spelling.ts' import { spellBlockDirectiveOpener } from './block-directive-spelling.ts'
import { spellDirectiveCloser, spellDirectiveOpener } from '../directive-syntax.ts' import { spellDirectiveCloser, spellDirectiveOpener } from '../directive-syntax.ts'
@@ -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
@@ -40,14 +40,15 @@ export function writeMarkdown(document: AdfDocument, flavour: Flavour): Result<s
const fault = adfDocumentFault(document) const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, []) if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const walk = walkBlocks(nodeContent(document), [], 0, { flavour, memo: undefined }) if (document.content === undefined) return success(`${documentSpelling}\n`)
const walk = walkBlocks(document.content, [], 0, { flavour, memo: undefined })
if (!walk.ok) return walk if (!walk.ok) return walk
const text = joinBlocks(walk.value.blocks, 'document') const text = joinBlocks(walk.value.blocks, 'document')
return success(text === '' ? '' : `${text}\n`) return success(text === '' ? '' : `${text}\n`)
} }
// headroom: the least slack any depth guard below the walk has. // headroom: the least slack any depth guard below the walk has.
function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, 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[] = []
@@ -75,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 {
@@ -127,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))
@@ -180,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') {
@@ -219,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)
@@ -229,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')
@@ -242,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')
@@ -250,48 +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 text = codeBlockText(node, path) const [only, ...others] = fencedTexts(node) ?? []
if (!text.ok) return text if (only === undefined || others.length > 0) return undefined
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value))) 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.
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 slot = languageSlot(nodeAttrs(node)['language']) const texts = fencedTexts(node)
if (texts === undefined) return commonMarkLine(carriedBlock(node, path, depth))
const slot: LanguageSlot = texts.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 text = codeBlockText(node, path) const info = slot.kind === 'fence' ? slot.info : ''
if (!text.ok) return text return success(directivePair(node, opener.value, texts.map((text) => fencedCodeBlock(info, text)).join('\n')))
return success(directivePair(node, opener.value, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
} }
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> { // The text of each fence, or `undefined` where a child holds what no fence keeps, so the block carry takes the node.
let text = '' function fencedTexts(node: AdfNode): string[] | undefined {
for (const [index, child] of nodeContent(node).entries()) { if (node.content === undefined) return ['']
const childPath = [...path, 'content', index] const texts: string[] = []
if ( for (const child of node.content) {
child.type !== 'text' || if (!isUnmarkedBareText(child) || typeof child.text !== 'string' || child.text === '' || /\r/.test(child.text) || holdsNullCharacter(child.text)) return undefined
typeof child.text !== 'string' || texts.push(child.text)
child.text === '' ||
nodeContent(child).length > 0 ||
nodeMarks(child).length > 0 ||
Object.keys(nodeAttrs(child)).length > 0
) {
return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath)
}
if (/\r/.test(child.text)) return failure('unspellable-character', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath)
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
text += child.text
} }
return success(text) 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()) {
@@ -340,7 +333,7 @@ function directiveItems(items: readonly WalkedItem[]): PlacedBlock[] {
function listStart(node: AdfNode, items: number): number | undefined { function listStart(node: AdfNode, items: number): number | undefined {
if (node.type !== 'orderedList') return 0 if (node.type !== 'orderedList') return 0
const start = nodeAttrs(node)['order'] const start = nodeAttrs(node)['order']
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || Object.is(start, -0) || start > largestListMarker) return undefined
return start + items - 1 > largestListMarker ? undefined : start return start + items - 1 > largestListMarker ? undefined : start
} }
@@ -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 +5,7 @@ import { blockArgument, markValues, marksAttribute } from '../block-directive.ts
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { overNested } from '../../json-value.ts' import { overNested } from '../../json-value.ts'
import { spellEmptyKeys } from '../empty-keys.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, spelledByBody: readonly string[] = []): Result<string> | undefined { export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, spelledByBody: readonly string[] = []): Result<string> | undefined {
@@ -14,7 +15,7 @@ export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel,
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody] const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, spelled) const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, spelled)
if (pairs === undefined) return undefined if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs) const spelledPairs = [...spellVocabulary(pairs), ...spellEmptyKeys(node)]
const marks = nodeMarks(node) const marks = nodeMarks(node)
if (marks.length > 0) { if (marks.length > 0) {
const values = markValues(marks) const values = markValues(marks)
+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']
@@ -2,9 +2,10 @@ import type { AdfNode } from '../../adf/document.ts'
import type { InlineNodeModel } from '../../adf/inline-nodes.ts' import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
import { nodeAttrs } from '../../adf/document.ts' import { nodeAttrs } from '../../adf/document.ts'
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts' import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
import { spellEmptyKeys } from '../empty-keys.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellInlineNodeAttributes(node: AdfNode, model: InlineNodeModel): string | undefined { export function spellInlineNodeAttributes(node: AdfNode, model: InlineNodeModel): string | undefined {
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, model.textAttribute === undefined ? [] : [model.textAttribute]) const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, model.textAttribute === undefined ? [] : [model.textAttribute])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) return pairs === undefined ? undefined : spellAttributes([...spellVocabulary(pairs), ...spellEmptyKeys(node)])
} }
+61 -65
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 { 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 { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
import { sameMark } from '../../adf/editor-normal.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,6 +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 (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
@@ -206,19 +201,30 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
continue continue
} }
const previous = runs[runs.length - 1] const previous = runs[runs.length - 1]
if (previous?.kind === 'marked' && sameMark(previous.mark, mark)) previous.nodes.push(node) if (previous?.kind === 'marked' && identicalMark(previous.mark, mark)) previous.nodes.push(node)
else runs.push({ index, kind: 'marked', mark, nodes: [node] }) else runs.push({ index, kind: 'marked', mark, nodes: [node] })
} }
return runs return runs
} }
function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean {
if (previous?.kind !== 'plain' || run.kind !== 'plain') return false
if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false
return joinsWhenRead(previous.node, run.node)
}
function nodePath(context: InlineContext, index: number): ConvertErrorPath { function nodePath(context: InlineContext, index: number): ConvertErrorPath {
return [...context.path, 'content', index] return [...context.path, 'content', index]
} }
// 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> {
@@ -231,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)
} }
@@ -252,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 (Object.keys(nodeAttrs(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.attributes) const attributes = spellMarkAttributes(mark, spelling)
if (attributes === undefined) return success({ carry: range }) if (attributes === undefined) return success({ carry: range })
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path) if (spelling.kind === 'code') return 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
@@ -293,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 },
], ],
}) })
@@ -312,19 +311,16 @@ function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRang
}) })
} }
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> { // Each node its own span: CommonMark reads two adjacent text nodes in one span back as one.
let text = '' function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange): Emission {
const spans: string[] = []
for (const node of nodes) { for (const node of nodes) {
if (node.type !== 'text' || nodeMarks(node).length !== depth + 1) return success({ carry: range }) const { text } = node
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) || 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) const fence = '`'.repeat(longestBacktickRun(text) + 1)
text += node.text spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`)
} }
if (/[\n\r]/.test(text)) return success({ carry: range }) return { segments: [syntax(spans.join(textBreakSpelling))] }
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
const fence = '`'.repeat(longestBacktickRun(text) + 1)
const padded = needsPadding(text) ? ` ${text} ` : text
return success({ segments: [syntax(`${fence}${padded}${fence}`)] })
} }
function needsPadding(text: string): boolean { function needsPadding(text: string): boolean {
+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
} }
+32
View File
@@ -0,0 +1,32 @@
import type { DirectiveAttributes, DirectiveValue, Read } from './directive-syntax.ts'
import type { EmptyKey } from '../adf/document.ts'
import { emptyKeys } from '../adf/document.ts'
import { unsupportedNodeShape } from './directive-syntax.ts'
type EmptyKeysRead = { empty: ReadonlySet<EmptyKey>; rest: Map<string, DirectiveValue> }
const emptyValue = 'empty'
// spec/flavour.md, Attributes: no attribute value spells an empty object or array.
export function spellEmptyKeys(held: Parameters<typeof emptyKeys>[0]): [string, string][] {
return emptyKeys(held).map((key) => [key, emptyValue])
}
export function spellsEmpty(value: DirectiveValue | undefined): boolean {
return value?.spelling === emptyValue
}
// `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 empty = new Set<EmptyKey>()
for (const key of keys) {
const spelled = rest.get(key)
if (spelled === undefined) continue
if (!spellsEmpty(spelled)) return { fault: unsupportedNodeShape(`the reserved key ${key} takes only the value ${emptyValue}: this one spells ${key}=${spelled.spelling}`) }
empty.add(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 } }
}
+7 -3
View File
@@ -7,6 +7,7 @@ import { holdsEntityReference } from './commonmark/entity-references.ts'
import { isAutolink } from './commonmark/grammar.ts' import { isAutolink } from './commonmark/grammar.ts'
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts' import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
import { nodeAttrs, nodeMarks } from '../adf/document.ts' import { nodeAttrs, nodeMarks } from '../adf/document.ts'
import { spellEmptyKeys } from './empty-keys.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string } type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string }
@@ -55,7 +56,10 @@ function isBareLink(nodes: readonly AdfNode[], href: string, marksInside: number
return nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === marksInside return nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === marksInside
} }
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined { // `undefined` where the spelling holds no such attrs: only a directive spells attrs=empty.
const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, []) export function spellMarkAttributes(mark: AdfMark, spelling: MarkSpelling): string | undefined {
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) const pairs = vocabularyPairs(nodeAttrs(mark), spelling.attributes, [])
const empty = spellEmptyKeys(mark)
if (pairs === undefined || (empty.length > 0 && spelling.kind !== 'directive')) return undefined
return spellAttributes([...spellVocabulary(pairs), ...empty])
} }
+48 -15
View File
@@ -1,32 +1,49 @@
import type { AdfNode } from '../adf/document.ts' import type { AdfNode } from '../adf/document.ts'
import type { DirectiveSpan, Read } from './directive-syntax.ts' import type { DirectiveSpan, Read } from './directive-syntax.ts'
import type { JsonSpelling } from '../canonical-json.ts' import type { JsonSpelling } from '../canonical-json.ts'
import type { JsonValue } from '../json-value.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
import { fencedCodeBlock } from './commonmark/backtick-runs.ts' import { fencedCodeBlock } from './commonmark/backtick-runs.ts'
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 carryName = 'carry' export const carryName = 'carry'
const jsonAttribute = 'json' const jsonAttribute = 'json'
// spec/flavour.md, The opaque carry: the info string names the type wherever it carries the type back.
export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<{ headroom: number; text: string }> { export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<{ headroom: number; text: string }> {
const json = carriedJson(node, 'two-space', path, largestNesting - depth) const { type, ...untyped } = node
const named = infoStringCarries(type)
const json = carriedJson(node, named ? untyped : node, 'two-space', path, largestNesting - depth)
if (!json.ok) return json if (!json.ok) return json
return success({ headroom: json.value.headroom, text: fencedCodeBlock(carryName, json.value.json) }) return success({ headroom: json.value.headroom, text: fencedCodeBlock(`${carryFencePrefix}${named ? type : ''}`, json.value.json) })
} }
export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> { export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
const json = carriedJson(node, 'compact', path, largestNesting) const json = carriedJson(node, node, 'compact', path, largestNesting)
if (!json.ok) return json if (!json.ok) return json
return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value.json)]]))) return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value.json)]])))
} }
export function readCarriedBlock(body: string, depth: number): Read<AdfNode> { // The type a fence's info string names, `undefined` where the info string opens no carry.
return readCarriedJson(body, 'two-space', largestNesting - depth) 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> {
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; ${fenceEscape(type)}`) }
}
const read = readCarriedJson(body, 'two-space', largestNesting - depth, type)
if (read.fault !== undefined || type !== '' || !infoStringCarries(read.value.type)) return read
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 {
@@ -36,28 +53,44 @@ export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefine
return readCarriedJson(spelled.value, 'compact', largestNesting) return readCarriedJson(spelled.value, 'compact', largestNesting)
} }
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> { function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> {
const headroom = levels - nestingDepth(node) const headroom = levels - nestingDepth(node)
if (!isJsonValue(node) || headroom < 0) { if (!isJsonValue(spelled) || headroom < 0) {
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path) return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path)
} }
return success({ headroom, json: serializeCanonicalJson(node, spelling) }) return success({ headroom, json: serializeCanonicalJson(spelled, spelling) })
} }
function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number): Read<AdfNode> { // `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> {
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') }
if (overNested(value, levels)) { const typed = type === undefined || type === '' ? { value } : typedValue(value, type)
if (typed.fault !== undefined) return typed
const held = typed.value
if (overNested(held, levels)) {
return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${levels} levels its position leaves` } } return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${levels} levels its position leaves` } }
} }
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(value)) 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 } 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> {
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; ${fenceEscape(type)}`) }
return { value: { ...value, type } }
} }
function parseJsonText(raw: string): { value: unknown } | undefined { function parseJsonText(raw: string): { value: unknown } | undefined {
+6 -2
View File
@@ -3,8 +3,9 @@ import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes } from '../directive-syntax.ts' import type { DirectiveAttributes } from '../directive-syntax.ts'
import type { MarkSpelling } from '../mark-spellings.ts' import type { MarkSpelling } from '../mark-spellings.ts'
import { directivePrefix } from '../directive-syntax.ts' import { directivePrefix } from '../directive-syntax.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { markSpelling } from '../mark-spellings.ts' import { markSpelling } from '../mark-spellings.ts'
import { readEmptyKeys } from '../empty-keys.ts'
import { readVocabulary } from './directive-attributes.ts' import { readVocabulary } from './directive-attributes.ts'
export function readDirectiveMark(name: string, attributes: DirectiveAttributes, path: ConvertErrorPath): Result<AdfMark> | undefined { export function readDirectiveMark(name: string, attributes: DirectiveAttributes, path: ConvertErrorPath): Result<AdfMark> | undefined {
@@ -12,8 +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 attrs = readVocabulary(name, attributes, spelling.attributes, undefined, path) const empty = readEmptyKeys(name, attributes, ['attrs'], false)
if (empty.fault !== undefined) return faulted(empty.fault, 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 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 })
} }

Some files were not shown because too many files have changed in this diff Show More