164 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
lilleman 1f7d11ea3e 10f - review: the position id and carry entries and the errors intro stated truly
CI / gate (push) Successful in 42s
CI / publish (push) Successful in 6s
2026-09-29 23:37:39 +02:00
lilleman 050e52296a 10f - review: README says to read markdown bound for one document once
CI / gate (push) Successful in 42s
CI / publish (push) Has been skipped
2026-09-29 23:35:24 +02:00
lilleman bd8d240712 10f - review: a node the carry restores stays deep-equal, its ids only skipped
CI / gate (push) Successful in 42s
CI / publish (push) Has been skipped
2026-09-29 21:51:22 +02:00
lilleman 3edc12aaa9 10f - review: tests for a carried node keeping its task nodes unminted 2026-09-29 21:51:22 +02:00
lilleman c81bda9bfb 10f - plainMarkdownToAdf mints task localIds as UUID v4s hashed from the markdown and position
CI / gate (push) Successful in 44s
CI / publish (push) Has been skipped
2026-09-29 21:47:09 +02:00
lilleman 160c2a7052 cjk - review: the voicing marks' script stated truly, one spelling for the alphabet
CI / gate (push) Successful in 46s
CI / publish (push) Successful in 6s
2026-09-29 18:52:03 +02:00
lilleman e2a660ee8e cjk - review: Hangul, the long-vowel mark and a character inside a delimiter bound a highlight
CI / gate (push) Successful in 44s
CI / publish (push) Has been cancelled
2026-09-29 18:49:55 +02:00
lilleman f105bc9a57 cjk - review: tests for Hangul, a long-vowel mark and a Latin word bounding a highlight 2026-09-29 18:49:37 +02:00
lilleman 6c499127b5 cjk - a letter of a script written without spaces bounds a highlight
CI / gate (push) Successful in 42s
CI / publish (push) Has been skipped
2026-09-29 18:43:29 +02:00
lilleman 151bf38398 cjk - tests for a highlight flush against a script written without spaces 2026-09-29 18:43:04 +02:00
lilleman a4b6b48635 35b - review: README names the marker escapes, a spec line reflowed
CI / gate (push) Successful in 1m39s
CI / publish (push) Successful in 6s
2026-09-29 12:23:16 +02:00
lilleman 1a266d5661 35b - review: the escape test names the renderer
CI / gate (push) Successful in 42s
CI / publish (push) Has been cancelled
2026-09-29 12:21:51 +02:00
lilleman 9bdbcd880e 35b - review: README names when a task list keeps its states as text
CI / gate (push) Successful in 42s
CI / publish (push) Has been cancelled
2026-09-29 12:20:46 +02:00
lilleman c3e86f8430 35b - review: escape a task marker opening any list item's first paragraph
CI / gate (push) Successful in 42s
CI / publish (push) Has been cancelled
2026-09-29 12:18:21 +02:00
lilleman 2395cf077e 35b - review: tests for escaping every list item's task marker 2026-09-29 12:18:16 +02:00
lilleman 95ff9ee910 35b - review: a task-list child reducing to nothing counts toward neither spelling
CI / gate (push) Successful in 44s
CI / publish (push) Has been cancelled
2026-09-29 12:13:54 +02:00
lilleman 6681bec391 35b - review: runs as a non-empty tuple, no item for a stray child reducing to nothing, 42 filed 2026-09-29 12:13:11 +02:00
lilleman 6c8ed03d36 35b - review: test that a stray task-list child reducing to nothing writes no item 2026-09-29 12:13:00 +02:00
lilleman 43869a7219 35b - review: drop every unread highlight in one pass, one task-as-text path, a linear title trim
CI / gate (push) Successful in 44s
CI / publish (push) Has been cancelled
2026-09-29 12:05:04 +02:00
lilleman 33f3bf7ee4 35b - review: tests for dropping every unread highlight, a phantom task item and a title's trailing blanks 2026-09-29 12:01:56 +02:00
lilleman 1d369ec5c5 35b - a leading marker reads two characters past it
CI / gate (push) Successful in 45s
CI / publish (push) Has been cancelled
2026-09-29 11:45:15 +02:00
lilleman 52cf681a1c 35b - 35 done 2026-09-29 11:42:50 +02:00
lilleman 06103ea2ba 35b - the writer spells the plain flavour and escapes text that would read as its markers 2026-09-29 11:42:46 +02:00
lilleman 7c15b02258 35b - tests for the writer spelling the plain flavour and escaping its markers 2026-09-29 11:32:45 +02:00
lilleman 692433b712 41 - a callout title keeps its link targets, decided
CI / gate (push) Successful in 41s
CI / publish (push) Has been cancelled
2026-09-29 10:55:01 +02:00
116 changed files with 3215 additions and 1041 deletions
+35 -22
View File
@@ -10,8 +10,14 @@ In `docs/decisions.md`:
- Plain markdown is a flavour of the grammar
- The round-trip is the product
- 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
- The carry fence names the node type
- A code block is a fence per text node
- Foreign HTML sorts three ways
- Names stay text
- Directives under `!adf:`
@@ -20,6 +26,7 @@ In `docs/decisions.md`:
- Links
- Ids stay site-local
- Plain task ids come from position
- A callout title keeps its link targets
- The plain flavour's spellings
- The HTML dialect
- No runtime dependencies
@@ -41,11 +48,13 @@ In `docs/decisions.md`:
- Firefox reads the build
- The coverage floors
- The size ratchet
- The project ships under the comprehension floor until items 59, 60, 61 and 62 land
- Properties on a fixed seed
- The CommonMark suite checks three ways
- The flavour spec is read as a source
- The node tables answer to Atlassian's schema
- Nothing recurses unbounded
- No value `JSON.parse` or `structuredClone` builds loops a call
- Nothing spreads an unbounded array
- A retry loop checks its own termination
- Readers scan by index
@@ -129,13 +138,10 @@ One-line commit messages and PR titles; short PR summaries. No AI-attribution ma
## 7. The working loop
`todo.md` lists what is left under the release that ships it, in shipping order. One item per
session — the first under the earliest release — in the smallest PR-able chunk; split a big item
into sub-items in `todo.md` before starting it. A chunk running a little over or under that is not
worth deliberating; what matters is that nothing is left undone in the end. The session stops there
whatever it was asked to finish: a release is a chain of sessions, 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.
A session works one chunk, starting from the first item in `todo.md` not waiting on an unmet "Lands
after", and stops when that chunk merges, whatever it was asked to finish: a release is a chain of
sessions, so an instruction to work until a release is done names the chain, not the session. An
open PR is a chunk already in flight, and finishing it is the session.
Per chunk:
1. Fresh worktree off updated `origin/main`; implement tests-first (§3).
@@ -144,8 +150,7 @@ Per chunk:
result exists for the commit under review, or when the diff since that result cannot affect
it (docs-only) — re-run only what its own findings or fixes invalidate.
3. Merge the PR (standing authorization, this repo only, granted by the maintainer through the
`0.2.0` release), delete the item from `todo.md` — what a consumer sees of it is
reworded for them into `CHANGELOG.md`'s `## Unreleased` — report, stop.
`0.2.0` release), report, stop.
Reserved for the maintainer whatever any rule here says: changing `version` in `package.json` (a
bump on `main` publishes, `docs/decisions.md` §Publish on a version bump — every release is the
@@ -164,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
keeps collecting instances is wrong: rewrite it.
Which output the audience expects — README goal 5 — is settled by a reader panel rather than
asked: three fresh-context readers, one per README persona the conversion serves, each given only
Which output the audience expects — README goal 6 — is settled by a reader panel rather than
asked: three fresh-context readers, one per README persona the question serves, each given only
`## Audience` and the input, writing what they expect before picking among outputs the goals
allow, rendered, shuffled, with no rationale and nothing saying what is implemented. Three agreeing
settle it; otherwise four more read, five of seven settle it, and less is a missing goal, asked.
The verdict lands in `docs/decisions.md`.
### 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
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
later. A weighing no entry decides is asked as a gap.
- A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks,
naming the number it can reach. A number the code needs and no 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.
### Stated numbers
A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks, naming
the number it can reach. A number the code needs and no entry states is a gap.
### The continuous loop
@@ -190,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
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.
## 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
- **Breaking:** directives, the opaque carry among them (now `carry`), are spelled under an `!adf:`
prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`, `!adf:name arg {attrs}`) in place
of the `:::`/`::`/`:name` forms: text holding an unescaped `!adf:` is claimed, and `adf` is an
ordinary code block language. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** directives, the inline opaque carry among them (now `!adf:carry{json="…"}`), are
spelled under an `!adf:` prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`,
`!adf:name arg {attrs}`) in place of the `:::`/`::`/`:name` forms: text holding an unescaped
`!adf:` is claimed. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** the block carry is a code fence whose info string `adf:<type>` names the node's
type, its body the node's JSON without `type`, and a code fence whose info string opens `adf:` is
claimed, and `adf` is an ordinary code block language. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` read markdown holding no block as a
document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}`
spells a document holding no `content` key.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it: two adjacent text
nodes CommonMark would read back as one are parted by `!adf:textBreak{}`, an empty `attrs`,
`content` or `marks` is spelled `{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is
spelled `-0`, and a `codeBlock` of several text nodes is a fence per node. A `codeBlock` holding
other than plain text nodes rides the block carry, where it was refused.
- **Breaking:** `unspellable-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
CommonMark escape spells is written as `!adf:link[text]{attrs}`.
- **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.
- Fix an image inside another image's description: it flattens into the alt text, where it was
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
+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,
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
recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by
the tables below. Stored ADF needs no change.
recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by the
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
@@ -22,15 +23,17 @@ import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1'
function migrateMarkdown(stored: string) {
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.
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
without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF rather than keeping
the unconverted markdown.
without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF, with
`content: parsed.value.content ?? []` as the recipe gives it, rather than keeping the unconverted
markdown.
### Spellings
@@ -40,12 +43,12 @@ function migrateMarkdown(stored: string) {
| `::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` |
| `: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 |
| `: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
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
@@ -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` |
| --- | --- | --- |
| 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
`unspellable-link` leaves `ConvertErrorCode`: a `switch` naming it stops compiling, and the link
it named converts.
`unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode`:
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` |
| --- | --- | --- |
| 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 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` |
+76 -79
View File
@@ -5,7 +5,8 @@ an HTML dialect.
**Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at
`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:
[`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).
@@ -23,48 +24,23 @@ represent.
## 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
conversions take, node types this version does not know included; one that has no spelling is
refused and says where, never silently reduced. No input makes a call loop forever or overflow
the stack. Every goal below gives way to this one.
2. **ADF is the hub.** Every format and flavour converts to and from ADF, and no two others
convert directly: markdown↔HTML composes through ADF. Adding a format or flavour costs one
reader and one writer. A flavour of a grammar shares that grammar's reader and writer and adds
only its own spellings.
3. **Plain CommonMark is input.** Markdown written for something else converts — the exceptions
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.
1. **Lossless, and every call returns a result, never a throw.**
2. **ADF is the hub.**
3. **Easy to find, and clear at a glance what it does.**
4. **Each format reads and writes as its standard says.**
5. **Our markdown is CommonMark, extended only where CommonMark has no spelling.**
6. **No surprises: output reads and edits the way its audience expects.**
7. **Lossy conversion drops form, never content.**
8. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.**
9. **Fast, and linear in the document's size as JSON.**
## Audience
Application developers embedding the library, addressed as personas rather than named consumers
(AGENTS.md §1). All four rely on the guarantees below and on `code` being a closed list; none may
rely on an error message's wording, which is free text.
Application developers embedding the library, in four personas. All four rely on the guarantees
below and on an error's `code` being a closed list; none may rely on an error message's wording,
which is free text.
- **Viewer/editor app** — shows a document, lets a human edit, posts it back. Relies on the
round-trip holding for whatever the site's editor wrote, unknown node types included, and on a
@@ -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.
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
```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
adfToMarkdown(doc: AdfDocument): Result<string>
@@ -106,17 +88,18 @@ plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
adfToHtml(doc: AdfDocument): Result<string> // 0.2.0
htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.2.0
markdownToHtml(markdown: string): Result<string> // 0.2.0, via ADF
htmlToMarkdown(html: string): Result<string> // 0.2.0, via ADF
markdownToHtml(markdown: string): Result<string> // 0.2.0
htmlToMarkdown(html: string): Result<string> // 0.2.0
```
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
## Plain markdown
Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` writes markdown other
tools render — GitHub, GitLab, Obsidian and the like — keeping the content and dropping the rest:
attributes, colours, layout, identity. It refuses only
Serves Goal 7. Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` writes
markdown other tools render — GitHub, GitLab, Obsidian and the like — keeping the content and
dropping the rest: attributes, colours, layout, identity. Content is what a reader of the rendered
document sees or follows: its text, images and link targets. It refuses only
`not-an-adf-document`, `unsupported-document-version` and `unsupported-nesting-depth`, and writes
no directive.
@@ -131,35 +114,45 @@ read replaces mentions, attachments and macros with text.
| ADF | Written | Read back |
| --- | --- | --- |
| `panel` | a GitHub alert, `> [!WARNING]`: info `NOTE`, note `IMPORTANT`, tip and success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE` | `NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error, and Obsidian's: hint tip; success, check, done success; attention warning; danger, failure, fail, missing, bug, error error; any other word info — in any case; the rest of the marker's line is the first paragraph |
| `expand`, `nestedExpand` | Obsidian's folded callout, `> [!NOTE]- Title` | `-` or `+` after any word, the rest of the marker's line the title; 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 |
| `backgroundColor` | `==text==` | `==text==` on one line, the text touching both delimiters, bounded outside by whitespace, punctuation or a line edge, 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 | — |
| `decisionList` | a bullet list | — |
| `mention`, `status`, `emoji`, `date` | their text: `@` kept, a mention with none `@` and its id, an emoji its `shortName` without, a date `2026-09-13` in UTC | — |
| `inlineCard`, `blockCard`, `embedCard` | a link to the card's URL | — |
| `inlineCard`, `blockCard`, `embedCard` | a link to the card's URL, else its name | — |
| external `media` | `![alt](url)` in a block, `[alt](url)` inline | — |
| stored `media`, `mediaInline`, `extension`, `inlineExtension` | their `alt` or `text` | — |
| `layoutSection`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`, `extensionFrame`, `caption`, a node this version does not know | its blocks or its text | — |
| `placeholder` | nothing | — |
- Content the document only references leaves an italic note naming it where it stood:
`_(image not included)_`, `_(jira-issues-table not included)_`, `_(synced block not included)_`,
`_(link card not included)_`, `_(extension not included)_`.
- Content the document only references, with no text of its own to keep, leaves an italic note
naming it where it stood: `_(image not included)_` for stored media with no `alt`,
`_(link card not included)_` for a card with neither URL nor name, an extension with no `text`
its key, `_(jira-issues-table not included)_`, or `_(extension not included)_` without one, and
`_(synced block not included)_` for a `syncBlock`.
- `backgroundColor`, `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops,
keeping its text, and so does a mark CommonMark 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
link or code span is trimmed, carriage returns and null characters are removed, and an empty
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
continue, is one bullet list keeping its numbers as text.
- A node read back carries no `localId`, which Atlassian's schema requires on `taskList`,
`taskItem` and `blockTaskItem`: mint one where the receiving site requires it.
- A task list beside a bullet or decision list, or holding a block other than a task, joins one
bullet list keeping its states as text: `- \[x] Done`.
- Text that would read as a marker takes a backslash: `==` wherever it could open or close a
highlight, `[!…]` opening a quote, and `[x]` or `[ ]` opening any list item, since GitHub reads
that marker per item.
- A node read back carries no `localId` except a `taskList`, `taskItem` or `blockTaskItem`, which
Atlassian's schema requires one on: each gets a UUID v4 hashed from the whole markdown and its
position, the same on every read. Join markdown bound for one document and read it once: the same
markdown read twice into one document repeats its ids.
## The errors
An ADF node type this version does not know is not an error: it is carried opaquely and restores
unchanged ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
Serves Goal 1. An ADF node type this version does not know is not an error: the lossless pair
carries it opaquely and restores it unchanged ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
`ConvertError` is `{ code, message, path, position? }`. `code` is the exported `ConvertErrorCode`,
stable across minors and safe to `switch` on exhaustively with no `default`; `message` is free text
@@ -183,17 +176,18 @@ Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.2.0`
| Code | Fires when | What you can do |
| --- | --- | --- |
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in a `carry` | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text |
| `malformed-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 |
| `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-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`:
| 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 |
Either direction. The emitter's own refusals are in this group — a parse reaches them by asking it
@@ -202,24 +196,27 @@ emit refuses:
| 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-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
- `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)).
- Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from
`0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
name every exception.
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
names, with three carve-outs — literal text matching directive, pipe-table or strikethrough
syntax is claimed (escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as
its own title-less paragraph; mid-text and titled images are error results, save an image inside
another's description, which flattens into the alt text. Converting back yields the library's
canonical spelling, which round-trips byte-identically — where it converts back at all: a parse
succeeding is no promise of that, so keep the source until the way back succeeds.
``` ` `` ` ``` reads cleanly and then refuses.
names, with four carve-outs — literal text matching directive, pipe-table or strikethrough syntax,
and a code fence whose info string opens `adf:`, are claimed (each can be kept literal —
`spec/flavour.md`) — and one gap: a CommonMark image fits only as its own title-less paragraph;
mid-text and titled images are error results, save an image inside another's description, which
flattens into the alt text. Converting back yields the library's canonical spelling, which
round-trips byte-identically.
- Four CommonMark spellings parse without an error and build a document the reference
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
@@ -228,28 +225,28 @@ emit refuses:
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
holds no example of the fourth.
- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
a carriage return, or a paragraph line beginning with a code span whose backticks read back as a
fence. Show the refusal and keep the document read-only; saving markdown you could not produce
is the loss the round-trip exists to stop.
- Not every document converts back: `adfToMarkdown` refuses a document nesting past 500 levels, and
the node shapes the `unsupported-node-shape` row lists. Show the refusal and keep the document
read-only; saving markdown you could not produce 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
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
input.
- 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`.
- 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
([`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
through as well, and a construct outside it is an error; well-formed HTML only — no tag-soup
recovery.
## The package
ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts` beside it.
Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs under Node,
Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
Serves Goal 8. ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts`
beside it. Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs
under Node, Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
Contract: [`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#any-es2022-engine), §Any
ES2022 engine to §Public on npm.
+5 -3
View File
@@ -1,16 +1,18 @@
try {
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) => ({
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 }) => {
const adf = JSON.parse(json)
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 }) => {
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) {
+8 -4
View File
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import { extname, join } from 'node:path'
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 driver = 'http://127.0.0.1:4444'
@@ -50,6 +49,11 @@ function fixtureNames(kind, extension) {
.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) {
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.equal(result.emitted.value, markdown)
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]
checking(name, result, () => {
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.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
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
One directory per contract kind, each landing with its milestone:
One directory per contract kind:
- `round-trip/` — `<name>.json` + `<name>.md`: the markdown `adfToMarkdown` must emit for that
document, byte for byte, and that `markdownToAdf` must read back to it (`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`
pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a
`reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node
model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap a later
milestone may close).
model) or `pending` (a parser gap).
JSON is editor-normal (`docs/decisions.md` §Equality is editor-normal), two-space indent, keys
sorted. `spec.json` is the vendored, upstream machine-readable suite, byte-exact from
[spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John
MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not
re-serialized by the corpus gate.
JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's
(`docs/decisions.md` §Equality is deep). `spec.json` is the vendored, upstream machine-readable
suite, byte-exact from [spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json)
(CommonMark 0.31.2, © John MacFarlane,
[CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not re-serialized by the
corpus gate.
-21
View File
@@ -13,27 +13,6 @@
"kind": "pending",
"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",
"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
{"type":
```adf:blockCard
{"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": {
"language": "carry"
"language": "adf:blockCard"
},
"content": [
{
"text": "{\n \"attrs\": {}\n}",
"type": "text"
}
],
"type": "codeBlock"
},
{
"attrs": {
"language": "adf:"
},
"content": [
{
@@ -1,12 +1,18 @@
!adf:codeBlock {language=carry}
```
```carry
{
"type": "blockCard"
}
```
!adf:codeBlock {language="adf:blockCard"}
```
{
"attrs": {}
}
```
!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": {
"rounded": true
@@ -13,12 +13,11 @@
],
"type": "paragraph"
}
],
"type": "panel"
]
}
```
```carry
```adf:panel
{
"attrs": {
"panelType": "extra info"
@@ -33,8 +32,7 @@
],
"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",
"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": {
> "url": "https://example.com/quoted"
> },
> "type": "blockCard"
> }
> }
> ```
- ```carry
- ```adf:blockCard
{
"attrs": {
"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": {
"url": "https://example.com/roadmap"
},
"type": "blockCard"
}
}
```
!adf:panel info
The card below has no spelling yet.
```carry
```adf:embedCard
{
"attrs": {
"layout": "wide",
"url": "https://example.com/board",
"width": 100
},
"type": "embedCard"
}
}
```
!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
{}
```
+201 -90
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
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.
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
@@ -23,40 +23,103 @@ sanitized and a mention keeps the test user's real account id.
## Markdown in is a canonical fixpoint
2026-08-23, the maintainer. Goals 1 and 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
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.
back yields the library's canonical spelling, and that spelling round-trips byte-identically.
## Equality is editor-normal
## Equality is deep
2026-08-24, the maintainer. Goal 1. Valid while markdown cannot tell apart the ADF shapes this
merges.
2026-08-24, deep 2026-10-03, the maintainer. Goal 1. Valid while a pipeline or a bot can build a
shape the editor would not.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content
array the absent key — the only domain markdown can restore.
Replaced by deep equality with `todo.md` 40 (2026-09-28, the maintainer).
"Equals" is deep equality over the document's JSON values: `assert.deepStrictEqual` on plain objects
as `JSON.parse` builds them, since ADF is JSON. Every key and value in `doc` counts, including two
adjacent text nodes, an empty `attrs`, `content` or `marks`, and `-0`. Neither side is normalized.
CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader builds
what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction reads and
writes editor-normal ADF — adjacent text nodes of identical marks and no attributes merged, `-0` as
`0`, and an empty `attrs`, `content` or `marks` the absent key, except the doc's `content`, which
ADF's schema requires — so two documents the editor holds equal write the same plain markdown.
## `!adf:textBreak{}` parts text CommonMark would join
2026-10-03, the maintainer. Goal 1. Valid while CommonMark reads adjacent text as one run.
Two adjacent text nodes CommonMark would read back as one are parted by the reserved inline leaf
`!adf:textBreak{}`, mirroring `!adf:listBreak`: a leaf building no node keeps both nodes and asks
nothing of the text around it. A code span holds no directive, so the spans close and reopen
around the leaf. The grammar: `spec/flavour.md` §Inline nodes, **Adjacent text nodes**.
## An empty key spells `empty`
2026-10-03, a writer panel and the maintainer. Goals 1 and 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
2026-08-23, extended to misplaced known nodes 2026-08-26, the maintainer. Goal 1. Valid while ADF
holds nodes, or node positions, this library does not spell.
2026-08-23, extended to misplaced known nodes 2026-08-26 and to code block children 2026-10-03, the
maintainer. Goal 1. Valid while ADF holds nodes, or node positions, this library does not spell.
An unknown ADF node is carried opaquely — raw JSON rides a dedicated syntax in both formats and
restores to a deep-equal node. The round-trip holds for documents newer than the library. So does
a known node no section spells where it stands: a markdown serializer spells a node by type without
checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`.
Where a container's own spelling cannot hold the child it has — a `bulletList` outside `listItem`,
a `codeBlock` outside text — the error result names that instead.
checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`. A
`codeBlock` holding a child no fence holds — anything but a text node carrying no marks, `attrs` or
`content` — rides the carry whole.
## The carry fence names the node type
2026-10-03, the maintainer. Goals 1 and 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
2026-08-23, the sort 2026-09-20, the maintainer. Goals 1, 3 and 6. Valid while ADF holds no node
for a bare container, a comment or a script. Lands with `todo.md` 6.
2026-08-23, the sort 2026-09-20, the maintainer. Goals 1 and 5. Valid while ADF holds no node
for a bare container, a comment or a script. Lands with `todo.md` item 6.
Every foreign element `htmlToAdf` and `markdownToAdf` read sorts one of three ways, never a silent
drop of content:
@@ -76,14 +139,14 @@ than they buy.
## 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
mention/emoji/media nodes; resolving names to ids is the consumer's job.
## Directives under `!adf:`
2026-08-23, prefixed `!adf:` 2026-09-16, the maintainer. Goals 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:`.
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
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
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
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.
One header row plus plain inline cells → pipe table; anything richer → directive form.
## Links
2026-09-13, nesting 2026-09-17, the maintainer. Goals 1 and 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.
`[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark spells the
@@ -127,24 +192,38 @@ accepted.
## Plain task ids come from position
2026-09-26, the maintainer. Goals 5 and 7. Valid while a site rejects a task node with no
`localId`. Lands with `todo.md` 10f.
2026-09-26, spelling 2026-09-29, the maintainer. Goals 7 and 8. Valid while a site rejects a task
node with no `localId`.
`plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` a `localId` from its
position in document order, unique within the document and minted with no host API, so a site
rejecting a missing `localId` takes the document and the same markdown reads to the same ids every
run.
`plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId`
in the editor's UUID v4 shape, hashed from the whole markdown and the node's order among those it
mints, skipping any id the document holds: the same markdown reads to the same ids every run,
different markdown to different ids. The same markdown pasted twice into one document repeats its
ids: determinism wins over that case. A node the carry restores stays deep-equal (§Unknown nodes ride the carry): its
ids are only skipped.
## A callout title keeps its link targets
2026-09-29, the maintainer. Goals 6 and 7. Valid while an expand's `title` is a string.
`plainMarkdownToAdf` writes a link in a folded callout's title as its text and its target in
parentheses: `> [!faq]- See [x](http://y)` reads to the title `See x (http://y)`. A link whose text
is its target, with or without `mailto:`, keeps its text alone: `<http://y>` titles `http://y`,
`<a@b.c>` `a@b.c` — three persona readers agreeing, 2026-09-30.
## The plain flavour's spellings
2026-09-14, panels 2026-09-25, the maintainer. Goal 5. Valid while GitHub's renderer is the one the
audience's markdown is read in.
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.
README §Plain markdown's rows come from a survey of GitHub, GitLab, Gitea, Obsidian, Pandoc,
MkDocs, Docusaurus, Typora, Joplin, Logseq, Bear, Notion, Azure DevOps and Discord, GitHub's
renderer confirming each shape. Reader panels settled `error` as an error panel and the `==`
bounds (3 of 3), the external image's two forms (6 of 7), the omission notes and a rule opening a
list item dropping (3 of 3), and a list's numbering overflowing into bullets (3 of 3, 5 of 7).
renderer confirming each shape. Reader panels settled `error` as an error panel, the `==` bounds
(3 of 3) and a Han, Hangul, kana, Thai, Lao, Khmer or Myanmar character on either side bounding a
delimiter, so `は==日本語==で` (3 of 3), `==한국어==에서만` and `iPhone==専用==` (6 of 7) highlight,
the external image's two forms (6 of 7), a rule opening a list item dropping and the omission
notes (3 of 3), and a list's numbering overflowing into bullets (3 of 3, 5 of 7).
An omission note reads as the converter's, never as the author's.
Reading takes other tools' spellings, since it reads their output and writes none of them.
Rejected: `~sub~` and `^sup^` (`~2~` is a strike on GitHub, so `subsup` drops), underline and colour
spellings, raw HTML (`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family,
@@ -153,7 +232,7 @@ past `[x]`/`[ ]`, and lifting bare URLs, `@name`, `:shortcode:` or ISO dates int
## 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.
The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*`
@@ -161,7 +240,7 @@ for what HTML cannot express, text always escaped. No stylesheet ships.
## No runtime dependencies
2026-08-23, the maintainer. Goal 7. Valid while ~20 lines of own code, or a vendored table, do each
2026-08-23, the maintainer. Goal 8. Valid while ~20 lines of own code, or a vendored table, do each
job a dependency would.
`dependencies` is empty. A runtime dependency enters only through an entry here stating why ~20
@@ -171,7 +250,7 @@ and HTML parsers are written in this repo.
## Standards ship as data
2026-08-30, the CommonMark suite 2026-09-05 and ADF's schemas 2026-09-13, the maintainer. Goals 1,
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
character references ship packed in their own module, so entity decoding is complete without one.
@@ -194,7 +273,7 @@ nodes that break it.
## 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
shipped source is ECMAScript and nothing else: no host import, no host global, no DOM.
@@ -208,13 +287,13 @@ never the higher one those repo-only tools want.
## ESM only
2026-08-23, the maintainer. Goal 7. Valid while the audience's toolchains all import ES modules.
2026-08-23, the maintainer. Goal 8. Valid while the audience's toolchains all import ES modules.
No CommonJS build, no dual-package hazard.
## One built entrypoint
2026-08-23, the maintainer. Goal 7. Valid while Node refuses to type-strip under `node_modules`.
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
type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so it cannot serve
@@ -222,7 +301,7 @@ an npm consumer.
## Public on npm
2026-08-23, the name 2026-09-01, the maintainer. Goals 2 and 7. Valid while the package's source
2026-08-23, the name 2026-09-01, the maintainer. Goals 2 and 8. Valid while the package's source
stays public beside it.
Published to public npm as `@larvit/adf-codec`. Public source: the Gitea repo goes public,
@@ -231,7 +310,7 @@ for the hub rather than the formats around it.
## The formats are API
2026-08-23, strict input 2026-09-01, content models 2026-09-16, the maintainer. 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.
The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing
@@ -246,7 +325,7 @@ types in `src/result.ts` hold its shape.
## 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`.
- Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose
@@ -257,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
(2026-09-20).
- A refusal no spelling recovers from is a gap in the flavour rather than a code: give the flavour
the spelling and the code goes (`unspellable-link`, 2026-09-13). A cause the carry answers gets
no code: a mark no spelling writes rides the carry with its node.
the spelling and the code goes (`unspellable-link`, 2026-09-13; `unspellable-character` and
`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
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.
- A code names the cause; where one cause recurs across node types, across one mark's attributes
or across directions, one code covers them all and `path` and `message` say which —
`unsupported-nesting-depth` is the 500-level guard whichever direction hits it,
`unspellable-character` the text node and the code block alike. Where two codes stay apart, the
line between them is what they name: `unspellable-character` is a character CommonMark rewrites
wherever text holds it, `unspellable-whitespace` the newline no inline directive's content slot
spans, in either direction.
`unsupported-nesting-depth` is the 500-level guard whichever direction hits it.
`unspellable-whitespace` is the newline an inline directive's content slot spells in input; the
emitter carries such a node. A null character in that slot is `unsupported-node-shape`, since
`unspellable-whitespace` does not read true of it (2026-10-04).
- A claim code names the spelling claimed, never the node that spelling would have built: a
malformed `!adf:table` is a `malformed-directive`, and an alignment colon a
`malformed-pipe-table` — the flavour's own delimiter row is `-` runs, so the grammar refuses the
colon rather than ADF's missing column model doing it. What the grammar itself refuses stays a
claim code, key order among it, and a leaf given a body is refused at its opener, as a container
missing its closer is (2026-09-16).
- A directive whose name reads back to no node is `unknown-directive-name` rather than a claim
code — the spelling is well formed, and telling that apart from a typo is what a consumer
switches on when a later MINOR gives the name meaning. A reserved name is a known name, so never
that code, and the two the flavour reserves part on form: a form the grammar does not have is a
claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place
is `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one
type (2026-09-01).
- A directive whose name reads back to no node is `unknown-directive-name` rather than a claim code
— the spelling is well formed, and telling that apart from a typo is what a consumer switches on
when a later MINOR gives the name meaning. A reserved name is a known name, so never that code,
and the names the flavour reserves part on form: a form the grammar does not have is a claim code
— `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place is
`unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one type,
`!adf:textBreak{}` anything but two adjacent text nodes CommonMark would read back as one,
`!adf:doc` standing beside another block (2026-09-01, the text break and `doc` 2026-10-03).
- A well-formed directive the node tables refuse — an attribute a node does not hold or spells
elsewhere, a value outside its kind or its canonical spelling, an argument, or a body of a shape
its content model does not take — is `unsupported-node-shape`, the emitter's code for the same
@@ -295,21 +375,21 @@ handles one cause alike whichever node, attribute or direction raised it.
## `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`.
- A message names the violation, not the rule alone — a rule by itself states a truth the reader
must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose
it names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and
inline alike, `\|` for every pipe row.
- `not-an-adf-document` carries the document's own path throughout: seven of the guard's eight
branches read the document's own shape, and threading a path to the eighth — a malformed node
anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces. The message
names the violation instead.
- `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 other two, which look anywhere
in the tree — a malformed node, or an object holding itself — wants the manual stack §Nothing
recurses unbounded forces. The message names the violation instead.
## Publish on a version bump
2026-08-23, converging 2026-09-03, the maintainer. Goal 7. Valid while CI on `main` holds the npm
2026-08-23, converging 2026-09-03, the maintainer. Goal 8. Valid while CI on `main` holds the npm
token.
`package.json` version on `main` is the source of truth. CI on `main`: tests green and the version
@@ -325,7 +405,7 @@ depend on a store that the gate would then have to keep.
## Docs describe the release being built
2026-09-16, the maintainer. Goal 7. Valid while a bump on `main` publishes.
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
the moment the bump publishes; add no interim note marking the gap.
@@ -341,7 +421,7 @@ nesting a spelling inside its own kind (`*(*a*)*`) names that mark once.
## The gate runs on Deno and Bun
2026-09-01, Deno's reason 2026-09-28, the maintainer. Goals 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.
The gate runs the suite under Deno and Bun as well as Node. Bun runs JavaScriptCore, the one engine
@@ -353,7 +433,7 @@ over the corpus rather than over a smoke import.
## The gate installs the tarball
2026-09-03, the maintainer. Goal 7. Valid while consumers install the packed package.
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`
and `types` are proved on the artifact that ships rather than on the source tree a self-reference
@@ -365,7 +445,7 @@ resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` s
## 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.
A headless Firefox loads `dist/index.js` over HTTP and converts the round-trip, normalization and
@@ -393,8 +473,8 @@ compared against `undefined` — have a half no valid document reaches.
## The size ratchet
2026-09-20, the maintainer. Goal 10. Valid while no measure picks out what readers find hard
better than a function's length.
2026-09-20, the maintainer. KISS, a technical principle. Valid while no measure picks out what
readers find hard better than a function's length.
`.oxlintrc.json`'s single rule, over the files `tsconfig.build.json` builds, is a per-function line
ceiling, set at that set's worst and moving only downward. It covers the built files alone, since
@@ -408,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
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
2026-09-14, the maintainer. Goal 1. Valid while a red gate must reproduce.
@@ -417,15 +517,15 @@ markdown, on a fixed seed in the gate; a counterexample found becomes a round-tr
## 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.
Each example is a named error or markdown that parses and emits to itself byte for byte; its
reference HTML's text, tags stripped and entities decoded, equals the parsed document's; and its
elements count the marks and nodes they map to. The fixpoint alone passes a parser returning the
empty document, the text alone one dropping every emphasis. An exception is the maintainer's to
add, and valid CommonMark parsing to a document `adfToMarkdown` refuses where a spelling could exist
is a bug to fix, never an exception.
add, and valid CommonMark parsing to a document `adfToMarkdown` refuses is a bug to fix, never an
exception.
## The flavour spec is read as a source
@@ -462,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
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
2026-09-18, the maintainer. Goal 1. Valid while engines cap a call's arguments.
@@ -523,7 +632,7 @@ refuses it — which is how the whitespace carry finds a line edge.
## Emphasis follows CommonMark's matching
2026-08-27, the maintainer. Goals 1 and 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.
Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text
@@ -532,7 +641,7 @@ only ones in play, and a pair that matching hands to another delimiter rides the
## 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.
A readable spelling tried ahead of a general one takes the `try` prefix and fails only where the
@@ -554,8 +663,8 @@ is the markdown flavour's choice, not ADF's.
## The source parts by ADF and format
2026-08-27, placement 2026-09-18, `adf/`'s bar 2026-09-21, the maintainer. Goal 2. Valid while
each format has a reader and a writer through ADF.
2026-08-27, placement 2026-09-18, `adf/`'s bar 2026-09-21, `plain/` 2026-10-03, the maintainer.
Goal 2. Valid while each format has a reader and a writer through ADF.
`src/adf/` holds ADF's own knowledge, imports no format, and is where a construct both formats read
lives: the question is answered in ADF's vocabulary — a node type, an attribute kind, a content
@@ -568,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
what both read where there are two.
Each format directory parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest of it
holding what both directions read. A construct's reader lives there beside the regex the emitter
escapes against, so the two cannot drift; a reader with no emit counterpart goes in `parse/`,
unless it is part of a construct that side already holds — a grammar stays in one file rather than
splitting across the seam. A rule both directions must answer alike — whether a list marker
interrupts a paragraph — is one function there too, never a copy per direction, however
conservative the copy would be. Where the rule is the emitter's own choice, input consults it
rather than restating it, and that is the only import `parse/` takes from `emit/` —
`commonMarkSpelling` and `openingLinkTakesDirective` — so no fixture the emitter writes can be
refused, and a spelling the emitter refuses gives its own error rather than a second name for it.
A flavour's own code, both directions included, sits in its own directory: `markdown/plain/`;
`todo.md` item 62 moves what still sits in `parse/` and `emit/`. Otherwise each format directory
parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest of it holding what both
directions read. A construct's reader lives there beside the regex the emitter escapes against, so
the two cannot drift; a reader with no emit counterpart goes in `parse/`, unless it is part of a
construct that side already holds — a grammar stays in one file rather than splitting across the
seam. A rule both directions must answer alike — whether a list marker interrupts a paragraph — is
one function there too, never a copy per direction, however conservative the copy would be. Where
the rule is the emitter's own choice, input consults it rather than restating it, and that is the
only value import `parse/` takes from `emit/` — `commonMarkSpelling` and `openingLinkTakesDirective`
— so no fixture the emitter writes can be refused, and a spelling the emitter refuses gives its own
error rather than a second name for it.
+95 -59
View File
@@ -1,12 +1,14 @@
# The markdown flavour
The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain
CommonMark is a subset apart from raw HTML (below), with three carve-outs: literal text that matches
directive syntax below or reads as a pipe table is claimed by the flavour, and a matched `~~` pair
spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal) — and one gap: a CommonMark
image fits only as its own title-less paragraph — mid-text and titled images are named errors. The
emitted form is contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this
grammar in the sections below.
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, a matched `~~` pair
spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal), and a code fence whose info
string opens `adf:` is the opaque carry (drop the info string and wrap the fence in
`!adf:codeBlock {language="adf:…"}` to keep it code) — and one gap: a CommonMark image fits only as
its own title-less paragraph — mid-text and titled images are named errors. The emitted form is
contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this grammar in the
sections below.
## Canonical form
@@ -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
carries an attribute, it is the inline directive.
- 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
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
@@ -67,9 +70,10 @@ normalizes to it through the round-trip.
matching below, which is what lets the emitter decide its own pairings.
- Blocks separated by one blank line at document level, inside a blockquote and between CommonMark
blocks; inside a directive container a pair holding a directive block takes none. No trailing
whitespace outside a code
block's
content, single trailing newline; a document with no blocks is the empty string.
whitespace outside a code block's content, single trailing newline. A document 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
@@ -81,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`
§The formats are API). Each name belongs to one position, and a name the other one spells — a mark
or an inline node written as a block directive, a block node written inline — is a different error,
naming the spelling it takes. Two reserved names read back to no node: `carry` for the opaque carry,
as both directive name and fence info string, and `listBreak` for the leaf that parts two adjacent
lists (Canonical form).
naming the spelling it takes. Four reserved names read back to no node: `carry` for the inline
opaque carry, `listBreak` for the leaf that parts two adjacent lists and `doc` for a document
holding no `content` key (Canonical form), and `textBreak` for the leaf that parts two text nodes
(Inline nodes). Every fence info string opening `adf:` is reserved for the block carry (The opaque
carry).
**Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name`
closes a container, and a name picks by what follows it in turn — a space or the line's end a block
@@ -144,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
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.
`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
directive syntax — every literal `!adf:`, `]` inside content, a bracket a link's destination and
@@ -165,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
product). Block and inline positions canonicalize differently, each fitting where it sits:
- **Block position**: a fenced code block with info string `carry`, body = the node's JSON —
two-space indent, object keys sorted.
- **Block position**: a fenced code block with info string `adf:` and the node's type, body = the
node's JSON without its `type` — two-space indent, object keys sorted: ```` ```adf:blockCard ````.
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),
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
stays absolute.
In block-directive position `!adf:carry` is a named error — the carry's block form is the fence.
stays absolute. In block-directive position `!adf:carry` is a named error — the carry's block form
is the fence.
## Raw HTML in input
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
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
HTML.
@@ -195,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
type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare
(`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted),
quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty; editor-normal
ADF reads an empty attrs object, marks array or content array as the absent key (`docs/decisions.md`
§Equality is editor-normal) — the grammar's empty-`{attrs}` omission already collapses the two
spellings.
quoted, `-0` spelled `-0`. `markdownToAdf` builds an `attrs`, `content` or `marks` key only where the
markdown spells one, an empty one through its reserved key (Attributes), so a document reads back
deep-equal (`docs/decisions.md` §Equality is deep). A node CommonMark spells takes the directive
form to hold an empty key.
Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a
`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
the node's `content`; any other body is a named error, and an empty pair is a node holding none.
@@ -217,20 +235,24 @@ cannot — `localId` (string) on any of them, marks, and the values below — ta
form.
- `blockquote`, `bulletList`, `listItem` — containers, block body. Attributes: `localId` (string).
- `codeBlock` — container, body one fenced code block whose info string is the language and whose
content is the node's. Attributes: `hideLineNumbers` (boolean), `language` (string), `localId`
(string), `uniqueId` (string), `wrap` (boolean). A language no info string carries back — empty,
the reserved `carry`, or holding a backtick, a backslash, a control character, edge whitespace or
an entity reference — rides the `language` attribute instead and the fence carries no info
string; writing it in the slot that rule leaves empty, or in both, is a named error. The body is
one ordinary code block, and a fence's info string decodes escapes and entity references as any
other does.
- `codeBlock` — container, body one fenced code block per text node, each fence's info string the
language and its content the node's text; a node holding no `content` key is one empty fence.
Attributes: `hideLineNumbers` (boolean), `language` (string), `localId` (string), `uniqueId`
(string), `wrap` (boolean). A language no info string carries back — empty, opening the reserved
`adf:`, or holding a backtick, a backslash, a control character, edge whitespace or an entity
reference — rides the `language` attribute instead and the fences carry no info string, and so
does a language beside `content=empty`, which has no fence; writing it in the slot that rule
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
the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
CommonMark spelling.
- `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
from 0, or one whose markers would run past 999999999, has no CommonMark spelling.
(number). `order` is the first marker, so a list carrying none, one that is `-0` or no whole
number from 0, or one whose markers would run past 999999999, has no CommonMark spelling.
- `paragraph` — container, inline body. Attributes: `localId` (string).
- `rule` — leaf. Attributes: `color` (string, `#rrggbb`), `localId` (string), `style` (`dashed`
`dotted` `fade` `sketch` `solid`), `weight` (number, 1–3).
@@ -426,10 +448,11 @@ Right.
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
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
nodes with identical marks and no attributes merged first — is a named error, and so is a `text` key
in `{attrs}`. An enclosing mark spelling does not reach into the slot. The rest take no content,
`!adf:text` included; content on a node that takes none is a named error.
parsing to anything but one text node carrying neither marks, attributes nor content is a named
error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot.
An `emoji`, `mention` or `status` whose `text` attribute holds a line ending or a null character
rides the inline carry; a content slot spelling one is a named error.
The rest take no content, `!adf:text` included; content on a node that takes none is a named error.
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
- `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text`
@@ -451,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}.
```
**Whitespace CommonMark cannot hold.** A newline inside a text node, and a space or tab where
CommonMark strips or refuses one — a block's inline content edges, either side of a line break, an
em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled `!adf:text{text="…"}`,
the reserved key carrying the node's text, escaped by the attribute grammar and never literal: pipe
cells trim and pad. The emitter wraps the whitespace run alone and leaves the rest plain text;
`markdownToAdf` merges adjacent text nodes carrying identical marks and no attributes
(`docs/decisions.md` §Equality is editor-normal). Input reads that spelling alone: the value is one
run of spaces and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark
carries plainly — is a named error.
**Text CommonMark cannot hold plainly.** A carriage return in text is `&#13;`, which CommonMark
reads back as the character. Three things are spelled `!adf:text{text="…"}`: a newline inside a text
node; a null character, which CommonMark reads as U+FFFD; and a space or tab where CommonMark strips
or refuses one — a block's inline content edges, either side of a line break, an em, strong or
strike spelling's inner edges, a pipe cell's edges. The reserved key carries the node's text,
escaped by the attribute grammar and never literal: pipe cells trim and pad. The emitter wraps the
run alone and leaves the rest plain text, which the spelled run joins on reading. Input reads that
spelling alone: the value is one run of spaces and tabs, one run of newlines, or one run of null
characters, and anything else — a mixed run, or text CommonMark carries plainly — is a named error.
```
!adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines.
```
**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
An inline node's marks ride the spelling wrapped around them, never the block sections' reserved
@@ -492,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,
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 —
`docs/decisions.md` §Equality is editor-normal restores the array, not a set — and opens each
spelling once over the longest run of adjacent inline nodes carrying an identical mark, attributes
included, at that depth. A run breaks at every node the emitter carries, so no emitted carry sits
inside a mark spelling.
`docs/decisions.md` §Equality is deep restores the array, not a set — and opens each spelling once
over the longest run of adjacent inline nodes carrying an identical mark, attributes included, at
that depth: `attrs: {}` differs from no `attrs`, and a directive spells it `{attrs=empty}`. A run
breaks at every node the emitter carries, so no emitted carry sits inside a mark spelling.
An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs
and the mark lacks, an order putting a code span outside another mark, `code` over anything but a
text node or over text holding a newline, or a spelling CommonMark's flanking rules cannot open or
close where the run sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the
intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the
merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a
mark spelling is a named error in input: the carry restores its node exactly, marks included
(`docs/decisions.md` §Unknown nodes ride the carry).
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs and
the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside
another mark, `code` over anything but a text node holding text, or over text holding a line ending
or a null character, or a spelling CommonMark's flanking rules cannot open or close where the run
sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the intra-word `*` runs
together with a neighbouring `**`, and the multiple-of-3 rule can leave the merged run's pairing to
another delimiter — rides the inline carry whole. An opaque carry inside a mark spelling is a named
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].
+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(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 { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
import { largestNesting } from '../nesting.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
export type AdfAttributes = { [key: string]: JsonValue }
@@ -17,6 +18,8 @@ export type AdfNode = {
type: string
}
export type EmptyKey = 'attrs' | 'content' | 'marks'
export type AdfDocument = {
content?: AdfNode[]
type: 'doc'
@@ -40,6 +43,7 @@ export function adfDocumentFault(value: unknown): ConvertFault | undefined {
const held: unknown = value['content']
if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(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")
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`
}
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
if (nodeMarks(node).length > 0 || node.text !== undefined) return false
export function holdsOnlyAttributes(node: AdfNode, attributes: readonly string[]): boolean {
if (nodeMarks(node).length > 0 || node.text !== undefined || emptyKeys(node).length > 0) return false
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.
export function isAdfDocument(value: unknown): value is AdfDocument {
const fault = adfDocumentFault(value)
@@ -81,6 +123,23 @@ export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] {
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[] {
const pending: unknown[] = [...value]
while (pending.length > 0) {
@@ -99,6 +158,13 @@ function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
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 {
const pending: AdfNode[] = [...nodes]
while (pending.length > 0) {
+1 -1
View File
@@ -18,7 +18,7 @@ export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling)
const { depth, value: held } = next
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 text.push(JSON.stringify(held))
else text.push(Object.is(held, -0) ? '-0' : JSON.stringify(held))
}
return text.join('')
}
+32 -6
View File
@@ -2,12 +2,13 @@ import assert from 'node:assert/strict'
import fc from 'fast-check'
import test from 'node:test'
import type { AdfNode } from '../adf/document.ts'
import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts'
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { adfToPlainMarkdown } 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 { 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 renamedPrefix = '!adg:'
@@ -19,20 +20,34 @@ function readsNoDirective(markdown: string): boolean {
return read.ok && renamed.ok && JSON.stringify(read.value).replaceAll(directivePrefix, renamedPrefix) === JSON.stringify(renamed.value)
}
test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => {
// Each block's text, an expand's title and an image's alt and url, in document order: what the plain pair keeps.
function shownText(nodes: readonly AdfNode[]): string[] {
const shown: string[] = []
for (const node of nodes) {
const attrs = node.attrs ?? {}
if ((node.type === 'expand' || node.type === 'nestedExpand') && typeof attrs['title'] === 'string') shown.push(attrs['title'])
if (node.type === 'media') shown.push(`${JSON.stringify(attrs['alt'] ?? '')} ${JSON.stringify(attrs['url'])}`)
const content = node.content ?? []
if (content.some((child) => child.type === 'text' || child.type === 'hardBreak')) shown.push(content.map((child) => child.text ?? '\n').join(''))
else for (const text of shownText(content)) shown.push(text)
}
return shown.filter((text) => text !== '')
}
test('a generated document emits markdown that reads back to it', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (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)
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),
)
})
test('a generated document writes plain markdown refusing only what the guard refuses, and that markdown reads back to itself', { timeout: propertyTimeout }, () => {
test('a generated document writes plain markdown refusing only what the guard refuses, and that markdown reads back to its text and to itself', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
const written = adfToPlainMarkdown(document)
@@ -40,8 +55,19 @@ test('a generated document writes plain markdown refusing only what the guard re
assert.ok(readsNoDirective(written.value), `a directive in ${JSON.stringify(written.value)}`)
const read = plainMarkdownToAdf(written.value)
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(written.value)}`)
const reduced = reduceToPlain(document)
assert.deepEqual(shownText(read.value.content ?? []), reduced.ok ? shownText(reduced.value.content ?? []) : reduced, `reading ${JSON.stringify(written.value)}`)
assert.deepEqual(adfToPlainMarkdown(read.value), written, `reading ${JSON.stringify(written.value)}`)
}),
propertyRuns(gateRuns),
)
})
test('a generated document writes the plain markdown its editor-normal form writes', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
assert.deepEqual(adfToPlainMarkdown(document), adfToPlainMarkdown(toEditorNormal(document)))
}),
propertyRuns(gateRuns),
)
})
+4 -4
View File
@@ -13,7 +13,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus',
const checks = ['count', 'fixpoint', 'text'] as const
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 }
@@ -30,7 +30,7 @@ function isCheck(value: unknown): value is Check {
}
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 {
@@ -77,12 +77,12 @@ test('the CommonMark spec suite is 0.31.2, vendored byte-exact', () => {
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')
for (const entry of exceptions) {
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`)
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 { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus')
const errorsRoot = join(corpusRoot, 'errors')
@@ -97,7 +96,7 @@ for (const directory of roundTripDirectories) {
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
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`)
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
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)
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
const again = markdownToAdf(emitted.value)
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}`)
const parsed = markdownToAdf(emitted.value)
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 { serializeCanonicalJson } from '../canonical-json.ts'
import { textDirectiveName } from '../markdown/text-directive.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
type Choice = { arbitrary: Arbitrary<string>; hostile?: true; weight: number }
@@ -419,7 +418,7 @@ function holdsDirectiveShape(document: AdfDocument): boolean {
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)
let directiveShaped = 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)
if (!parsed.ok) return
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
if (holdsDirectiveShape(parsed.value)) directiveShaped += 1
const read = markdownToAdf(emitted.value)
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
assert.deepEqual(toEditorNormal(read.value), toEditorNormal(parsed.value), `reading ${JSON.stringify(emitted.value)}`)
assert.deepEqual(read.value, parsed.value, `reading ${JSON.stringify(emitted.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.equal(respelled.value, emitted.value)
+55 -20
View File
@@ -2,16 +2,17 @@ import assert from 'node:assert/strict'
import { env } from 'node:process'
import fc from 'fast-check'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../adf/document.ts'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode, EmptyKey } from '../adf/document.ts'
import type { Arbitrary } from 'fast-check'
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { JsonValue } from '../json-value.ts'
import { blockArgument } from '../markdown/block-directive.ts'
import { blockNodes } from '../adf/block-nodes.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts'
import { emptyKeys, mergeAdjacentText } from '../adf/document.ts'
import { inlineNodes } from '../adf/inline-nodes.ts'
import { joinsWhenRead } from '../markdown/adjacent-text.ts'
import { markAttributes } from '../adf/mark-attributes.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
type Positions = { block: AdfNode; inline: AdfNode }
@@ -23,7 +24,7 @@ export const propertyTimeout = 600000
const depthIdentifier = fc.createDepthIdentifier()
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
const flatCommonMarkShapeWeight = 4
export const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
export const markdownPieces = fc.constantFrom(...'aZ09 \t\n\r\0!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉日ー한𠀀', '==', '[!NOTE]', '[x]', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
const nestingCommonMarkShapeWeight = 21
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 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.
const keyPiece = fc
@@ -73,18 +78,35 @@ function heldAttributes(held: Readonly<Record<string, JsonValue | undefined>>):
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 {
const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))]
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: attributes({ color: 'string' }).map((attrs) => ({ attrs, type: 'backgroundColor' })), weight: 2 },
{ arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 },
)
))
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
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
.record({ marks: fc.oneof(fc.constant<AdfMark[]>([]), fc.constant<AdfMark[]>([{ type: 'code' }]), marks), text: fc.string({ maxLength: 6, minLength: 1, unit: fc.constantFrom('`', '``', ' ', 'a') }) })
@@ -95,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' }))
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 }[] {
@@ -104,10 +126,10 @@ function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): {
const positions = fc.letrec<Positions>((tie) => {
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 = {
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,
none: fc.constant<AdfNode[]>([]),
}
@@ -115,31 +137,40 @@ const positions = fc.letrec<Positions>((tie) => {
const blockArbitraries = Object.entries(blockNodes).map(([type, model]) => {
const argument = blockArgument(type)
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 }
})
const unknownNode = fc
.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType })
.map((held): AdfNode => held)
const unknownNode = occasionallyEmpty(
fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType }),
)
const leafBlocks = blockArbitraries.filter((entry) => entry.leaf).map((entry) => entry.node)
const containerBlocks = blockArbitraries.filter((entry) => !entry.leaf).map((entry) => entry.node)
const misplacedWeight = 7
const paragraph = fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: fc.array(backtickRunNode, { maxLength: 4, minLength: 2 }), weight: 1 }).map((content): AdfNode => ({ content, type: 'paragraph' }))
const 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 listItems = fc.array(
blockContent.map((content): AdfNode => ({ content, type: 'listItem' })),
occasionallyEmpty(blockContent.map((content): AdfNode => ({ content, type: 'listItem' }))),
{ depthIdentifier, maxLength: 3, minLength: 1 },
)
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,
fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable),
]
const task = (type: string, content: Arbitrary<AdfNode[]>) =>
occasionallyEmpty(fc.record({ content, state: fc.constantFrom('DONE', 'TODO') }).map(({ content: held, state }): AdfNode => ({ attrs: { state }, content: held, type })))
const taskItem = fc.oneof({ arbitrary: task('taskItem', inlineContent), weight: 3 }, { arbitrary: task('blockTaskItem', blockContent), weight: 1 })
const nestingCommonMarkShapes = [
blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })),
occasionallyEmpty(blockContent.map((content): AdfNode => ({ content, type: 'blockquote' }))),
fc.array(fc.oneof({ arbitrary: taskItem, weight: 3 }, { arbitrary: tie('block'), weight: 1 }), { depthIdentifier, maxLength: 3, minLength: 1 }).map((content): AdfNode => ({ content, type: 'taskList' })),
listItems.map((content): AdfNode => ({ content, type: 'bulletList' })),
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' })),
]
const flatBlocks = [...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)]
@@ -162,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 } {
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 { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
export type { JsonValue } from './json-value.ts'
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { adfToPlainMarkdown } from './markdown/emit/plain-reduction.ts'
export { adfToPlainMarkdown } from './markdown/plain/adf-to-plain-markdown.ts'
export { isAdfDocument } from './adf/document.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 { JsonValue } from '../json-value.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 { spellDirectiveOpener } from './directive-syntax.ts'
import { spellAttributes, spellDirectiveOpener } from './directive-syntax.ts'
const argumentByType = new Map(
Object.entries({
@@ -14,6 +14,13 @@ const argumentByType = new Map(
} satisfies Partial<Record<BlockType, string>>),
)
export const documentName = 'doc'
// spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell.
export const documentAttribute = { key: 'content', value: 'none' }
export const documentSpelling = spellDirectiveOpener(documentName, undefined, spellAttributes([[documentAttribute.key, documentAttribute.value]]))
export const listBreakName = 'listBreak'
export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
@@ -25,17 +32,14 @@ export function blockArgument(type: string): string | 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)
if (model === undefined) return undefined
return model.contentModel === 'none' ? 'leaf' : 'container'
}
export function markValues(marks: readonly AdfMark[]): JsonValue {
return marks.map((mark) => {
const attrs = nodeAttrs(mark)
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
})
return marks.map((mark) => (mark.attrs === undefined ? { type: mark.type } : { attrs: mark.attrs, type: mark.type }))
}
export function readMarkValues(value: JsonValue): AdfMark[] | undefined {
+3 -5
View File
@@ -1,14 +1,12 @@
import type { JsonValue } from '../json-value.ts'
import { carryName } from './opaque-carry.ts'
import { holdsControlCharacter } from './commonmark/grammar.ts'
import { holdsEntityReference } from './commonmark/entity-references.ts'
import { carryFenceType } from './opaque-carry.ts'
import { infoStringCarries } from './commonmark/grammar.ts'
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.
export function languageSlot(language: JsonValue | undefined): LanguageSlot {
if (language === undefined) return { kind: 'none' }
if (typeof language !== 'string' || language === '' || language === carryName) return { kind: 'attribute' }
if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return { kind: 'attribute' }
if (typeof language !== 'string' || carryFenceType(language) !== undefined || !infoStringCarries(language)) return { kind: 'attribute' }
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'
@@ -83,10 +83,6 @@ export function backslashEscape(text: string, index: number): string | 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 {
return pipeClaim.test(line)
}
@@ -133,6 +129,11 @@ export function holdsControlCharacter(text: string): boolean {
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 {
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'
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 {
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 { ConvertFault } from '../result.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 { isJsonValue, overNested } from '../json-value.ts'
import { largestNesting } from '../nesting.ts'
@@ -100,10 +100,10 @@ export function readSoleStringAttribute(span: DirectiveSpan, key: string): Read<
return { value: spelled.decoded }
}
// Both directions answer alike: an inline directive never spans lines, so no content slot holds a line ending.
export function slotLineEndingFault(type: string, text: string): ConvertFault | undefined {
if (!/[\n\r]/.test(text)) return undefined
return { code: 'unspellable-whitespace', message: `the ${type} content slot holds a newline no inline directive spans` }
// An inline directive never spans lines, and no content slot holds a null character; the emitter carries such a node.
export function slotFault(type: string, text: string): ConvertFault | undefined {
if (/[\n\r]/.test(text)) 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 {
@@ -139,7 +139,7 @@ export function spellStringAttribute(text: string): string {
export function spellAttributeValue(value: VocabularyValue): string {
if (value.kind === 'boolean') return `${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)
}
+74 -67
View File
@@ -6,14 +6,13 @@ import type { JsonValue } from '../../json-value.ts'
import type { Result } from '../../result.ts'
import { adfToMarkdown, markdownToAdf } from '../../index.ts'
import { largestNesting } from '../../nesting.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
function paragraph(...content: AdfNode[]): AdfNode {
return { content, type: 'paragraph' }
return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' }
}
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 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' }, { 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.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', () => {
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '```', type: 'text' })))), 'unspellable-line-start')
function readsBack(doc: AdfDocument): string {
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', () => {
@@ -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' }
assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n !adf:listBreak\n\n - x\n')
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(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', () => {
assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```carry\n{\n "type": "blockCard"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```carry\n{\n "type": "toString"\n}\n```\n')
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' }))), '```adf:blockCard\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({ text: 'x', type: 'text' }))), '```carry\n{\n "text": "x",\n "type": "text"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```carry\n{\n "type": "hardBreak"\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' }))), '```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', () => {
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '!adf:codeBlock {language=carry}\n```\n```\n!adf:/codeBlock\n')
test('spells the code block whose language opens with the reserved info string prefix', () => {
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')
})
@@ -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')
})
test('refuses a node whose content model the canonical form cannot emit', () => {
assert.equal(
markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))),
'unsupported-node-shape: a codeBlock holds plain text nodes only: this paragraph node is not one',
)
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('carries a code block holding a node no fence holds', () => {
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), '```adf:codeBlock\n{\n "content": [\n {\n "type": "paragraph"\n }\n ]\n}\n```\n')
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' }]) {
assert.ok(markdown(adfToMarkdown(document({ content: [child], type: 'codeBlock' }))).startsWith('```adf:codeBlock\n'), JSON.stringify(child))
}
})
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({ 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', () => {
@@ -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', () => {
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 outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' })
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 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.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', () => {
@@ -372,7 +378,7 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
const read = markdownToAdf(spelled.value)
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'))
@@ -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', () => {
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')
const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
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', () => {
assert.equal(
markdown(adfToMarkdown(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' }))),
'unspellable-character: a codeBlock holds no carriage return CommonMark keeps: this text holds one',
)
assert.equal(
markdown(adfToMarkdown(document(paragraph({ text: 'a\rb', type: 'text' })))),
'unspellable-character: a text node holds a carriage return CommonMark rewrites',
)
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('spells the characters CommonMark rewrites', () => {
assert.equal(readsBack(document(paragraph({ text: 'a\r\r\nb', type: 'text' }))), 'a&#13;&#13;!adf:text{text="\\n"}b\n')
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')
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(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')
readsBack(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))
readsBack(document(paragraph({ marks: [{ type: 'code' }], text: 'a\u0000b', type: 'text' })))
})
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 empty = 'unsupported-node-shape: a text node holds text: this one has none'
const holding = 'unsupported-node-shape: a text node holds no content: this one holds some'
assert.equal(markdown(adfToMarkdown(document(paragraph({ text: '', type: 'text' })))), empty)
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))), empty)
assert.equal(markdown(adfToMarkdown(document(paragraph({ content: nested, text: 'x', type: 'text' })))), holding)
assert.equal(markdown(adfToMarkdown(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))), holding)
assert.equal(readsBack(document(paragraph({ text: '', type: 'text' }))), '!adf:carry{json="{\\"text\\":\\"\\",\\"type\\":\\"text\\"}"}\n')
assert.equal(readsBack(document(paragraph({ type: 'text' }, { text: 'a', type: 'text' }))), '!adf:carry{json="{\\"type\\":\\"text\\"}"}a\n')
readsBack(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))
readsBack(document(paragraph({ marks: [{ type: 'em' }], text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: '', type: 'text' })))
readsBack(document(paragraph({ content: nested, text: 'x', type: 'text' })))
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', () => {
@@ -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')
})
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' }
assert.equal(
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', () => {
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({}))), '!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({ type: 'caption' }))), '!adf:caption\n!adf:/caption\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', () => {
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: { toString: 'x' }, type: 'panel' }), '```carry\n{\n "attrs": {\n "toString": "x"\n },\n "type": "panel"\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: { rounded: true }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "rounded": true\n }\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' }), '```adf:panel\n{\n "attrs": {\n "localId": 4\n }\n}\n```\n')
assert.equal(
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(
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(
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)))
assert.equal(
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', () => {
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({ 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', () => {
@@ -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', () => {
const url = 'https://example.com/moon.png'
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({ type: 'external', url }))), `![](${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({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), neither('hardBreak', 'content'))
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', () => {
@@ -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 underline: AdfMark = { type: 'underline' }
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: { 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')
@@ -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", () => {
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`)
})
+148 -79
View File
@@ -1,14 +1,16 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { blockDirectiveForm, listBreakSpelling } from '../block-directive.ts'
import type { Flavour } from '../plain/conventions.ts'
import { adfDocumentFault, holdsOnlyAttributes, isUnmarkedBareText, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain/conventions.ts'
import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts'
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
import { carriedBlock } from '../opaque-carry.ts'
import { emitInlineLine } from './inline-line.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { fencedCodeBlock } from '../commonmark/backtick-runs.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 { spellBlockDirectiveOpener } from './block-directive-spelling.ts'
import { spellDirectiveCloser, spellDirectiveOpener } from '../directive-syntax.ts'
@@ -20,32 +22,38 @@ type BlockSpelling = 'commonmark' | 'directive' | 'list'
type EmittedBlock = { headroom: number; spelling: BlockSpelling; text: string }
type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
type PlacedBlock = Omit<EmittedBlock, 'headroom'> & { node: AdfNode }
type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
// Keyed by reference: only a caller building one object per position (the parse, the plain reduction) passes one; a consumer's document may share a node.
export type SpellingMemo = Map<AdfNode, KeptSpelling>
type Walk = { blocks: readonly PlacedBlock[]; headroom: number }
type WalkedItem = { node: AdfNode; walk: Walk }
type WalkedItem = { node: AdfNode; walk: PlacedBlocks }
export type Writing = { flavour: Flavour; memo: SpellingMemo | undefined }
export const largestListMarker = 999999999
// Bare because tryList admits no item carrying attributes, marks or text.
const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
export function adfToMarkdown(document: AdfDocument): Result<string> {
return writeMarkdown(document, 'lossless')
}
export function writeMarkdown(document: AdfDocument, flavour: Flavour): Result<string> {
const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const walk = walkBlocks(nodeContent(document), [], 0, undefined)
if (document.content === undefined) return success(`${documentSpelling}\n`)
const walk = walkBlocks(document.content, [], 0, { flavour, memo: undefined })
if (!walk.ok) return walk
const text = joinBlocks(walk.value.blocks, 'document')
return success(text === '' ? '' : `${text}\n`)
}
// headroom: the least slack any depth guard below the walk has.
function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<Walk> {
function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
let headroom = largestNesting - depth
if (headroom < 0) return tooDeep(path)
const blocks: PlacedBlock[] = []
for (const [index, node] of nodes.entries()) {
const block = emitBlock(node, [...path, 'content', index], depth, memo)
const block = emitBlock(node, [...path, 'content', index], depth, writing)
if (!block.ok) return block
headroom = Math.min(headroom, block.value.headroom)
blocks.push({ ...block.value, node })
@@ -68,64 +76,133 @@ function joinBlocks(blocks: readonly PlacedBlock[], container: BlockContainer):
}
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string {
const plainPair = previous.spelling !== 'directive' && next.spelling !== 'directive'
if (plainPair && next.spelling === 'list') {
const bothCommonMark = previous.spelling !== 'directive' && next.spelling !== 'directive'
if (bothCommonMark && next.spelling === 'list') {
if (previous.spelling === 'list' && previous.node.type === next.node.type) {
const gap = container === 'directive' ? '\n' : '\n\n'
return `${gap}${listBreakSpelling}${gap}`
}
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 {
const items = nodeContent(node)
const empty = items[0] === undefined || nodeContent(items[0]).length === 0
const empty = node.type !== 'taskList' && (items[0] === undefined || nodeContent(items[0]).length === 0)
if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty)
return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty)
}
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> {
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const model = blockNodeModel(node.type)
if (model === undefined) return commonMarkLine(carriedBlock(node, path, depth))
const readable = readableBlock(node, path, depth, memo)
const readable = readableBlock(node, path, depth, writing)
if (readable !== undefined) return readable
return emitDirectiveBlock(node, model, path, depth, () => walkBlocks(nodeContent(node), path, depth + 1, memo))
return emitDirectiveBlock(node, model, path, depth, () => walkBlocks(nodeContent(node), path, depth + 1, writing))
}
export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo): Result<null> | undefined {
const readable = readableBlock(node, path, depth, memo)
export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<null> | undefined {
const readable = readableBlock(node, path, depth, writing)
if (readable === undefined) return undefined
if (!readable.ok) return readable
return readable.value.spelling === 'directive' ? undefined : success(null)
}
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined {
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
const { memo } = writing
const kept = memo?.get(node)
if (kept !== undefined) {
if (kept.block === undefined) return undefined
// A read below the fill would skip the depth guards the walk it replaces runs (docs/decisions.md §The spelling memo).
if (depth <= kept.depth) return success({ ...kept.block, headroom: kept.block.headroom + kept.depth - depth })
}
const spelled = spellReadableBlock(node, path, depth, memo)
const spelled = spellReadableBlock(node, path, depth, writing)
if (spelled === undefined) memo?.set(node, { block: undefined, depth })
else if (spelled.ok) memo?.set(node, { block: spelled.value, depth })
return spelled
}
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined {
if (node.type === 'blockquote') return tryBlockquote(node, path, depth, memo)
if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, memo)
if (node.type === 'codeBlock') return tryCodeBlock(node, path)
if (node.type === 'heading') return tryHeading(node, path)
if (node.type === 'mediaSingle') return readableText(tryImage(node, path))
if (node.type === 'paragraph') return tryParagraph(node, path)
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
const plain = writing.flavour === 'plain' ? spellPlainBlock(node, path, depth, writing) : undefined
if (plain !== undefined) return plain
if (node.type === 'blockquote') return tryBlockquote(node, path, depth, writing)
if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, writing)
if (node.type === 'codeBlock') return readableText(tryCodeBlock(node))
if (node.type === 'heading') return tryHeading(node, path, writing.flavour)
if (node.type === 'mediaSingle') return readableText(tryImage(node))
if (node.type === 'paragraph') return tryParagraph(node, path, writing.flavour)
if (node.type === 'rule') return readableText(tryRule(node))
if (node.type === 'table') return readableText(tryPipeTable(node, path))
if (node.type === 'table') return readableText(tryPipeTable(node, path, writing.flavour))
return undefined
}
// The plain flavour's nodes, in the shapes the plain reduction leaves them.
function spellPlainBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
if (node.type === 'panel') return quotedUnder(alertMarker(nodeAttrs(node)['panelType']), node, path, depth, writing)
if (node.type === 'taskList') return tryTaskList(node, path, depth, writing)
if (node.type !== 'expand' && node.type !== 'nestedExpand') return undefined
const title = nodeAttrs(node)['title']
if (typeof title !== 'string') return quotedUnder(foldedAlertMarker, node, path, depth, writing)
// The reader takes a title as lossless inline text.
const line = emitInlineLine([{ text: title, type: 'text' }], 'paragraph', path, 'lossless')
return line.ok ? quotedUnder(`${foldedAlertMarker} ${line.value}`, node, path, depth, writing) : line
}
function quotedUnder(head: string, node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
if (!inner.ok) return inner
const body = joinBlocks(inner.value.blocks, 'document')
return success(commonMarkText(quoted(body === '' ? head : `${head}\n\n${body}`), inner.value.headroom))
}
function quoted(text: string): string {
return text
.split('\n')
.map((line) => (line === '' ? '>' : `> ${line}`))
.join('\n')
}
function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const items: PlacedBlock[][] = []
let headroom = largestNesting - depth - 1
// A child other than a task nests in the task before it.
for (const [index, child] of nodeContent(node).entries()) {
const task = child.type === 'taskItem' || child.type === 'blockTaskItem'
const walk = task ? taskBlocks(child, [...path, 'content', index], depth + 1, writing) : placedBlock(child, [...path, 'content', index], depth + 1, writing)
if (!walk.ok) return walk
headroom = Math.min(headroom, walk.value.headroom)
const previous = items.at(-1)
if (task || previous === undefined) items.push([...walk.value.blocks])
else for (const block of walk.value.blocks) previous.push(block)
}
const lines = items.map((blocks) => tryListItemLines(joinBlocks(blocks, 'list-item'), '- '))
// The plain reduction leaves no task a list item cannot hold: a directive here would break the flavour.
return lines.includes(undefined) ? failure('unsupported-node-shape', 'a task holds blocks no list item spells', path) : success({ headroom, spelling: 'list', text: lines.join('\n') })
}
function placedBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
const block = emitBlock(node, path, depth, writing)
return block.ok ? success({ blocks: [{ ...block.value, node }], headroom: block.value.headroom }) : block
}
// The marker leads the first paragraph, or stands as one where the blocks open with another.
function taskBlocks(task: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
const marker = taskMarker(nodeAttrs(task)['state'])
const markerBlock: PlacedBlock = { node: { type: 'paragraph' }, spelling: 'commonmark', text: marker }
if (task.type === 'taskItem') {
const content = nodeContent(task)
const line = content.length === 0 ? success('') : emitInlineLine(content, 'paragraph', path, writing.flavour)
if (!line.ok) return line
return success({ blocks: [{ ...markerBlock, text: line.value === '' ? marker : `${marker} ${line.value}` }], headroom: Number.POSITIVE_INFINITY })
}
const walk = walkBlocks(nodeContent(task), path, depth, writing)
if (!walk.ok) return walk
const [first, ...rest] = walk.value.blocks
const blocks = first?.node.type === 'paragraph' ? [{ ...first, text: `${marker} ${first.text}` }, ...rest] : [markerBlock, ...walk.value.blocks]
return success({ blocks, headroom: walk.value.headroom })
}
function readableText(text: string | undefined): Result<EmittedBlock> | undefined {
return text === undefined ? undefined : success(commonMarkText(text))
}
@@ -143,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)}` }
}
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 (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)
@@ -153,10 +230,10 @@ function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertE
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 (model.contentModel === 'inline') {
const line = emitInlineLine(nodeContent(node), 'paragraph', path)
const line = emitInlineLine(nodeContent(node), 'paragraph', path, 'lossless')
if (!line.ok) return line
return success(directivePair(node, opener, line.value))
}
@@ -165,87 +242,80 @@ function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string,
return success(directivePair(node, opener, joinBlocks(walk.value.blocks, 'directive'), walk.value.headroom))
}
function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, [])) return undefined
const inner = walkBlocks(nodeContent(node), path, depth + 1, memo)
function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
if (!holdsOnlyAttributes(node, [])) return undefined
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
if (!inner.ok) return inner
const text = joinBlocks(inner.value.blocks, 'document')
.split('\n')
.map((line) => (line === '' ? '>' : `> ${line}`))
.join('\n')
return success(commonMarkText(text, inner.value.headroom))
const alert = writing.flavour === 'plain' && leadingMarker(text, readAlertMarker) !== undefined
return success(commonMarkText(quoted(alert ? `\\${text}` : text), inner.value.headroom))
}
function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['language'])) return undefined
function tryCodeBlock(node: AdfNode): string | undefined {
if (!holdsOnlyAttributes(node, ['language'])) return undefined
const slot = languageSlot(nodeAttrs(node)['language'])
if (slot.kind === 'attribute') return undefined
const text = codeBlockText(node, path)
if (!text.ok) return text
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
const [only, ...others] = fencedTexts(node) ?? []
if (only === undefined || others.length > 0) return undefined
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> {
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'])
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (!opener.ok) return opener
const text = codeBlockText(node, path)
if (!text.ok) return text
return success(directivePair(node, opener.value, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
const info = slot.kind === 'fence' ? slot.info : ''
return success(directivePair(node, opener.value, texts.map((text) => fencedCodeBlock(info, text)).join('\n')))
}
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
let text = ''
for (const [index, child] of nodeContent(node).entries()) {
const childPath = [...path, 'content', index]
if (
child.type !== 'text' ||
typeof child.text !== 'string' ||
child.text === '' ||
nodeContent(child).length > 0 ||
nodeMarks(child).length > 0 ||
Object.keys(nodeAttrs(child)).length > 0
) {
return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath)
}
if (/\r/.test(child.text)) return failure('unspellable-character', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath)
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
text += child.text
// The text of each fence, or `undefined` where a child holds what no fence keeps, so the block carry takes the node.
function fencedTexts(node: AdfNode): string[] | undefined {
if (node.content === undefined) return ['']
const texts: string[] = []
for (const child of node.content) {
if (!isUnmarkedBareText(child) || typeof child.text !== 'string' || child.text === '' || /\r/.test(child.text) || holdsNullCharacter(child.text)) return undefined
texts.push(child.text)
}
return success(text)
return texts
}
function tryHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['level'])) return undefined
function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
if (!holdsOnlyAttributes(node, ['level'])) return undefined
const level = nodeAttrs(node)['level']
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
const hashes = '#'.repeat(level)
const content = nodeContent(node)
if (content.length === 0) return success(commonMarkText(hashes))
const line = emitInlineLine(content, 'heading', path)
const line = emitInlineLine(content, 'heading', path, flavour)
if (!line.ok) return line
return success(commonMarkText(`${hashes} ${line.value}`))
}
function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined {
function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
const ordered = node.type === 'orderedList'
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
if (!holdsOnlyAttributes(node, ordered ? ['order'] : [])) return undefined
const items = nodeContent(node)
const start = listStart(node, items.length)
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[] = []
let headroom = Number.POSITIVE_INFINITY
for (const [offset, item] of items.entries()) {
const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1, memo)
const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1, writing)
if (!walk.ok) return walk
headroom = Math.min(headroom, walk.value.headroom)
walked.push({ node: item, walk: walk.value })
}
const lines: string[] = []
for (const [offset, item] of walked.entries()) {
const line = tryListItemLines(item.walk.blocks, ordered ? `${start + offset}. ` : '- ')
const inner = joinBlocks(item.walk.blocks, 'list-item')
// GitHub reads a task marker opening any item's first paragraph as a checkbox, whatever its siblings hold.
const escaped = writing.flavour === 'plain' && leadingMarker(inner, readTaskMarker) !== undefined ? `\\${inner}` : inner
const line = tryListItemLines(escaped, ordered ? `${start + offset}. ` : '- ')
if (line === undefined) {
// The directive form spends a level the walk did not count.
if (headroom < 1) return tooDeep(path)
@@ -263,12 +333,11 @@ function directiveItems(items: readonly WalkedItem[]): PlacedBlock[] {
function listStart(node: AdfNode, items: number): number | undefined {
if (node.type !== 'orderedList') return 0
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
}
function tryListItemLines(blocks: readonly PlacedBlock[], marker: string): string | undefined {
const inner = joinBlocks(blocks, 'list-item')
function tryListItemLines(inner: string, marker: string): string | undefined {
if (inner === '') return marker.trimEnd()
const body = inner.split('\n')
if (body.some((line) => line !== '' && isBlankLine(line))) return undefined
@@ -278,15 +347,15 @@ function tryListItemLines(blocks: readonly PlacedBlock[], marker: string): strin
return lines.join('\n')
}
function tryParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
function tryParagraph(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
const content = nodeContent(node)
if (content.length === 0 || !carriesOnly(node, [])) return undefined
const line = emitInlineLine(content, 'paragraph', path)
if (content.length === 0 || !holdsOnlyAttributes(node, [])) return undefined
const line = emitInlineLine(content, 'paragraph', path, flavour)
if (!line.ok) return line
return success(commonMarkText(line.value))
}
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 '---'
}
@@ -5,6 +5,7 @@ import { blockArgument, markValues, marksAttribute } from '../block-directive.ts
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { overNested } from '../../json-value.ts'
import { spellEmptyKeys } from '../empty-keys.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
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 pairs = vocabularyPairs(nodeAttrs(node), model.attributes, spelled)
if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs)
const spelledPairs = [...spellVocabulary(pairs), ...spellEmptyKeys(node)]
const marks = nodeMarks(node)
if (marks.length > 0) {
const values = markValues(marks)
+5 -6
View File
@@ -1,23 +1,22 @@
import type { AdfNode } from '../../adf/document.ts'
import type { ConvertErrorPath } from '../../result.ts'
import { carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { holdsOnlyAttributes, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts'
import { tryImageLine } from './inline-line.ts'
const centeredMediaSingle = '{"layout":"center"}'
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)
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 {
const content = nodeContent(node)
const media = content[0]
if (!carriesOnly(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 (!holdsOnlyAttributes(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) 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 alt = attrs['alt']
const url = attrs['url']
@@ -2,9 +2,10 @@ import type { AdfNode } from '../../adf/document.ts'
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
import { nodeAttrs } from '../../adf/document.ts'
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
import { spellEmptyKeys } from '../empty-keys.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellInlineNodeAttributes(node: AdfNode, model: InlineNodeModel): string | undefined {
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, model.textAttribute === undefined ? [] : [model.textAttribute])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
return pairs === undefined ? undefined : spellAttributes([...spellVocabulary(pairs), ...spellEmptyKeys(node)])
}
+86 -76
View File
@@ -1,18 +1,20 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { Flavour } from '../plain/conventions.ts'
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
import type { LineContainer } from '../line-container.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts'
import { carriedInline } from '../opaque-carry.ts'
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { identicalMark, isBareText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.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 { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts'
import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { sameMark } from '../../adf/editor-normal.ts'
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
import { slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
import { spellTextDirective } from '../text-directive.ts'
@@ -24,6 +26,7 @@ type InlineContext = {
atBlockEnd: boolean
bracketed: boolean
carried: ReadonlySet<number>
flavour: Flavour
openingLinkAsDirective: boolean
path: ConvertErrorPath
spansLines: boolean
@@ -33,48 +36,47 @@ type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[
type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } | { fallback?: undefined; line: string }
type LineFallbacks = { carried: Set<number>; openingLinkAsDirective: boolean }
type LineFallbacks = { carried: Set<number>; flavour: Flavour; openingLinkAsDirective: boolean }
export type PlainLineFallback = { kind: 'claimed-line'; line: number; text: string } | { kind: 'opening-link' } | { kind: 'unspellable-run'; run: MarkRun }
export type PlainLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
const emitted = emitLine(nodes, container, path)
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<string> {
const emitted = emitLine(nodes, container, path, flavour)
if (!emitted.ok) return emitted
return success(emitted.value.line)
}
export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<boolean> {
const emitted = emitLine(nodes, 'paragraph', path)
const emitted = emitLine(nodes, 'paragraph', path, 'lossless')
if (!emitted.ok) return emitted
return success(emitted.value.openingLinkAsDirective)
}
export function plainLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<PlainLineFallback | undefined> {
const emission = lineSegments(nodes, container, path, { carried: new Set(), openingLinkAsDirective: false })
const emission = lineSegments(nodes, container, path, { carried: new Set(), flavour: 'plain', openingLinkAsDirective: false })
if (!emission.ok) return emission
if (emission.value.carry !== undefined) return failure('unsupported-node-shape', 'an inline node on a plain line has no spelling but the carry', path)
const verdict = lineVerdict(emission.value.segments, container)
const verdict = lineVerdict(emission.value.segments, container, 'plain')
return success(verdict.kind === 'line' ? undefined : verdict)
}
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined {
const emitted = emitLine(nodes, 'table-cell', path)
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, flavour: Flavour): string | undefined {
const emitted = emitLine(nodes, 'table-cell', path, flavour)
if (!emitted.ok) return undefined
if (emitted.value.segments.some((segment) => isSyntax(segment.escaping) && segment.text.includes('|'))) return undefined
return emitted.value.line
}
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
const destination = spellDestination(href)
if (destination === undefined) return undefined
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
const attempt = attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', path)
return attempt.ok ? attempt.value.line : undefined
return attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', 'lossless').line
}
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<EmittedLine> {
const fallbacks: LineFallbacks = { carried: new Set(), openingLinkAsDirective: false }
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<EmittedLine> {
const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false }
// Terminates because takeFallback refuses a pass that took no new fallback.
for (;;) {
const emission = lineSegments(nodes, container, path, fallbacks)
@@ -84,12 +86,11 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con
if (!taken.ok) return taken
continue
}
const attempt = attemptLine(emission.value.segments, container, path)
if (!attempt.ok) return attempt
if (attempt.value.line !== undefined) {
return success({ line: attempt.value.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
const attempt = attemptLine(emission.value.segments, container, flavour)
if (attempt.line !== undefined) {
return success({ line: attempt.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
}
}
@@ -111,54 +112,51 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
const emission = emitRun(nodes, 0, 0, context)
if (!emission.ok) return emission
if (emission.value.carry !== undefined) return emission
return success({ segments: carryStrippedWhitespace(emission.value.segments) })
return success({ segments: spellEdgeWhitespace(emission.value.segments) })
}
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> {
const verdict = lineVerdict(segments, container)
if (verdict.kind === 'opening-link') return success({ fallback: 'opening-link' })
if (verdict.kind === 'unspellable-run') return success({ fallback: verdict.run })
if (verdict.kind === 'claimed-line') return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(verdict.text)}`, path)
return success({ line: verdict.text })
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): LineAttempt {
const verdict = lineVerdict(segments, container, flavour)
if (verdict.kind === 'opening-link') return { fallback: 'opening-link' }
if (verdict.kind === 'unspellable-run') return { fallback: verdict.runs[0] }
return { line: verdict.text }
}
// The fallbacks in the order a line takes them, or the line where it takes none.
function lineVerdict(segments: readonly InlineSegment[], container: LineContainer): PlainLineFallback | { kind: 'line'; text: string } {
const assembled = assembleInlineLine(segments, container)
function lineVerdict(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): PlainLineFallback | { kind: 'line'; text: string } {
const assembled = assembleInlineLine(segments, container, flavour)
if (assembled.openingLinkAsDirective) return { kind: 'opening-link' }
if (assembled.unspellableRun !== undefined) return { kind: 'unspellable-run', run: assembled.unspellableRun }
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] ?? '' }
const [run, ...others] = assembled.unspellableRuns
return run === undefined ? { kind: 'line', text: assembled.line } : { kind: 'unspellable-run', runs: [run, ...others] }
}
// spec/flavour.md, Inline nodes.
function carryStrippedWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
const carried: InlineSegment[] = []
function spellEdgeWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
const spelled: InlineSegment[] = []
for (const [index, segment] of segments.entries()) {
const previous = segments[index - 1]
const next = segments[index + 1]
const leading = previous === undefined || previous.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]
const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : ''
const body = segment.text.slice(head.length)
const middle = trailing ? trimTrailingSpace(body) : body
const tail = body.slice(middle.length)
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 (tail !== '') edges.push(carriedText(tail))
if (tail !== '') edges.push(textDirectiveSegment(tail))
return edges
}
function carriedText(text: string): InlineSegment {
function textDirectiveSegment(text: string): InlineSegment {
return syntax(spellTextDirective(text))
}
@@ -182,6 +180,7 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c
const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
const segments: InlineSegment[] = []
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 emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
if (!emitted.ok) return emitted
@@ -202,19 +201,30 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
continue
}
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] })
}
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 {
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 {
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> {
@@ -227,7 +237,7 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
const types = nodeMarks(node).map((mark) => mark.type)
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
const 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)
return emitInlineDirective(node, model, index, path)
}
@@ -248,33 +258,27 @@ function emitInlineDirective(node: AdfNode, model: InlineNodeModel, index: numbe
if (attributes === undefined) return success({ carry: { first: index, last: index } })
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
if (slot === undefined) return success({ segments: [syntax(spellInlineLeafDirective(node.type, attributes))] })
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
const spans = slotLineEndingFault(node.type, slot)
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)
if (typeof slot !== 'string' || slotFault(node.type, slot) !== undefined) return success({ carry: { first: index, last: index } })
const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }]
return success({ segments: [syntax(spellInlineDirectiveOpener(node.type)), ...content, syntax(`]${attributes}`)] })
}
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
if (Object.keys(nodeAttrs(node)).length > 0) return success({ carry: { first: index, last: index } })
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
// spec/flavour.md, Text CommonMark cannot hold plainly.
function emitText(node: AdfNode, context: InlineContext, index: number): Emission {
if (!isBareText(node) || typeof node.text !== 'string' || node.text === '') return { carry: { first: index, last: index } }
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
const parts = node.text.split(/(\n+)/).filter((part) => part !== '')
return success({ segments: parts.map((part) => (part.startsWith('\n') ? carriedText(part) : { escaping, text: part })) })
const parts = node.text.split(/(\n+|\0+|\r)/).filter((part) => 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> {
const path = nodePath(context, index)
const range: NodeRange = { first: index, last: index + nodes.length - 1 }
if (mark.type === 'backgroundColor' && context.flavour === 'plain') return emitHighlight(nodes, depth, range, context)
const spelling = markSpelling(mark.type)
if (spelling === undefined) return success({ carry: range })
const attributes = spellMarkAttributes(mark, spelling.attributes)
const attributes = spellMarkAttributes(mark, spelling)
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)
const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined
if (link !== undefined) return link
@@ -288,29 +292,35 @@ function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number
const inner = emitRun(nodes, depth + 1, range.first, context)
if (!inner.ok) return inner
if (inner.value.carry !== undefined) return inner
const carried = carryStrippedWhitespace(inner.value.segments)
const spelled = spellEdgeWhitespace(inner.value.segments)
return success({
segments: [
{ emphasis: 'open', escaping: 'none', nodes: { ...range, depth }, text: spelling },
...carried,
...spelled,
{ emphasis: 'close', escaping: 'none', nodes: { ...range, depth }, text: spelling },
],
})
}
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
let text = ''
function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRange, context: InlineContext): Result<Emission> {
const inner = emitRun(nodes, depth + 1, range.first, context)
if (!inner.ok || inner.value.carry !== undefined) return inner
const run = { ...range, depth }
return success({
segments: [{ escaping: 'none', highlight: 'open', nodes: run, text: highlightDelimiter }, ...inner.value.segments, { escaping: 'none', highlight: 'close', nodes: run, text: highlightDelimiter }],
})
}
// Each node its own span: CommonMark reads two adjacent text nodes in one span back as one.
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange): Emission {
const spans: string[] = []
for (const node of nodes) {
if (node.type !== 'text' || nodeMarks(node).length !== depth + 1) return success({ carry: range })
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
text += node.text
const { text } = node
if (!isBareText(node) || nodeMarks(node).length !== depth + 1 || typeof text !== 'string' || text === '' || /[\n\r]/.test(text) || holdsNullCharacter(text)) return { carry: range }
const fence = '`'.repeat(longestBacktickRun(text) + 1)
spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`)
}
if (/[\n\r]/.test(text)) return success({ carry: range })
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
const fence = '`'.repeat(longestBacktickRun(text) + 1)
const padded = needsPadding(text) ? ` ${text} ` : text
return success({ segments: [syntax(`${fence}${padded}${fence}`)] })
return { segments: [syntax(spans.join(textBreakSpelling))] }
}
function needsPadding(text: string): boolean {
+47 -16
View File
@@ -1,13 +1,15 @@
import type { Flavour } from '../plain/conventions.ts'
import type { LineContainer } from '../line-container.ts'
import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark/grammar.ts'
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
import { claimsDirectivePrefix } from '../directive-syntax.ts'
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
import { highlightDelimiter, highlightFlanking } from '../plain/conventions.ts'
import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
import { readEntityReference } from '../commonmark/entity-references.ts'
export type EmphasisRole = 'close' | 'open'
export type DelimiterRole = 'close' | 'open'
export type InlineEscaping = 'backslash' | 'bracketed' | 'bracketed-link-target' | 'none'
@@ -16,11 +18,12 @@ export type NodeRange = { first: number; last: number }
export type MarkRun = NodeRange & { depth: number }
export type InlineSegment =
| { emphasis: EmphasisRole; escaping: 'none'; nodes: MarkRun; text: string }
| { emphasis?: undefined; escaping: 'none'; nodes: NodeRange; text: string }
| { emphasis?: undefined; escaping: InlineEscaping; nodes?: undefined; text: string }
| { emphasis: DelimiterRole; escaping: 'none'; highlight?: undefined; nodes: MarkRun; text: string }
| { emphasis?: undefined; escaping: 'none'; highlight: DelimiterRole; nodes: MarkRun; text: string }
| { emphasis?: undefined; escaping: 'none'; highlight?: undefined; nodes: NodeRange; text: string }
| { emphasis?: undefined; escaping: InlineEscaping; highlight?: undefined; nodes?: undefined; text: string }
export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRun: MarkRun | undefined }
export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRuns: MarkRun[] }
type ScanLine = { position: LinePosition; start: number; text: string }
@@ -32,8 +35,8 @@ const delimiters = ['*', '_', '`', '~']
const followsLinkText = /[([]/
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
return escape(resolveEmphasis(segments), container)
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): AssembledLine {
return escape(resolveEmphasis(segments), container, flavour === 'plain')
}
function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
@@ -65,11 +68,11 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
return resolved
}
function escape(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
function escape(segments: readonly InlineSegment[], container: LineContainer, highlights: boolean): AssembledLine {
const scan = segments.map((segment) => segment.text).join('')
const escapings: InlineEscaping[] = []
for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) escapings.push(segment.escaping)
const escaped = escapeClosedRuns(scan, escapings, escapeClaims(scan, escapings, container))
const escaped = escapeClosedRuns(scan, escapings, escapeClaims(scan, escapings, container, highlights))
const placements: number[] = []
let output = ''
for (let index = 0; index < scan.length; index += 1) {
@@ -78,34 +81,45 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
output += scan.charAt(index)
}
if (container === 'paragraph' && opensLinkDefinition(output)) {
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRun: undefined }
return { line: `\\${output}`, unspellableRun: unspellableRun(segments, output, placements) }
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRuns: [] }
return { line: `\\${output}`, unspellableRuns: unspellableRuns(segments, output, placements) }
}
return { line: output, unspellableRun: unspellableRun(segments, output, placements) }
return { line: output, unspellableRuns: unspellableRuns(segments, output, placements) }
}
function escapeClaims(scan: string, escapings: readonly InlineEscaping[], container: LineContainer): ReadonlySet<number> {
function escapeClaims(scan: string, escapings: readonly InlineEscaping[], container: LineContainer, highlights: boolean): ReadonlySet<number> {
const escaped = new Set<number>()
const linkClose = lastLinkClose(scan, escapings)
let line = scanLine(scan, 0)
let afterEscape = false
// Whether the `=` before opens a `==` the reader takes whole, so this one starts nothing.
let pairsEquals = false
for (let index = 0; index < scan.length; index += 1) {
if (index > line.start + line.text.length) line = scanLine(scan, line.start + line.text.length + 1)
const escaping = escapings[index]
const escapable = escaping === 'backslash' || escaping === 'bracketed'
const opensEquals: boolean = highlights && !pairsEquals && scan.startsWith(highlightDelimiter, index)
const claimed: boolean =
(escapable &&
(claimsLineStart(line, index, container) ||
((opensEquals && claimsHighlight(scan, index)) ||
claimsLineStart(line, index, container) ||
mergesWithSyntax(scan, escapings, index) ||
opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, afterEscape))) ||
(escaping === 'bracketed-link-target' &&
((scan.charAt(index) === '`' && opensCodeSpan(scan, index, afterEscape)) || claimsDirectivePrefix(scan, index)))
if (claimed) escaped.add(index)
afterEscape = claimed
pairsEquals = opensEquals && !claimed
}
return escaped
}
// Like an emphasis run, a `==` in text escapes where the reader can open or close with it.
function claimsHighlight(scan: string, index: number): boolean {
const flanking = highlightFlanking(scan, index)
return flanking.opens || flanking.closes
}
// CommonMark reads no escape inside a code span, so a backtick string an escape forms or splits off still closes one an earlier bare run opens.
function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], claimed: ReadonlySet<number>): ReadonlySet<number> {
const escaped = new Set(claimed)
@@ -134,10 +148,27 @@ function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], cl
return escaped
}
function unspellableRun(segments: readonly InlineSegment[], output: string, placements: readonly number[]): MarkRun | undefined {
// One emphasis run, the innermost, or every highlight run the line cannot spell.
function unspellableRuns(segments: readonly InlineSegment[], output: string, placements: readonly number[]): MarkRun[] {
const { nodes, runs } = emittedRuns(segments, placements, output)
const pair = misflanked(runs) ?? unpaired(runs)
return pair === undefined ? undefined : nodes[pair]
const run = pair === undefined ? undefined : nodes[pair]
return run === undefined ? unreadHighlights(segments, output, placements) : [run]
}
// No `==` in text can open or close, and highlights never nest, so a pair reads back where each delimiter flanks.
function unreadHighlights(segments: readonly InlineSegment[], output: string, placements: readonly number[]): MarkRun[] {
const unread: MarkRun[] = []
let cursor = 0
for (const segment of segments) {
const start = placements[cursor] ?? 0
cursor += segment.text.length
if (segment.highlight === undefined) continue
const flanking = highlightFlanking(output, start)
const flanks = segment.highlight === 'open' ? flanking.opens : flanking.closes
if (!flanks && unread.at(-1) !== segment.nodes) unread.push(segment.nodes)
}
return unread
}
function misflanked(runs: readonly EmittedRun[]): number | undefined {
+8 -7
View File
@@ -1,10 +1,11 @@
import type { AdfNode } from '../../adf/document.ts'
import type { ConvertErrorPath } from '../../result.ts'
import { carriesOnly, nodeContent } from '../../adf/document.ts'
import type { Flavour } from '../plain/conventions.ts'
import { holdsOnlyAttributes, nodeContent } from '../../adf/document.ts'
import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
import { tryPipeCell } from './inline-line.ts'
export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | undefined {
export function tryPipeTable(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): string | undefined {
const rows = pipeRows(node)
if (rows === undefined) return undefined
const lines: string[] = []
@@ -12,7 +13,7 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
const cells: string[] = []
for (const [cellIndex, paragraph] of row.entries()) {
const content = nodeContent(paragraph)
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], flavour)
if (line === undefined) return undefined
cells.push(line)
}
@@ -25,16 +26,16 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
function pipeRows(node: AdfNode): AdfNode[][] | undefined {
const rows = nodeContent(node)
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[][] = []
for (const [index, row] of rows.entries()) {
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 paragraphs: AdfNode[] = []
for (const cell of cells) {
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)
}
grid.push(paragraphs)
@@ -45,6 +46,6 @@ function pipeRows(node: AdfNode): AdfNode[][] | undefined {
function plainParagraph(cell: AdfNode): AdfNode | undefined {
const content = nodeContent(cell)
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
}
+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 { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
import { nodeAttrs, nodeMarks } from '../adf/document.ts'
import { spellEmptyKeys } from './empty-keys.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
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
}
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined {
const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, [])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
// `undefined` where the spelling holds no such attrs: only a directive spells attrs=empty.
export function spellMarkAttributes(mark: AdfMark, spelling: MarkSpelling): string | undefined {
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 { DirectiveSpan, Read } from './directive-syntax.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 { fencedCodeBlock } from './commonmark/backtick-runs.ts'
import { infoStringCarries } from './commonmark/grammar.ts'
import { isAdfNode } from '../adf/document.ts'
import { isJsonValue, nestingDepth, overNested } from '../json-value.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'
export const carryFencePrefix = 'adf:'
export const carryName = 'carry'
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 }> {
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
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> {
const json = carriedJson(node, 'compact', path, largestNesting)
const json = carriedJson(node, node, 'compact', path, largestNesting)
if (!json.ok) return json
return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value.json)]])))
}
export function readCarriedBlock(body: string, depth: number): Read<AdfNode> {
return readCarriedJson(body, 'two-space', largestNesting - depth)
// The type a fence's info string names, `undefined` where the info string opens no carry.
export function carryFenceType(info: string): string | undefined {
return info.startsWith(carryFencePrefix) ? info.slice(carryFencePrefix.length) : undefined
}
export function readCarriedBlock(type: string, body: string, depth: number): Read<AdfNode> {
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 {
@@ -36,28 +53,44 @@ export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefine
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)
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 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)
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
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` } }
}
if (serializeCanonicalJson(value, spelling) !== raw) {
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") }
return { value }
if (!isAdfNode(held)) return { fault: unsupportedNodeShape(`the opaque carry holds one ADF node's JSON: this JSON is no ADF node; ${wayOut}`) }
return { value: held }
}
// 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 {
+6 -2
View File
@@ -3,8 +3,9 @@ import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes } from '../directive-syntax.ts'
import type { MarkSpelling } from '../mark-spellings.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 { readEmptyKeys } from '../empty-keys.ts'
import { readVocabulary } from './directive-attributes.ts'
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
const markdown = markdownForm(spelling)
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 (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 })
}

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