175 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
lilleman b9e2842310 35a - review: prose pass on README §Plain markdown and §message and path
CI / gate (push) Successful in 43s
CI / publish (push) Successful in 6s
2026-09-28 22:21:28 +02:00
lilleman 75c5917712 35a - review: an image refusal beside a marker names the line it shares, README wording, 41 filed
CI / gate (push) Successful in 43s
CI / publish (push) Has been skipped
2026-09-28 22:17:22 +02:00
lilleman daaa2134b3 35a - review: tests for the image refusal naming where the image sits 2026-09-28 22:17:22 +02:00
lilleman af7658a863 35a - review: a title keeps its ==, an image beside a marker is refused as before
CI / gate (push) Successful in 41s
CI / publish (push) Has been skipped
2026-09-28 22:12:58 +02:00
lilleman 13b04e46d3 35a - review: tests for an expand title's == and an image beside a marker 2026-09-28 22:12:58 +02:00
lilleman 474e6a2991 35a - the parser reads the plain flavour and the lift goes
CI / gate (push) Successful in 43s
CI / publish (push) Has been skipped
2026-09-28 22:02:55 +02:00
lilleman 23804a5b3a 40 - plan exact deep-equality for the round-trip, replacing 39
CI / gate (push) Successful in 43s
CI / publish (push) Successful in 7s
2026-09-28 21:39:47 +02:00
lilleman 8d2d2405f3 Goal 7 names the public surface; 39 exports sameAdf
CI / gate (push) Successful in 44s
CI / publish (push) Successful in 7s
2026-09-28 21:25:19 +02:00
lilleman 4443ea57af 36e - todo-history.md's decisions move to docs/decisions.md and it goes
CI / gate (push) Successful in 42s
CI / publish (push) Successful in 7s
2026-09-28 20:59:22 +02:00
lilleman 09d2e54f5a 36d - review: foreign HTML's goals and the loss it names, entries that land with their items
CI / gate (push) Successful in 41s
CI / publish (push) Successful in 7s
2026-09-28 18:56:50 +02:00
lilleman 2701e165c4 36d - todo.md's settled text and §11 and §15's dated rules move to docs/decisions.md
CI / gate (push) Successful in 42s
CI / publish (push) Has been cancelled
2026-09-28 18:55:09 +02:00
119 changed files with 3738 additions and 2666 deletions
+65 -53
View File
@@ -1,8 +1,7 @@
# Working in this repo # Working in this repo
The rules for every collaborator, human or agent, and an index of the decisions a reader would The rules for every collaborator, human or agent, and an index of the decisions a reader would
otherwise relitigate. Using the library: `README.md`. What is still to build: `todo.md`; a bare otherwise relitigate. Using the library: `README.md`. What is still to build: `todo.md`.
`(28)` cites that item's entry in `todo-history.md`.
## Decisions ## Decisions
@@ -11,15 +10,24 @@ In `docs/decisions.md`:
- Plain markdown is a flavour of the grammar - Plain markdown is a flavour of the grammar
- The round-trip is the product - The round-trip is the product
- Markdown in is a canonical fixpoint - Markdown in is a canonical fixpoint
- Equality is editor-normal - Equality is deep
- `!adf:textBreak{}` parts text CommonMark would join
- An empty key spells `empty`
- `-0` is spelled `-0`
- Empty markdown is a document of no blocks
- Unknown nodes ride the carry - Unknown nodes ride the carry
- Foreign HTML is refused by name - The carry fence names the node type
- A code block is a fence per text node
- Foreign HTML sorts three ways
- Names stay text - Names stay text
- Directives under `!adf:` - Directives under `!adf:`
- CommonMark is a subset - CommonMark is a subset
- Tables - Tables
- Links - Links
- Ids stay site-local - 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 - The HTML dialect
- No runtime dependencies - No runtime dependencies
- Standards ship as data - Standards ship as data
@@ -40,26 +48,30 @@ In `docs/decisions.md`:
- Firefox reads the build - Firefox reads the build
- The coverage floors - The coverage floors
- The size ratchet - The size ratchet
- The project ships under the comprehension floor until items 59, 60, 61 and 62 land
- Properties on a fixed seed - Properties on a fixed seed
- The CommonMark suite checks three ways
- The flavour spec is read as a source - The flavour spec is read as a source
- The node tables answer to Atlassian's schema - The node tables answer to Atlassian's schema
- Nothing recurses unbounded - Nothing recurses unbounded
- No value `JSON.parse` or `structuredClone` builds loops a call
- Nothing spreads an unbounded array - Nothing spreads an unbounded array
- A retry loop checks its own termination - A retry loop checks its own termination
- Readers scan by index - Readers scan by index
- The spelling memo - The spelling memo
- Cost fixes are measured, never timed
- Only the hard break holds a raw newline - Only the hard break holds a raw newline
- Emphasis follows CommonMark's matching - Emphasis follows CommonMark's matching
- Readable spellings take the `try` prefix - Readable spellings take the `try` prefix
- The attribute vocabulary is ADF's - The attribute vocabulary is ADF's
- The source parts by ADF and format - The source parts by ADF and format
## 7. Nothing about any consumer ## 1. Nothing about any consumer
No Jira client, no HTTP, no REST shapes, no issue keys, no actual consumer named anywhere. Design No Jira client, no HTTP, no REST shapes, no issue keys, no actual consumer named anywhere. Design
against the README's personas. against the README's personas.
## 9. Release automation ## 2. Release automation
- The bump commit renames `CHANGELOG.md`'s `## Unreleased` to the version. - The bump commit renames `CHANGELOG.md`'s `## Unreleased` to the version.
- Exact versions: `save-exact=true` in `.npmrc`. - Exact versions: `save-exact=true` in `.npmrc`.
@@ -68,10 +80,10 @@ against the README's personas.
specific as the publisher tags: `oven/bun:1.4.0-alpine` pins Bun's patch and leaves the base specific as the publisher tags: `oven/bun:1.4.0-alpine` pins Bun's patch and leaves the base
floating because Bun publishes nothing narrower. Actions pin semver tags. floating because Bun publishes nothing narrower. Actions pin semver tags.
## 10. Tests first, in Docker ## 3. Tests first, in Docker
Test for the behaviour wanted first, then implement until green. `node --test`, beside the code. Test for the behaviour wanted first, then implement until green. `node --test`, beside the code.
Node, tsc and npm never run on the host — only via the pinned images (§9). Tests are independent, Node, tsc and npm never run on the host — only via the pinned images (§2). Tests are independent,
containers are torn down after a run. A test reaches only for what Node's, Deno's and Bun's `node:` containers are torn down after a run. A test reaches only for what Node's, Deno's and Bun's `node:`
shims all carry. shims all carry.
@@ -81,7 +93,7 @@ a hang. A leg added later owes the same marker, and a function a leg reaches cha
with `&&`, because the `||` that captures the leg's status suspends `set -e` for everything it with `&&`, because the `||` that captures the leg's status suspends `set -e` for everything it
calls. A leg whose output is both streamed and grepped keeps the copy in a `mktemp` calls. A leg whose output is both streamed and grepped keeps the copy in a `mktemp`
file: `tee /dev/stderr` reopens fd 2, and under `./ci.sh > log 2>&1` the two offsets punch NUL file: `tee /dev/stderr` reopens fd 2, and under `./ci.sh > log 2>&1` the two offsets punch NUL
holes through each other's lines (4d). holes through each other's lines.
`PROPERTY_RUNS=<runs>` raises the property runs and randomizes the seed for local digging. The `PROPERTY_RUNS=<runs>` raises the property runs and randomizes the seed for local digging. The
generators and run parameters properties share live in `src/conformance/property-harness.ts`, generators and run parameters properties share live in `src/conformance/property-harness.ts`,
@@ -91,12 +103,11 @@ Each `- ` bullet in `spec/flavour.md`'s `## Block nodes`, `## Inline nodes` and
the nodes named before its first em dash, with the attributes following `Attributes: ` — a the nodes named before its first em dash, with the attributes following `Attributes: ` — a
parenthesized value set reading `string`; fenced examples are skipped. Keep prose out of a bullet. parenthesized value set reading `string`; fenced examples are skipped. Keep prose out of a bullet.
## 11. Code rules ## 4. Code rules
- Two-space indent, English everywhere. Alphabetical order wherever order - Two-space indent, English everywhere. Alphabetical order wherever order carries no meaning,
carries no meaning, keyed on the name a line introduces rather than where it came from: an keyed on the name a line introduces: an import sorts on its first binding, type imports ahead of
import sorts on its first binding, type imports ahead of value imports, so moving or renaming a value imports, so moving or renaming a module reorders nothing.
module reorders nothing (the maintainer, 2026-09-18).
- No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the - No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the
fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
unrepresentable. unrepresentable.
@@ -107,46 +118,39 @@ parenthesized value set reading `string`; fenced examples are skipped. Keep pros
a second consumer, or it goes. a second consumer, or it goes.
- Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`); a file - Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`); a file
does not repeat its directory in its name — `adf/document.ts`, never `adf/adf-document.ts`. A name does not repeat its directory in its name — `adf/document.ts`, never `adf/adf-document.ts`. A name
is the noun `spec/flavour.md` or ADF's schema uses for the thing; a directory follows a split the is the noun `spec/flavour.md` or ADF's schema uses for the thing.
spec draws; a placement neither this section nor `docs/decisions.md` §The source parts by ADF and
format settles goes beside its only reader, or in what both read where there are two (the
maintainer, 2026-09-18).
## 12. Prose to a minimum ## 5. Prose to a minimum
Applies everywhere: comments, every markdown file in this repo (this one included), PR text. Applies everywhere: comments, every markdown file in this repo (this one included), PR text.
- Default is no comment. One earns its single line only by naming an invariant, footgun or - Default is no comment. One earns its single line only by naming an invariant, footgun or
external constraint the code cannot show — never restatement, history, absence or arrangement. external constraint the code cannot show — never restatement, history, absence or arrangement.
A second line belongs in the commit message or a decision entry here. A second line belongs in the commit message or a `docs/decisions.md` entry.
- A doc paragraph says what the repo cannot say for itself, or it goes. The fix for a redundant - A doc paragraph says what the repo cannot say for itself, or it goes. The fix for a redundant
one is deletion, not trimming. A false claim in any doc is a bug, fixed where found. one is deletion, not trimming. A false claim in any doc is a bug, fixed where found.
- Published text — npm README, error messages, API docs — never references internal systems, - Published text — npm README, error messages, API docs — never references internal systems,
tickets or repos. tickets or repos.
## 13. Commits and PRs ## 6. Commits and PRs
One-line commit messages and PR titles; short PR summaries. No AI-attribution markers, ever. One-line commit messages and PR titles; short PR summaries. No AI-attribution markers, ever.
## 15. The working loop ## 7. The working loop
`todo.md` lists what is left under the release that ships it, in shipping order. One item per A session works one chunk, starting from the first item in `todo.md` not waiting on an unmet "Lands
session — the first under the earliest release — in the smallest PR-able chunk; split a big item after", and stops when that chunk merges, whatever it was asked to finish: a release is a chain of
into sub-items in `todo.md` before starting it. A chunk running a little over or under that is not sessions, so an instruction to work until a release is done names the chain, not the session. An
worth deliberating; what matters is that nothing is left undone in the end. The session stops there open PR is a chunk already in flight, and finishing it is the session.
whatever it was asked to finish: a release is a chain of sessions, so an instruction to work until a
release is done names the chain, not the session. An open PR is a chunk already in flight, and
finishing it is the session.
Per chunk: Per chunk:
1. Fresh worktree off updated `origin/main`; implement tests-first (§10). 1. Fresh worktree off updated `origin/main`; implement tests-first (§3).
2. Run the larv-review flow until it passes and CI is green. A reviewer launch states the latest 2. Run the larv-review flow until it passes and CI is green. A reviewer launch states the latest
gate result (commit and outcome); a reviewer does not re-run `ci.sh` or the tests when a gate result (commit and outcome); a reviewer does not re-run `ci.sh` or the tests when a
result exists for the commit under review, or when the diff since that result cannot affect result exists for the commit under review, or when the diff since that result cannot affect
it (docs-only) — re-run only what its own findings or fixes invalidate. it (docs-only) — re-run only what its own findings or fixes invalidate.
3. Merge the PR (standing authorization, this repo only, granted through the `0.2.0` release — 3. Merge the PR (standing authorization, this repo only, granted by the maintainer through the
the maintainer, 2026-09-13), delete the item from `todo.md` — what a consumer sees of it is `0.2.0` release), report, stop.
reworded for them into `CHANGELOG.md`'s `## Unreleased` — report, stop.
Reserved for the maintainer whatever any rule here says: changing `version` in `package.json` (a Reserved for the maintainer whatever any rule here says: changing `version` in `package.json` (a
bump on `main` publishes, `docs/decisions.md` §Publish on a version bump — every release is the bump on `main` publishes, `docs/decisions.md` §Publish on a version bump — every release is the
@@ -157,32 +161,29 @@ maintainer's) and the `NPM_TOKEN` secret.
Any choice where what the maintainer would pick is not near-certain gets asked. The confidence bar Any choice where what the maintainer would pick is not near-certain gets asked. The confidence bar
is very high — asking too often is the accepted cost, guessing wrong is not. is very high — asking too often is the accepted cost, guessing wrong is not.
An ask is a gap in this file, and its answer is the rule that closes it, landing here — never the An ask is a gap in `docs/decisions.md`, and its answer is the entry that closes it, landing there —
instance alone. Before asking, name the class the question belongs to and the earlier never the instance alone; an answer that is a goal lands in the README, one that is a working rule
`(the maintainer, …)` entries of that class; where a rule already decides it, apply it without here. Before asking, name the class the question belongs to and the entries of that class; where
asking, and where the rule reads two ways on this input, that reading is the ask. Never ask "A or one already decides it, apply it without asking, and where it reads two ways on this input, that
B?": state the gap, the earlier asks of its class, the nearest text here, a candidate rule in this reading is the ask. Never ask "A or B?": state the gap, the earlier entries of its class, the
file's voice and section, and the instance it yields. A rule that keeps collecting instances is nearest text, a candidate entry in that file's voice, and the instance it yields. An entry that
wrong: rewrite it. keeps collecting instances is wrong: rewrite it.
Which output the audience expects — README goal 5 — is settled by a reader panel rather than Which output the audience expects — README goal 6 — is settled by a reader panel rather than
asked: three fresh-context readers, one per README persona the conversion serves, each given only asked: three fresh-context readers, one per README persona the question serves, each given only
`## Audience` and the input, writing what they expect before picking among outputs the goals `## Audience` and the input, writing what they expect before picking among outputs the goals
allow, rendered, shuffled, with no rationale and nothing saying what is implemented. Three agreeing allow, rendered, shuffled, with no rationale and nothing saying what is implemented. Three agreeing
settle it; otherwise four more read, five of seven settle it, and less is a missing goal, asked. settle it; otherwise four more read, five of seven settle it, and less is a missing goal, asked.
The verdict lands in the item it settles (the maintainer, 2026-09-25). The verdict lands in `docs/decisions.md`.
### Rules the loop has settled (the maintainer, 2026-09-18) A writer panel settles every new or changed markdown or HTML spelling: a reader panel whose readers
are the people who read and write that format (README `## Audience`). A panel of the developer
personas settles a question about what an app relies on.
- A finding inside the chunk's item is fixed in the chunk. Outside it, a new `todo.md` item, always ### Stated numbers
in a release, weighed against every item on that release by the personas and `docs/decisions.md`
§Plain markdown is a flavour of the grammar through §Names stay text — an item it outweighs moves A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks, naming
later. A weighing no rule decides is asked as a gap. the number it can reach. A number the code needs and no entry states is a gap.
- A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks,
naming the number it can reach. A number the code needs and no rule states is a gap.
- An earliest release with no items left and nothing shipped toward it is planned as the chunk:
every later item weighed as above, the order written in `todo.md`, and the maintainer's approval
taken before any code. With work shipped toward it, it is ready to cut: report that and stop.
### The continuous loop ### The continuous loop
@@ -191,3 +192,14 @@ and `todo.md` and trusting them over anything remembered from earlier iterations
is a thin driver: each chunk's work runs in a fresh-context subagent holding this file as its is a thin driver: each chunk's work runs in a fresh-context subagent holding this file as its
charter, and the driver only relays maintainer questions, runs the review flow, merges, and cleans charter, and the driver only relays maintainer questions, runs the review flow, merges, and cleans
up. The loop stops when only maintainer-reserved acts remain. up. The loop stops when only maintainer-reserved acts remain.
## 8. Scoring run
The comprehension panel's fill-ins:
- Language: TypeScript.
- Kind: a pure-function document converter with hand-written parsers and emitters.
- Domain: Atlassian Document Format and CommonMark parsing.
- Domain docs: the CommonMark spec, ADF's JSON schema and `spec/flavour.md`.
- 3am question: a viewer/editor app reports that a document it saved comes back with two text
nodes merged and a mark gone after `markdownToAdf(adfToMarkdown(doc))`.
+26 -4
View File
@@ -2,10 +2,30 @@
## Unreleased ## Unreleased
- **Breaking:** directives, the opaque carry among them (now `carry`), are spelled under an `!adf:` - **Breaking:** directives, the inline opaque carry among them (now `!adf:carry{json="…"}`), are
prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`, `!adf:name arg {attrs}`) in place spelled under an `!adf:` prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`,
of the `:::`/`::`/`:name` forms: text holding an unescaped `!adf:` is claimed, and `adf` is an `!adf:name arg {attrs}`) in place of the `:::`/`::`/`:name` forms: text holding an unescaped
ordinary code block language. Convert stored markdown per `MIGRATION.md`. `!adf:` is claimed. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** the block carry is a code fence whose info string `adf:<type>` names the node's
type, its body the node's JSON without `type`, and a code fence whose info string opens `adf:` is
claimed, and `adf` is an ordinary code block language. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` read markdown holding no block as a
document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}`
spells a document holding no `content` key.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it: two adjacent text
nodes CommonMark would read back as one are parted by `!adf:textBreak{}`, an empty `attrs`,
`content` or `marks` is spelled `{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is
spelled `-0`, and a `codeBlock` of several text nodes is a fence per node. A `codeBlock` holding
other than plain text nodes rides the block carry, where it was refused.
- **Breaking:** `unspellable-character` and `unspellable-line-start` leave `ConvertErrorCode`, and
`adfToMarkdown` no longer returns `unspellable-whitespace`. A carriage return in text is spelled
`&#13;` and a null character `!adf:text{text="\u0000"}`; a paragraph line opening with a code span
is spelled as that code span, its backticks opening no fence; a code block holding either
character, a code span holding a null character, a text node holding no text or holding
`content`, and an `emoji`, `mention` or `status` whose `text` holds a line ending or a null
character ride the carry. See `MIGRATION.md`.
- Fix a link `title` holding a null character: it is written as `!adf:link[text]{attrs}`, where it
read back as U+FFFD.
- **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no - **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no
CommonMark escape spells is written as `!adf:link[text]{attrs}`. CommonMark escape spells is written as `!adf:link[text]{attrs}`.
- **Breaking:** some directive refusals carry `malformed-directive` where they carried - **Breaking:** some directive refusals carry `malformed-directive` where they carried
@@ -20,6 +40,8 @@
`collection`, `id` and `occurrenceKey` directly where they rode the opaque carry. `collection`, `id` and `occurrenceKey` directly where they rode the opaque carry.
- Fix an image inside another image's description: it flattens into the alt text, where it was - Fix an image inside another image's description: it flattens into the alt text, where it was
refused. refused.
- Fix a cyclic document, in which an object holds itself: `adfToMarkdown` and `adfToPlainMarkdown`
refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, where all three hung.
## 0.1.0 ## 0.1.0
+23 -9
View File
@@ -5,8 +5,9 @@
Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling without an error, Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling without an error,
turning each directive into text and each carried node into an `adf` code block. Before `0.2.0` turning each directive into text and each carried node into an `adf` code block. Before `0.2.0`
reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the
recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by the
the tables below. Stored ADF needs no change. tables below. Stored ADF needs one change: give a document holding no `content` key `content: []`.
`0.1.0` built that shape from empty markdown, meaning the empty document.
### Convert markdown ### Convert markdown
@@ -22,15 +23,17 @@ import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1'
function migrateMarkdown(stored: string) { function migrateMarkdown(stored: string) {
const parsed = markdownToAdf010(stored) const parsed = markdownToAdf010(stored)
return parsed.ok ? adfToMarkdown(parsed.value) : parsed // 0.1.0 dropped an empty content array, so a document with no content key meant an empty one.
return parsed.ok ? adfToMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed
} }
``` ```
- Convert each document once: a second pass can return ok while turning the directives into text. - Convert each document once: a second pass can return ok while turning the directives into text.
Stop `0.1.0` writing first, and record which documents are converted. Stop `0.1.0` writing first, and record which documents are converted.
- A refusal carrying `position` is `0.1.0`'s parse, which refused that markdown before too. One - A refusal carrying `position` is `0.1.0`'s parse, which refused that markdown before too. One
without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF rather than keeping without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF, with
the unconverted markdown. `content: parsed.value.content ?? []` as the recipe gives it, rather than keeping the unconverted
markdown.
### Spellings ### Spellings
@@ -40,12 +43,12 @@ function migrateMarkdown(stored: string) {
| `::media {id=a type=file}` | `!adf:media {id=a type=file}` | | `::media {id=a type=file}` | `!adf:media {id=a type=file}` |
| `::taskItem TODO {localId=i}`: an empty `caption`, `decisionItem`, `paragraph` or `taskItem`, or an empty `heading` carrying `localId` | `!adf:taskItem TODO {localId=i}` then `!adf:/taskItem` | | `::taskItem TODO {localId=i}`: an empty `caption`, `decisionItem`, `paragraph` or `taskItem`, or an empty `heading` carrying `localId` | `!adf:taskItem TODO {localId=i}` then `!adf:/taskItem` |
| `:mention[@Mikael]{id=5b10a2}` | `!adf:mention[@Mikael]{id=5b10a2}` | | `:mention[@Mikael]{id=5b10a2}` | `!adf:mention[@Mikael]{id=5b10a2}` |
| the `adf` code fence and `:adf{json="…"}` | the `carry` code fence and `!adf:carry{json="…"}` | | the `adf` code fence and `:adf{json="…"}` | the `adf:<type>` code fence, its JSON without `type`, and `!adf:carry{json="…"}` |
| `\:` keeps a directive literal | `\!adf:` keeps a directive literal | | `\:` keeps a directive literal | `\!adf:` keeps a directive literal |
| `:adf{json="…"}` carrying a link for its `collection`, `id` or `occurrenceKey` | `!adf:link[text]{attrs}` | | `:adf{json="…"}` carrying a link for its `collection`, `id` or `occurrenceKey` | `!adf:link[text]{attrs}` |
A colon run and `:name[` are plain text now, and `adf` an ordinary code block language; text A colon run and `:name[` are plain text now, and `adf` an ordinary code block language; text
holding an unescaped `!adf:` and a `carry` fence are claimed instead. holding an unescaped `!adf:` and a code fence whose info string opens `adf:` are claimed instead.
### Readings ### Readings
@@ -54,15 +57,26 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
| Input | `0.1.0` | `0.2.0` | | Input | `0.1.0` | `0.2.0` |
| --- | --- | --- | | --- | --- | --- |
| a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them | | a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them |
| markdown holding no block (`markdownToAdf("")`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former |
| a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | the block carry, refusing a body that is not one node's canonical JSON; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block |
### Error codes ### Error codes
`unspellable-link` leaves `ConvertErrorCode`: a `switch` naming it stops compiling, and the link `unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode`:
it named converts. a `switch` naming one stops compiling. A document `adfToMarkdown` refused with one of them now
converts; markdown the parser refused with one of them now refuses with the code the table gives.
| Input | `0.1.0` | `0.2.0` | | Input | `0.1.0` | `0.2.0` |
| --- | --- | --- | | --- | --- | --- |
| a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` | | a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` |
| a carriage return in text, on emit | `unspellable-character` | spells `&#13;` |
| a null character in text, on emit | `unspellable-character` | spells `!adf:text{text="\u0000"}` |
| a carriage return or null character in a code block, or a null character in a code span, on emit | `unspellable-character` | rides the carry |
| a paragraph line opening with a code span of three or more backticks, on emit | `unspellable-line-start` | spells the code span |
| a block directive whose CommonMark spelling holds a carriage return, a null character or a code span opening a line, on parse | `unspellable-character`, `unspellable-line-start` | `unsupported-node-shape`; write the CommonMark spelling |
| an `emoji`, `mention` or `status` whose `text` holds a line ending or a null character, on emit | `unspellable-whitespace`, `unspellable-character` | rides the inline carry |
| a text node holding no text, or holding `content`, on emit | `unsupported-node-shape` | rides the carry |
| a `codeBlock` holding other than plain text nodes, on emit | `unsupported-node-shape` | rides the block carry |
| a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` | | a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` |
| a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` | | a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` |
| an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` | | an empty node the `::taskItem` spelling row names, written as a leaf | parses | `malformed-directive` |
+90 -86
View File
@@ -5,7 +5,8 @@ an HTML dialect.
**Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at **Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at
`0.2.0`.** `0.2.0`.**
Plan: `todo.md`. Decisions: Plan:
[`todo.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/todo.md). Decisions:
[`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md). Changes: [`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md). Changes:
[`CHANGELOG.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/CHANGELOG.md). The lossless flavour's grammar: [`CHANGELOG.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/CHANGELOG.md). The lossless flavour's grammar:
[`spec/flavour.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/spec/flavour.md). [`spec/flavour.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/spec/flavour.md).
@@ -23,46 +24,23 @@ represent.
## Goals ## Goals
In priority order. The most useful ADF conversion library available, judged by these goals, in priority order:
1. **Lossless, and every call returns.** The round-trip holds for every document the lossless 1. **Lossless, and every call returns a result, never a throw.**
conversions take, node types this version does not know included; one that has no spelling is 2. **ADF is the hub.**
refused and says where, never silently reduced. No input makes a call loop forever or overflow 3. **Easy to find, and clear at a glance what it does.**
the stack. Every goal below gives way to this one. 4. **Each format reads and writes as its standard says.**
2. **ADF is the hub.** Every format and flavour converts to and from ADF, and no two others 5. **Our markdown is CommonMark, extended only where CommonMark has no spelling.**
convert directly: markdown↔HTML composes through ADF. Adding a format or flavour costs one 6. **No surprises: output reads and edits the way its audience expects.**
reader and one writer. A flavour of a grammar shares that grammar's reader and writer and adds 7. **Lossy conversion drops form, never content.**
only its own spellings. 8. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.**
3. **Plain CommonMark is input.** Markdown written for something else converts — the exceptions 9. **Fast, and linear in the document's size as JSON.**
below are the whole of them — and every spelling a flavour claims on top of CommonMark is
escapable, so each flavour is opt-in.
4. **Output a person can edit.** A node CommonMark can spell gets that spelling; the lossless
flavour's directive form carries only what CommonMark cannot hold.
5. **Lossy conversion keeps the content.** `adfToPlainMarkdown` and `plainMarkdownToAdf` drop what
plain markdown cannot hold — format, design, structure — never content: what a reader of the
rendered document sees or follows, its text, images and link targets. Content the document only
references is marked where it stood, by a note that reads as the converter's and names what was
left out. What is dropped goes the way the audience expects. The lossy pair creates and
exports; it never saves back over the document it read — identity (task, mention, media ids)
lives only in the lossless pair.
6. **Failures are values.** Nothing throws, and `code` is a closed list — as much a contract as
the emitted formats are.
7. **Nothing in the way.** No runtime dependencies, no I/O, no configuration, no host API: ESM on
any ES2022 engine, in a browser as readily as on a server, installed from public npm.
8. **Correct before fast.** Each format means what its own specification says — markdown as the
CommonMark spec reads it, well-formed HTML as the HTML standard parses it — both in what this
library reads and in what a conforming parser reads from what it writes. A call takes a whole
document and returns a whole result.
9. **Fast once correct.** Conversion time grows linearly with the document wherever the goals above
allow it; a faster path that risks one of them is not taken.
10. **Source a contributor can hold.** Any one function reads in one sitting, and no change makes
the longest one longer.
## Audience ## Audience
Application developers embedding the library, addressed as personas rather than named consumers Application developers embedding the library, in four personas. All four rely on the guarantees
(AGENTS.md §7). All four rely on the guarantees below and on `code` being a closed list; none may below and on an error's `code` being a closed list; none may rely on an error message's wording,
rely on an error message's wording, which is free text. which is free text.
- **Viewer/editor app** — shows a document, lets a human edit, posts it back. Relies on the - **Viewer/editor app** — shows a document, lets a human edit, posts it back. Relies on the
round-trip holding for whatever the site's editor wrote, unknown node types included, and on a round-trip holding for whatever the site's editor wrote, unknown node types included, and on a
@@ -74,6 +52,10 @@ rely on an error message's wording, which is free text.
- **LLM/agent pipeline** — hands documents to a model as markdown and writes the edits back. - **LLM/agent pipeline** — hands documents to a model as markdown and writes the edits back.
Relies on the round-trip and on markdown a reader half-knowing the lossless flavour can still edit. Relies on the round-trip and on markdown a reader half-knowing the lossless flavour can still edit.
Behind those apps, the people who read and write the markdown, and later the HTML: product
managers, engineers and support agents working in Atlassian products through a plugin or another
UI. They know markdown and not ADF, and rely on every spelling saying what it means to them.
## The shape ## The shape
```sh ```sh
@@ -92,7 +74,9 @@ if (result.ok) {
} }
``` ```
Pure functions, no I/O, no configuration. Serves Goals 1, 2 and 8. Pure functions, each taking a whole document and returning a whole
result; no I/O, no configuration. `markdownToHtml` and `htmlToMarkdown` convert through ADF:
they keep only what ADF holds, and refuse what `markdownToAdf` or `htmlToAdf` refuses.
```ts ```ts
adfToMarkdown(doc: AdfDocument): Result<string> adfToMarkdown(doc: AdfDocument): Result<string>
@@ -104,55 +88,71 @@ plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
adfToHtml(doc: AdfDocument): Result<string> // 0.2.0 adfToHtml(doc: AdfDocument): Result<string> // 0.2.0
htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.2.0 htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.2.0
markdownToHtml(markdown: string): Result<string> // 0.2.0, via ADF markdownToHtml(markdown: string): Result<string> // 0.2.0
htmlToMarkdown(html: string): Result<string> // 0.2.0, via ADF htmlToMarkdown(html: string): Result<string> // 0.2.0
``` ```
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws. `Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
## Plain markdown ## Plain markdown
Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` writes markdown other Serves Goal 7. Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` writes
tools render — GitHub, GitLab, Obsidian and the like — keeping the content and dropping the rest: markdown other tools render — GitHub, GitLab, Obsidian and the like — keeping the content and
attributes, colours, layout, identity. It refuses only dropping the rest: attributes, colours, layout, identity. Content is what a reader of the rendered
document sees or follows: its text, images and link targets. It refuses only
`not-an-adf-document`, `unsupported-document-version` and `unsupported-nesting-depth`, and writes `not-an-adf-document`, `unsupported-document-version` and `unsupported-nesting-depth`, and writes
no directive. `plainMarkdownToAdf` reads through `markdownToAdf`, refusing what it refuses, and no directive.
turns the conventions below back into nodes, taking other tools' spellings too. Markdown
`adfToPlainMarkdown` wrote reads back and writes again byte for byte; the document it came from `plainMarkdownToAdf` reads what `markdownToAdf` reads and refuses what it refuses, and reads the
does not come back. To edit a document and save it back, use `adfToMarkdown` and `markdownToAdf`: conventions below as nodes, taking other tools' spellings too; a backslash keeps a marker as text:
saving what this pair read replaces mentions, attachments and macros with text. `\==x==`, `> \[!NOTE]`, `- \[x]`. Markdown `adfToPlainMarkdown` wrote reads back and writes again
byte for byte; the document it came from does not come back.
To edit a document and save it back, use `adfToMarkdown` and `markdownToAdf`: saving what this pair
read replaces mentions, attachments and macros with text.
| ADF | Written | Read back | | ADF | Written | Read back |
| --- | --- | --- | | --- | --- | --- |
| `panel` | a GitHub alert, `> [!WARNING]`: info `NOTE`, note `IMPORTANT`, tip and success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE` | GitHub's five words, 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 | | `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; an expand inside an expand is a `nestedExpand` | | `expand`, `nestedExpand` | Obsidian's folded callout, `> [!NOTE]- Title` | `-` or `+` after any word, the rest of the marker's line the title; a link reads `text (target)`, or its text alone where the text is the target with or without `mailto:`; an expand inside an expand is a `nestedExpand` |
| `taskList` | `- [x] Done`, `- [ ] Todo` | a bullet list whose every item is so marked, `[X]` too | | `taskList` | `- [x] Done`, `- [ ] Todo` | a bullet list whose every item is so marked, `[X]` too |
| `backgroundColor` | `==text==` | `==text==` bounded outside by whitespace, punctuation or a line edge, in the editor's default highlight | | `backgroundColor` | `==text==` | `==text==` on one line, the text touching both delimiters, bounded outside by whitespace, punctuation or a line edge, or touching a Han, Hangul, Hiragana, Katakana, Thai, Lao, Khmer or Myanmar character on either side, in the editor's default highlight `#f8e6a0` |
| `table` | a pipe table: the first row its header, a cell's blocks on one line, a span kept under its header by empty cells | — | | `table` | a pipe table: the first row its header, a cell's blocks on one line, a span kept under its header by empty cells | — |
| `decisionList` | a bullet list | — | | `decisionList` | a bullet list | — |
| `mention`, `status`, `emoji`, `date` | their text: `@` kept, a mention with none `@` and its id, an emoji its `shortName` without, a date `2026-09-13` in UTC | — | | `mention`, `status`, `emoji`, `date` | their text: `@` kept, a mention with none `@` and its id, an emoji its `shortName` without, a date `2026-09-13` in UTC | — |
| `inlineCard`, `blockCard`, `embedCard` | a link to the card's URL | — | | `inlineCard`, `blockCard`, `embedCard` | a link to the card's URL, else its name | — |
| external `media` | `![alt](url)` in a block, `[alt](url)` inline | — | | external `media` | `![alt](url)` in a block, `[alt](url)` inline | — |
| stored `media`, `mediaInline`, `extension`, `inlineExtension` | their `alt` or `text` | — | | stored `media`, `mediaInline`, `extension`, `inlineExtension` | their `alt` or `text` | — |
| `layoutSection`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`, `extensionFrame`, `caption`, a node this version does not know | its blocks or its text | — | | `layoutSection`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`, `extensionFrame`, `caption`, a node this version does not know | its blocks or its text | — |
| `placeholder` | nothing | — | | `placeholder` | nothing | — |
- Content the document only references leaves an italic note naming it where it stood: - Content the document only references, with no text of its own to keep, leaves an italic note
`_(image not included)_`, `_(jira-issues-table not included)_`, `_(synced block not included)_`, naming it where it stood: `_(image not included)_` for stored media with no `alt`,
`_(link card not included)_`, `_(extension not included)_`. `_(link card not included)_` for a card with neither URL nor name, an extension with no `text`
- `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops, keeping its text, and its key, `_(jira-issues-table not included)_`, or `_(extension not included)_` without one, and
so does a mark CommonMark cannot spell where it stands. `_(synced block not included)_` for a `syncBlock`.
- A newline in text is a hard break, edge whitespace outside a link or code span is trimmed, - `backgroundColor`, `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops,
carriage returns and null characters are removed, and an empty paragraph drops. keeping its text, and so does a mark the flavour cannot spell where it stands.
- A newline in text is a hard break and in an expand's title a space, edge whitespace outside a
link or code span is trimmed, carriage returns and null characters are removed, and an empty
paragraph drops.
- A text node's `content` drops: Atlassian's schema forbids it, so no rendered document shows it.
- An ordered list numbered past `999999999`, or adjacent ordered lists whose numbering does not - An ordered list numbered past `999999999`, or adjacent ordered lists whose numbering does not
continue, is one bullet list keeping its numbers as text. continue, is one bullet list keeping its numbers as text.
- A node read back carries no `localId`, which Atlassian's schema requires on `taskList`, - A task list beside a bullet or decision list, or holding a block other than a task, joins one
`taskItem` and `blockTaskItem`: mint one where the receiving site requires it. bullet list keeping its states as text: `- \[x] Done`.
- Text that would read as a marker takes a backslash: `==` wherever it could open or close a
highlight, `[!…]` opening a quote, and `[x]` or `[ ]` opening any list item, since GitHub reads
that marker per item.
- A node read back carries no `localId` except a `taskList`, `taskItem` or `blockTaskItem`, which
Atlassian's schema requires one on: each gets a UUID v4 hashed from the whole markdown and its
position, the same on every read. Join markdown bound for one document and read it once: the same
markdown read twice into one document repeats its ids.
## The errors ## The errors
An ADF node type this version does not know is not an error: it is carried opaquely and restores Serves Goal 1. An ADF node type this version does not know is not an error: the lossless pair
unchanged ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)). carries it opaquely and restores it unchanged ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
`ConvertError` is `{ code, message, path, position? }`. `code` is the exported `ConvertErrorCode`, `ConvertError` is `{ code, message, path, position? }`. `code` is the exported `ConvertErrorCode`,
stable across minors and safe to `switch` on exhaustively with no `default`; `message` is free text stable across minors and safe to `switch` on exhaustively with no `default`; `message` is free text
@@ -176,17 +176,18 @@ Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.2.0`
| Code | Fires when | What you can do | | Code | Fires when | What you can do |
| --- | --- | --- | | --- | --- | --- |
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in a `carry` | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text | | `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in an opaque carry | write the spelling the message names, or keep it literal: escape the prefix — `\!adf:`, block and inline alike — or, for a code fence whose info string opens `adf:`, drop the info string and wrap the fence in `!adf:codeBlock {language="adf:…"}` |
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way | | `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning | | `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
| `unmappable-html` | the input holds an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the lossless flavour | | `unmappable-html` | the input holds an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the lossless flavour |
| `unmappable-image` | an image sits inside other content that is not another image's description, or carries a title | give the image a paragraph of its own and drop the title | | `unmappable-image` | an image sits inside other content that is not another image's description, or carries a title | give the image a paragraph of its own and drop the title |
| `unspellable-whitespace` | an `emoji`, `mention` or `status` directive's content slot spells a newline or a carriage return — `&#10;`, `&#13;`, `!adf:text{text="\n"}` | replace it with a space, or write the node as `!adf:carry` — an inline directive never spans lines |
Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`: Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`:
| Code | Fires when | What you can do | | Code | Fires when | What you can do |
| --- | --- | --- | | --- | --- | --- |
| `not-an-adf-document` | the value handed in is no ADF document — a missing or wrong `type`, a stray key, a node that is not a node | guard the boundary you receive JSON at with `isAdfDocument`; the message names the branch that refused | | `not-an-adf-document` | the value handed in is no ADF document — a missing or wrong `type`, a stray key, a node that is not a node, an object in the document holding itself | guard the boundary you receive JSON at with `isAdfDocument`; the message names the branch that refused |
| `unsupported-document-version` | the document's `version` is not 1 | keep the ADF and pass the document over, or show it read-only; the version is the site's, not yours to change | | `unsupported-document-version` | the document's `version` is not 1 | keep the ADF and pass the document over, or show it read-only; the version is the site's, not yours to change |
Either direction. The emitter's own refusals are in this group — a parse reaches them by asking it Either direction. The emitter's own refusals are in this group — a parse reaches them by asking it
@@ -195,24 +196,27 @@ emit refuses:
| Code | Fires when | What you can do | | Code | Fires when | What you can do |
| --- | --- | --- | | --- | --- | --- |
| `unspellable-character` | text or a code block holds a carriage return or a null character, which CommonMark rewrites wherever it sits | strip or replace the character; no escape carries it through the round-trip |
| `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span |
| `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines |
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | | `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body | | `unsupported-node-shape` | parsing: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node carrying one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content` | fix what the message names; `spec/flavour.md` lists every type's attributes and body |
## The guarantees ## The guarantees
- `markdownToAdf(adfToMarkdown(doc))` equals `doc` — unknown node types included, carried opaquely Serves Goals 1, 4 and 5.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as JSON, for a document of plain objects
as `JSON.parse` builds them — every key and value as `doc` holds it, adjacent text nodes, an
empty `attrs`, `content` or `marks` and `-0` included, and unknown node types carried opaquely
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)). ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
- Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from
`0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
name every exception.
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html` - Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
names, with three carve-outs — literal text matching directive, pipe-table or strikethrough names, with four carve-outs — literal text matching directive, pipe-table or strikethrough syntax,
syntax is claimed (escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as and a code fence whose info string opens `adf:`, are claimed (each can be kept literal —
its own title-less paragraph; mid-text and titled images are error results, save an image inside `spec/flavour.md`) — and one gap: a CommonMark image fits only as its own title-less paragraph;
another's description, which flattens into the alt text. Converting back yields the library's mid-text and titled images are error results, save an image inside another's description, which
canonical spelling, which round-trips byte-identically — where it converts back at all: a parse flattens into the alt text. Converting back yields the library's canonical spelling, which
succeeding is no promise of that, so keep the source until the way back succeeds. round-trips byte-identically.
``` ` `` ` ``` reads cleanly and then refuses.
- Four CommonMark spellings parse without an error and build a document the reference - Four CommonMark spellings parse without an error and build a document the reference
implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's
empty link, a list continuing past a marker change stays one list against CommonMark's two, a empty link, a list continuing past a marker change stays one list against CommonMark's two, a
@@ -221,28 +225,28 @@ emit refuses:
text, which the spec requires and the reference itself breaks, nesting one `<a>` in the other. text, which the spec requires and the reference itself breaks, nesting one `<a>` in the other.
The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite
holds no example of the fourth. holds no example of the fourth.
- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding - Not every document converts back: `adfToMarkdown` refuses a document nesting past 500 levels, and
a carriage return, or a paragraph line beginning with a code span whose backticks read back as a the node shapes the `unsupported-node-shape` row lists. Show the refusal and keep the document
fence. Show the refusal and keep the document read-only; saving markdown you could not produce read-only; saving markdown you could not produce is the loss the round-trip exists to stop.
is the loss the round-trip exists to stop.
- The pipe table narrows GFM's twice: every row opens with a pipe, so GFM's bare form is an error - The pipe table narrows GFM's twice: every row opens with a pipe, so GFM's bare form is an error
result rather than the prose it reads as, and an alignment colon in the delimiter row is an result rather than the prose it reads as, and an alignment colon in the delimiter row is an
error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in
input. input.
- Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist - Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist
is the `taskList` directive — `plainMarkdownToAdf` turns the marker into a `taskList`. is the `taskList` directive — `plainMarkdownToAdf` turns the marker into a `taskList`.
- A document nested deeper than 500 levels is an error result, not a stack overflow. - A document nested deeper than 500 levels is an error result, not a stack overflow, and no call
loops forever on a string, or on a value `JSON.parse` or `structuredClone` builds.
- The emitted formats are semver surface - The emitted formats are semver surface
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#the-formats-are-api)). ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#the-formats-are-api)).
- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides - **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` deep-equals `doc`; fidelity HTML cannot express rides
`data-*` attributes. Foreign HTML maps a documented element set, which markdown's raw HTML reads `data-*` attributes. Foreign HTML maps a documented element set, which markdown's raw HTML reads
through as well, and a construct outside it is an error; well-formed HTML only — no tag-soup through as well, and a construct outside it is an error; well-formed HTML only — no tag-soup
recovery. recovery.
## The package ## The package
ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts` beside it. Serves Goal 8. ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts`
Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs under Node, beside it. Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs
Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint. under Node, Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
Contract: [`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#any-es2022-engine), §Any Contract: [`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#any-es2022-engine), §Any
ES2022 engine to §Public on npm. ES2022 engine to §Public on npm.
+5 -3
View File
@@ -1,16 +1,18 @@
try { try {
const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js') const { adfToMarkdown, isAdfDocument, markdownToAdf } = await import('/dist/index.js')
// WebDriver's JSON reads -0 back as 0, so a document crosses as JSON text with -0 tagged; run.js revives it.
const spelled = (result) => (result.ok ? { ok: true, value: JSON.stringify(result.value, (_, value) => (Object.is(value, -0) ? '\u0000-0' : value)) } : result)
window.convertCorpus = (corpus) => ({ window.convertCorpus = (corpus) => ({
errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })), errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })),
normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: markdownToAdf(markdown) })), normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: spelled(markdownToAdf(markdown)) })),
realPayloads: corpus.realPayloads.map(({ json, name }) => { realPayloads: corpus.realPayloads.map(({ json, name }) => {
const adf = JSON.parse(json) const adf = JSON.parse(json)
const emitted = adfToMarkdown(adf) const emitted = adfToMarkdown(adf)
return { emitted, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? markdownToAdf(emitted.value) : undefined } return { emitted, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(markdownToAdf(emitted.value)) : undefined }
}), }),
roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => { roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
const adf = JSON.parse(json) const adf = JSON.parse(json)
return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: markdownToAdf(markdown) } return { emitted: adfToMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: spelled(markdownToAdf(markdown)) }
}), }),
}) })
} catch (cause) { } catch (cause) {
+8 -4
View File
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict'
import { createServer } from 'node:http' import { createServer } from 'node:http'
import { extname, join } from 'node:path' import { extname, join } from 'node:path'
import { readFileSync, readdirSync } from 'node:fs' import { readFileSync, readdirSync } from 'node:fs'
import { toEditorNormal } from '../dist/adf/editor-normal.js'
const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' } const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' }
const driver = 'http://127.0.0.1:4444' const driver = 'http://127.0.0.1:4444'
@@ -50,6 +49,11 @@ function fixtureNames(kind, extension) {
.sort() .sort()
} }
// convert-corpus.js tags -0 so it survives WebDriver's JSON.
function revived(text) {
return JSON.parse(text, (_, value) => (value === '\u0000-0' ? -0 : value))
}
function refusal(result) { function refusal(result) {
return result.ok ? '' : `${result.error.code}: ${result.error.message}` return result.ok ? '' : `${result.error.code}: ${result.error.message}`
} }
@@ -107,7 +111,7 @@ for (const [index, { json, markdown, name }] of corpus.roundTrip.entries()) {
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`) assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
assert.equal(result.emitted.value, markdown) assert.equal(result.emitted.value, markdown)
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`) assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(json)) assert.deepEqual(revived(result.parsed.value), JSON.parse(json))
}) })
} }
@@ -115,7 +119,7 @@ for (const [index, { name }] of corpus.normalization.entries()) {
const result = results.normalization[index] const result = results.normalization[index]
checking(name, result, () => { checking(name, result, () => {
assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`) assert.ok(result.parsed.ok, `it did not parse — ${refusal(result.parsed)}`)
assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(fixture(name, '.json'))) assert.deepEqual(revived(result.parsed.value), JSON.parse(fixture(name, '.json')))
}) })
} }
@@ -125,7 +129,7 @@ for (const [index, { json, name }] of corpus.realPayloads.entries()) {
assert.ok(result.isDocument, `${name}.json is no ADF document`) assert.ok(result.isDocument, `${name}.json is no ADF document`)
assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`) assert.ok(result.emitted.ok, `it did not emit — ${refusal(result.emitted)}`)
assert.ok(result.parsed.ok, `it did not parse back — ${refusal(result.parsed)}`) assert.ok(result.parsed.ok, `it did not parse back — ${refusal(result.parsed)}`)
assert.deepEqual(toEditorNormal(result.parsed.value), JSON.parse(json)) assert.deepEqual(revived(result.parsed.value), JSON.parse(json))
}) })
} }
+8 -8
View File
@@ -1,6 +1,6 @@
# The corpus # The corpus
One directory per contract kind, each landing with its milestone: One directory per contract kind:
- `round-trip/` — `<name>.json` + `<name>.md`: the markdown `adfToMarkdown` must emit for that - `round-trip/` — `<name>.json` + `<name>.md`: the markdown `adfToMarkdown` must emit for that
document, byte for byte, and that `markdownToAdf` must read back to it (`docs/decisions.md` §The document, byte for byte, and that `markdownToAdf` must read back to it (`docs/decisions.md` §The
@@ -16,11 +16,11 @@ One directory per contract kind, each landing with its milestone:
is the suite; `refusals.json` pins each refusing example to its error `code`; `exceptions.json` is the suite; `refusals.json` pins each refusing example to its error `code`; `exceptions.json`
pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a
`reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node `reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node
model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap a later model) or `pending` (a parser gap).
milestone may close).
JSON is editor-normal (`docs/decisions.md` §Equality is editor-normal), two-space indent, keys JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's
sorted. `spec.json` is the vendored, upstream machine-readable suite, byte-exact from (`docs/decisions.md` §Equality is deep). `spec.json` is the vendored, upstream machine-readable
[spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John suite, byte-exact from [spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json)
MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not (CommonMark 0.31.2, © John MacFarlane,
re-serialized by the corpus gate. [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not re-serialized by the
corpus gate.
-21
View File
@@ -13,27 +13,6 @@
"kind": "pending", "kind": "pending",
"reason": "A list continuing past a marker change renders as two lists, the parser opens one." "reason": "A list continuing past a marker change renders as two lists, the parser opens one."
}, },
{
"check": "fixpoint",
"divergence": "unspellable-line-start",
"example": 330,
"kind": "unspellable",
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
},
{
"check": "fixpoint",
"divergence": "unspellable-line-start",
"example": 331,
"kind": "unspellable",
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
},
{
"check": "fixpoint",
"divergence": "unspellable-line-start",
"example": 340,
"kind": "unspellable",
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
},
{ {
"check": "count", "check": "count",
"divergence": "em 2/1", "divergence": "em 2/1",
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,5 @@
```adf:
{
"type": "blockCard"
}
```
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,3 @@
```adf:\\
{}
```
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+5
View File
@@ -0,0 +1,5 @@
```adf:blockCard
{
"type": "blockCard"
}
```
+2 -2
View File
@@ -1,3 +1,3 @@
```carry ```adf:blockCard
{"type": {"attrs":
``` ```
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,8 @@
!adf:codeBlock {attrs=empty}
```js
a
```
```js
b
```
!adf:/codeBlock
@@ -0,0 +1 @@
unsupported-node-shape
+7
View File
@@ -0,0 +1,7 @@
!adf:codeBlock
```
a
```
```
```
!adf:/codeBlock
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,8 @@
!adf:codeBlock
```js
a
```
```ts
b
```
!adf:/codeBlock
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1 @@
!adf:status[!adf:carry{json="{\"marks\":[],\"text\":\"x\",\"type\":\"text\"}"}]
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
!adf:doc
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,3 @@
!adf:doc {content=none}
Text.
@@ -0,0 +1 @@
unsupported-node-shape
@@ -0,0 +1,3 @@
!adf:panel info {attrs=empty}
Text.
!adf:/panel
@@ -0,0 +1 @@
unsupported-node-shape
+3
View File
@@ -0,0 +1,3 @@
!adf:paragraph {content=empty}
Text.
!adf:/paragraph
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+3
View File
@@ -0,0 +1,3 @@
!adf:paragraph {attrs=none}
Text.
!adf:/paragraph
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
**!adf:date{marks=empty}**
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
a!adf:textBreak{x=y}b
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
a!adf:textBreak[x]b
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
**a**!adf:textBreak{}b
+1
View File
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
!adf:textBreak{}Hello
@@ -0,0 +1,15 @@
{
"content": [
{
"content": [
{
"text": "a\r\nb",
"type": "text"
}
],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,10 @@
```adf:codeBlock
{
"content": [
{
"text": "a\r\nb",
"type": "text"
}
]
}
```
@@ -14,7 +14,19 @@
}, },
{ {
"attrs": { "attrs": {
"language": "carry" "language": "adf:blockCard"
},
"content": [
{
"text": "{\n \"attrs\": {}\n}",
"type": "text"
}
],
"type": "codeBlock"
},
{
"attrs": {
"language": "adf:"
}, },
"content": [ "content": [
{ {
@@ -1,12 +1,18 @@
!adf:codeBlock {language=carry} ```carry
```
{ {
"type": "blockCard" "type": "blockCard"
} }
``` ```
!adf:codeBlock {language="adf:blockCard"}
```
{
"attrs": {}
}
```
!adf:/codeBlock !adf:/codeBlock
!adf:codeBlock {language=carry} !adf:codeBlock {language="adf:"}
```` ````
``` ```
```` ````
@@ -0,0 +1,56 @@
{
"content": [
{
"attrs": {
"language": "js"
},
"content": [
{
"text": "const a = 1\n",
"type": "text"
},
{
"text": "const b = 2",
"type": "text"
}
],
"type": "codeBlock"
},
{
"content": [
{
"text": "a",
"type": "text"
},
{
"text": "```",
"type": "text"
},
{
"text": "\n",
"type": "text"
}
],
"type": "codeBlock"
},
{
"attrs": {
"language": "has`tick",
"wrap": true
},
"content": [
{
"text": "x",
"type": "text"
},
{
"text": " y ",
"type": "text"
}
],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,31 @@
!adf:codeBlock
```js
const a = 1
```
```js
const b = 2
```
!adf:/codeBlock
!adf:codeBlock
```
a
```
````
```
````
```
```
!adf:/codeBlock
!adf:codeBlock {language="has\u0060tick" wrap=true}
```
x
```
```
y
```
!adf:/codeBlock
@@ -0,0 +1,4 @@
{
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
!adf:doc {content=none}
@@ -0,0 +1,96 @@
{
"content": [
{
"content": [],
"type": "paragraph"
},
{
"attrs": {},
"content": [
{
"text": "Plain words.",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [],
"type": "rule"
},
{
"attrs": {
"level": 2
},
"content": [
{
"text": "Title",
"type": "text"
}
],
"marks": [],
"type": "heading"
},
{
"attrs": {
"panelType": "info"
},
"content": [],
"type": "panel"
},
{
"content": [
{
"attrs": {},
"content": [
{
"content": [
{
"text": "Item",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
},
{
"content": [
{
"content": [
{
"text": "Next",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "bulletList"
},
{
"content": [],
"type": "codeBlock"
},
{
"attrs": {
"language": "js"
},
"marks": [],
"type": "codeBlock"
},
{
"attrs": {
"language": "js"
},
"content": [],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,35 @@
!adf:paragraph {content=empty}
!adf:/paragraph
!adf:paragraph {attrs=empty}
Plain words.
!adf:/paragraph
!adf:rule {content=empty}
!adf:heading {level=2 marks=empty}
Title
!adf:/heading
!adf:panel info {content=empty}
!adf:/panel
!adf:bulletList
!adf:listItem {attrs=empty}
Item
!adf:/listItem
!adf:listItem
Next
!adf:/listItem
!adf:/bulletList
!adf:codeBlock {content=empty}
!adf:/codeBlock
!adf:codeBlock {marks=empty}
```js
```
!adf:/codeBlock
!adf:codeBlock {content=empty language=js}
!adf:/codeBlock
@@ -1,4 +1,4 @@
```carry ```adf:panel
{ {
"attrs": { "attrs": {
"rounded": true "rounded": true
@@ -13,12 +13,11 @@
], ],
"type": "paragraph" "type": "paragraph"
} }
], ]
"type": "panel"
} }
``` ```
```carry ```adf:panel
{ {
"attrs": { "attrs": {
"panelType": "extra info" "panelType": "extra info"
@@ -33,8 +32,7 @@
], ],
"type": "paragraph" "type": "paragraph"
} }
], ]
"type": "panel"
} }
``` ```
@@ -0,0 +1,83 @@
{
"content": [
{
"attrs": {
"order": -0
},
"content": [
{
"content": [
{
"content": [
{
"text": "First",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "listItem"
}
],
"type": "orderedList"
},
{
"attrs": {
"level": -0
},
"content": [
{
"text": "Title",
"type": "text"
}
],
"type": "heading"
},
{
"attrs": {
"extensionKey": "toc",
"extensionType": "com.atlassian.confluence.macro.core",
"parameters": {
"maxLevel": -0
}
},
"type": "extension"
},
{
"content": [
{
"attrs": {
"data": [
-0
],
"url": "https://example.com/"
},
"type": "inlineCard"
},
{
"marks": [
{
"attrs": {
"color": "#000000",
"size": -0
},
"type": "border"
}
],
"text": "x",
"type": "text"
}
],
"type": "paragraph"
},
{
"attrs": {
"x": -0
},
"type": "blockCard"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,21 @@
!adf:orderedList {order=-0}
!adf:listItem
First
!adf:/listItem
!adf:/orderedList
!adf:heading {level=-0}
Title
!adf:/heading
!adf:extension {extensionKey=toc extensionType="com.atlassian.confluence.macro.core" parameters="{\"maxLevel\":-0}"}
!adf:inlineCard{data="[-0]" url="https://example.com/"}!adf:border[x]{color="#000000" size=-0}
```adf:blockCard
{
"attrs": {
"x": -0
}
}
```
@@ -0,0 +1,20 @@
{
"content": [
{
"content": [
{
"marks": [
{
"type": "code"
}
],
"text": "``",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
``` `` ```
@@ -1,4 +1,5 @@
{ {
"content": [],
"type": "doc", "type": "doc",
"version": 1 "version": 1
} }
@@ -0,0 +1,98 @@
{
"content": [
{
"content": [
{
"attrs": {},
"type": "date"
},
{
"text": " ",
"type": "text"
},
{
"content": [],
"type": "date"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "a",
"type": "text"
},
{
"marks": [],
"type": "hardBreak"
},
{
"text": "b",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"attrs": {
"id": "x",
"text": "@M"
},
"content": [],
"type": "mention"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "bare",
"type": "text"
},
{
"marks": [],
"text": "marked",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"attrs": {},
"type": "em"
}
],
"text": "x",
"type": "text"
},
{
"text": " and ",
"type": "text"
},
{
"attrs": {
"shortName": ":a:"
},
"marks": [
{
"attrs": {},
"type": "strong"
}
],
"type": "emoji"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,9 @@
!adf:date{attrs=empty} !adf:date{content=empty}
a!adf:hardBreak{marks=empty}b
!adf:mention[@M]{content=empty id=x}
bare!adf:carry{json="{\"marks\":[],\"text\":\"marked\",\"type\":\"text\"}"}
!adf:carry{json="{\"marks\":[{\"attrs\":{},\"type\":\"em\"}],\"text\":\"x\",\"type\":\"text\"}"} and !adf:carry{json="{\"attrs\":{\"shortName\":\":a:\"},\"marks\":[{\"attrs\":{},\"type\":\"strong\"}],\"type\":\"emoji\"}"}
@@ -0,0 +1,154 @@
{
"content": [
{
"content": [
{
"text": "Hello, ",
"type": "text"
},
{
"text": "world",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "strong"
}
],
"text": "Hello, ",
"type": "text"
},
{
"marks": [
{
"type": "strong"
}
],
"text": "world",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "code"
}
],
"text": "a",
"type": "text"
},
{
"marks": [
{
"type": "code"
}
],
"text": "b",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"attrs": {
"href": "https://example.com/"
},
"type": "link"
}
],
"text": "a",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/"
},
"type": "link"
}
],
"text": "b",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "Line\n",
"type": "text"
},
{
"text": "next",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "underline"
}
],
"text": "a",
"type": "text"
},
{
"marks": [
{
"type": "underline"
}
],
"text": "b",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"attrs": {},
"type": "underline"
}
],
"text": "a",
"type": "text"
},
{
"marks": [
{
"type": "underline"
}
],
"text": "b",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,13 @@
Hello, !adf:textBreak{}world
**Hello, !adf:textBreak{}world**
`a`!adf:textBreak{}`b`
[a!adf:textBreak{}b](https://example.com/)
Line!adf:text{text="\n"}!adf:textBreak{}next
!adf:underline[a!adf:textBreak{}b]
!adf:underline[a]{attrs=empty}!adf:underline[b]
@@ -0,0 +1,43 @@
{
"content": [
{
"content": [
{
"text": "a\r\nb\u0000c",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/",
"title": "t\u0000"
},
"type": "link"
}
],
"text": "link",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"attrs": {
"color": "neutral",
"text": "a\nb"
},
"type": "status"
},
{
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,3 @@
a&#13;!adf:text{text="\n"}b!adf:text{text="\u0000"}c!adf:link[link]{href="https://example.com/" title="t\u0000"}
!adf:carry{json="{\"attrs\":{\"color\":\"neutral\",\"text\":\"a\\nb\"},\"type\":\"status\"}"}!adf:carry{json="{\"type\":\"text\"}"}
@@ -1,17 +1,15 @@
> ```carry > ```adf:blockCard
> { > {
> "attrs": { > "attrs": {
> "url": "https://example.com/quoted" > "url": "https://example.com/quoted"
> }, > }
> "type": "blockCard"
> } > }
> ``` > ```
- ```carry - ```adf:blockCard
{ {
"attrs": { "attrs": {
"url": "https://example.com/listed" "url": "https://example.com/listed"
}, }
"type": "blockCard"
} }
``` ```
@@ -0,0 +1,35 @@
{
"content": [
{
"attrs": {
"language": "js"
},
"content": [
{
"marks": [
{
"type": "strong"
}
],
"text": "const a = 1",
"type": "text"
}
],
"type": "codeBlock"
},
{
"content": [
{
"text": "a",
"type": "text"
},
{
"type": "hardBreak"
}
],
"type": "codeBlock"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,32 @@
```adf:codeBlock
{
"attrs": {
"language": "js"
},
"content": [
{
"marks": [
{
"type": "strong"
}
],
"text": "const a = 1",
"type": "text"
}
]
}
```
```adf:codeBlock
{
"content": [
{
"text": "a",
"type": "text"
},
{
"type": "hardBreak"
}
]
}
```
@@ -1,23 +1,21 @@
```carry ```adf:blockCard
{ {
"attrs": { "attrs": {
"url": "https://example.com/roadmap" "url": "https://example.com/roadmap"
}, }
"type": "blockCard"
} }
``` ```
!adf:panel info !adf:panel info
The card below has no spelling yet. The card below has no spelling yet.
```carry ```adf:embedCard
{ {
"attrs": { "attrs": {
"layout": "wide", "layout": "wide",
"url": "https://example.com/board", "url": "https://example.com/board",
"width": 100 "width": 100
}, }
"type": "embedCard"
} }
``` ```
!adf:/panel !adf:/panel
@@ -0,0 +1,21 @@
{
"content": [
{
"attrs": {
"url": "https://example.com/"
},
"type": "has`tick"
},
{
"type": ""
},
{
"type": " padded"
},
{
"type": "two words"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,24 @@
```adf:
{
"attrs": {
"url": "https://example.com/"
},
"type": "has`tick"
}
```
```adf:
{
"type": ""
}
```
```adf:
{
"type": " padded"
}
```
```adf:two words
{}
```
+270 -85
View File
@@ -11,61 +11,142 @@ a backslash reach them intact; what the flavour cannot spell reduces ADF→ADF a
## The round-trip is the product ## The round-trip is the product
2026-08-23, the maintainer. Goal 1. Valid while a consumer saves back through the lossless pair. 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. less silently destroys content an editor could not represent, in a document it did not author.
When losslessness and readability conflict, losslessness wins. Round-trip equality is a property When losslessness and readability conflict, losslessness wins. Round-trip equality is a property
tested over a checked-in corpus (`corpus/README.md`), not a claim made in prose. tested over a checked-in corpus (`corpus/README.md`), not a claim made in prose. Its real payloads
are invented content written in Atlassian's editor on the maintainer's test site, so none is
sanitized and a mention keeps the test user's real account id.
## Markdown in is a canonical fixpoint ## Markdown in is a canonical fixpoint
2026-08-23, the maintainer. Goals 1 and 3. Valid while markdown input may be written by hand. 2026-08-23, the maintainer; every parsed document has converted back since 2026-10-04. Goals 1 and
5. Valid while markdown input may be written by hand.
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
back yields the library's canonical spelling, and that spelling round-trips byte-identically — back yields the library's canonical spelling, and that spelling round-trips byte-identically.
where there is a way back. CommonMark spells some things the flavour has no escape for — a
paragraph opening with a code span whose backticks read back as a fence — so a parse succeeding
does not imply a spellable document; `corpus/commonmark-spec/exceptions.json` names those.
## Equality is editor-normal ## Equality is deep
2026-08-24, the maintainer. Goal 1. Valid while markdown cannot tell apart the ADF shapes this 2026-08-24, deep 2026-10-03, the maintainer. Goal 1. Valid while a pipeline or a bot can build a
merges. shape the editor would not.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks "Equals" is deep equality over the document's JSON values: `assert.deepStrictEqual` on plain objects
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content as `JSON.parse` builds them, since ADF is JSON. Every key and value in `doc` counts, including two
array the absent key — the only domain markdown can restore. adjacent text nodes, an empty `attrs`, `content` or `marks`, and `-0`. Neither side is normalized.
CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader builds
what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction reads and
writes editor-normal ADF — adjacent text nodes of identical marks and no attributes merged, `-0` as
`0`, and an empty `attrs`, `content` or `marks` the absent key, except the doc's `content`, which
ADF's schema requires — so two documents the editor holds equal write the same plain markdown.
## `!adf:textBreak{}` parts text CommonMark would join
2026-10-03, the maintainer. Goal 1. Valid while CommonMark reads adjacent text as one run.
Two adjacent text nodes CommonMark would read back as one are parted by the reserved inline leaf
`!adf:textBreak{}`, mirroring `!adf:listBreak`: a leaf building no node keeps both nodes and asks
nothing of the text around it. A code span holds no directive, so the spans close and reopen
around the leaf. The grammar: `spec/flavour.md` §Inline nodes, **Adjacent text nodes**.
## An empty key spells `empty`
2026-10-03, a writer panel and the maintainer. Goals 1 and 6. Valid while no attribute value
spells an empty object or array.
An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the
bare value `empty`, so a container opener and closer with nothing between them stays the node
holding no `content` key. A writer panel chose the spelling, 5 of 7. The grammar: `spec/flavour.md`
§Directives, **Attributes**, and §Marks.
## `-0` is spelled `-0`
2026-10-03, the maintainer. Goal 1. Valid while JSON's own serialization writes `-0` as `0`.
`-0` is spelled `-0` wherever the flavour writes a number or a JSON value, since JSON's grammar
reads it back as `-0`. The grammar: `spec/flavour.md` §Block nodes and §The CommonMark blocks.
## Empty markdown is a document of no blocks
2026-10-03, a writer panel and the maintainer. Goals 1 and 6. Valid while ADF's schema requires
`content` on `doc`.
Markdown holding no block reads as `{ content: [], type: 'doc', version: 1 }`, the document
`spec/adf-schema/full.json` requires. A document holding no `content` key is
`!adf:doc {content=none}` as its only block, and a named error anywhere else. The writer panel split
4 for `none` and 3 for `absent`, and the maintainer chose `none`; all seven rejected a bare
`!adf:doc`.
## Unknown nodes ride the carry ## Unknown nodes ride the carry
2026-08-23, extended to misplaced known nodes 2026-08-26, the maintainer. Goal 1. Valid while ADF 2026-08-23, extended to misplaced known nodes 2026-08-26 and to code block children 2026-10-03, the
holds nodes, or node positions, this library does not spell. maintainer. Goal 1. Valid while ADF holds nodes, or node positions, this library does not spell.
An unknown ADF node is carried opaquely — raw JSON rides a dedicated syntax in both formats and An unknown ADF node is carried opaquely — raw JSON rides a dedicated syntax in both formats and
restores to a deep-equal node. The round-trip holds for documents newer than the library. So does restores to a deep-equal node. The round-trip holds for documents newer than the library. So does
a known node no section spells where it stands: a markdown serializer spells a node by type without a known node no section spells where it stands: a markdown serializer spells a node by type without
checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`. checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`. A
Where a container's own spelling cannot hold the child it has — a `bulletList` outside `listItem`, `codeBlock` holding a child no fence holds — anything but a text node carrying no marks, `attrs` or
a `codeBlock` outside text — the error result names that instead. `content` — rides the carry whole.
## Foreign HTML is refused by name ## The carry fence names the node type
2026-08-23, the maintainer. Goals 1 and 6. Valid until the HTML dialect's element set lands 2026-10-03, the maintainer. Goals 1 and 6. Valid while a code fence's info string reads back
(`todo.md`, 6). verbatim.
An unmappable foreign HTML element is an error result naming the element — never a silent drop. The block carry is a code fence whose info string `adf:<type>` names the node's type, its body the
node's JSON without `type`: ```` ```adf:blockCard ````. A type no info string carries back — by the
rule a code language follows — leaves the info string `adf:` and keeps `type` in the body. Every
info string opening `adf:` is reserved, so a `codeBlock` whose language opens so takes the
`language` attribute, and `carry` is an ordinary language. A body holding `type` under a named type,
or a fence whose info string is `adf:` alone while its body's `type` could be spelled in the info
string, is `unsupported-node-shape`. The reservation claims a fence CommonMark reads as code until
`todo.md` item 43 gives CommonMark its own reader.
## A code block is a fence per text node
2026-10-03, the maintainer. Goal 1. Valid while ADF holds a code block's text in more than one
node.
A `codeBlock` holding several text nodes is the `!adf:codeBlock` container holding one fence per
node, so each node keeps its own text. The fences carry one info string, since ADF holds one
language, and none is empty beside another, since a text node holds text. The grammar:
`spec/flavour.md` §The CommonMark blocks, the `codeBlock` bullet.
## Foreign HTML sorts three ways
2026-08-23, the sort 2026-09-20, the maintainer. Goals 1 and 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:
- A container around document content that ADF has no node for unwraps to its children, its own
attributes dropped: `<div align="center">text</div>` keeps `text`, losing the alignment.
- Content ADF cannot hold is an error result naming it. A comment is one: a person wrote those
words, and neither of Atlassian's schemas holds them — `annotation`'s `inlineComment` carries an
id, `placeholder` is the editor's own hint, `extension` names a vendor app.
- What is not document content drops whole: `<script>` and `<style>`, their text with them.
`<details><summary>Title</summary>…</details>` is an `expand` titled by its summary, a
`nestedExpand` inside another; an empty one is refused, since `expand` requires content. A `style`
attribute is not read at `0.2.0`: the `textColor` and `backgroundColor` it could reach cost more
than they buy.
`plainMarkdownToAdf` reads through `markdownToAdf`'s parser, so it takes the same set.
## Names stay text ## Names stay text
2026-08-23, the maintainer. Goal 7. Valid while resolving a name to an id needs I/O. 2026-08-23, the maintainer. Goal 8. Valid while resolving a name to an id needs I/O.
A bare `@name` or `:smile:` in typed text stays a text node. Only directives produce A bare `@name` or `:smile:` in typed text stays a text node. Only directives produce
mention/emoji/media nodes; resolving names to ids is the consumer's job. mention/emoji/media nodes; resolving names to ids is the consumer's job.
## Directives under `!adf:` ## Directives under `!adf:`
2026-08-23, prefixed `!adf:` 2026-09-16, the maintainer. Goals 3 and 4. Valid while prose does not 2026-08-23, prefixed `!adf:` 2026-09-16, the maintainer. Goals 5 and 6. Valid while prose does not
write `!adf:`. write `!adf:`.
Directives are one grammar for everything markdown lacks, namespaced under `!adf:`: Directives are one grammar for everything markdown lacks, namespaced under `!adf:`:
@@ -76,21 +157,23 @@ opener nests by itself and leaf versus container falls out of the node's content
## CommonMark is a subset ## CommonMark is a subset
2026-08-23, the maintainer. Goal 3. Valid while prose rarely writes the shapes the carve-outs claim. 2026-08-23, the maintainer. Goal 5. Valid while prose rarely writes the shapes the carve-outs claim.
Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a
directive, a pipe table or a `~~` pair is claimed — plus one image gap. directive, a pipe table or a `~~` pair is claimed, and so is a code fence whose info string opens
`adf:` (§The carry fence names the node type) — plus one image gap. `todo.md` item 43 ends the
claims, giving CommonMark its own reader, and `todo.md` item 65 ends the image gap.
## Tables ## Tables
2026-08-23, the maintainer. Goal 4. Valid while a pipe table holds only one header row and inline 2026-08-23, the maintainer. Goal 6. Valid while a pipe table holds only one header row and inline
cells. cells.
One header row plus plain inline cells → pipe table; anything richer → directive form. One header row plus plain inline cells → pipe table; anything richer → directive form.
## Links ## Links
2026-09-13, nesting 2026-09-17, the maintainer. Goals 1 and 4. Valid while CommonMark's link 2026-09-13, nesting 2026-09-17, the maintainer. Goals 1 and 6. Valid while CommonMark's link
syntax is what readers edit. syntax is what readers edit.
`[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark spells the `[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark spells the
@@ -107,9 +190,49 @@ where its reference implementation nests one `<a>` in another.
Identity-bearing nodes carry their ids in attributes; a document is only portable within its site — Identity-bearing nodes carry their ids in attributes; a document is only portable within its site —
accepted. accepted.
## Plain task ids come from position
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` 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 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, the `==` bounds
(3 of 3) and a Han, Hangul, kana, Thai, Lao, Khmer or Myanmar character on either side bounding a
delimiter, so `は==日本語==で` (3 of 3), `==한국어==에서만` and `iPhone==専用==` (6 of 7) highlight,
the external image's two forms (6 of 7), a rule opening a list item dropping and the omission
notes (3 of 3), and a list's numbering overflowing into bullets (3 of 3, 5 of 7).
An omission note reads as the converter's, never as the author's.
Reading takes other tools' spellings, since it reads their output and writes none of them.
Rejected: `~sub~` and `^sup^` (`~2~` is a strike on GitHub, so `subsup` drops), underline and colour
spellings, raw HTML (`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family,
footnotes, definition lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states
past `[x]`/`[ ]`, and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes.
## The HTML dialect ## The HTML dialect
2026-08-23, the maintainer. Goals 4 and 7. Valid while HTML output is read by consumers styling it 2026-08-23, the maintainer. Goals 6 and 8. Valid while HTML output is read by consumers styling it
themselves. themselves.
The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*` The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*`
@@ -117,7 +240,7 @@ for what HTML cannot express, text always escaped. No stylesheet ships.
## No runtime dependencies ## No runtime dependencies
2026-08-23, the maintainer. Goal 7. Valid while ~20 lines of own code, or a vendored table, do each 2026-08-23, the maintainer. Goal 8. Valid while ~20 lines of own code, or a vendored table, do each
job a dependency would. job a dependency would.
`dependencies` is empty. A runtime dependency enters only through an entry here stating why ~20 `dependencies` is empty. A runtime dependency enters only through an entry here stating why ~20
@@ -127,7 +250,7 @@ and HTML parsers are written in this repo.
## Standards ship as data ## Standards ship as data
2026-08-30, the CommonMark suite 2026-09-05 and ADF's schemas 2026-09-13, the maintainer. Goals 1, 2026-08-30, the CommonMark suite 2026-09-05 and ADF's schemas 2026-09-13, the maintainer. Goals 1,
3 and 7. Valid while each table is fixed data a dependency would only wrap. 5 and 8. Valid while each table is fixed data a dependency would only wrap.
A table a standard fixes is data rather than a dependency: HTML5's 2125 semicolon-terminated A table a standard fixes is data rather than a dependency: HTML5's 2125 semicolon-terminated
character references ship packed in their own module, so entity decoding is complete without one. character references ship packed in their own module, so entity decoding is complete without one.
@@ -150,7 +273,7 @@ nodes that break it.
## Any ES2022 engine ## Any ES2022 engine
2026-09-01, the maintainer. Goal 7. Valid while ES2022 is the floor browsers and servers share. 2026-09-01, the maintainer. Goal 8. Valid while ES2022 is the floor browsers and servers share.
The library runs on any ES2022 engine, not only Node — a browser as readily as a server. The The library runs on any ES2022 engine, not only Node — a browser as readily as a server. The
shipped source is ECMAScript and nothing else: no host import, no host global, no DOM. shipped source is ECMAScript and nothing else: no host import, no host global, no DOM.
@@ -164,13 +287,13 @@ never the higher one those repo-only tools want.
## ESM only ## ESM only
2026-08-23, the maintainer. Goal 7. Valid while the audience's toolchains all import ES modules. 2026-08-23, the maintainer. Goal 8. Valid while the audience's toolchains all import ES modules.
No CommonJS build, no dual-package hazard. No CommonJS build, no dual-package hazard.
## One built entrypoint ## One built entrypoint
2026-08-23, the maintainer. Goal 7. Valid while Node refuses to type-strip under `node_modules`. 2026-08-23, the maintainer. Goal 8. Valid while Node refuses to type-strip under `node_modules`.
Built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint — Node refuses to Built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint — Node refuses to
type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so it cannot serve type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so it cannot serve
@@ -178,28 +301,31 @@ an npm consumer.
## Public on npm ## Public on npm
2026-08-23, the maintainer. Goal 7. Valid while the package's source stays public beside it. 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, Published to public npm as `@larvit/adf-codec`. Public source: the Gitea repo goes public,
LICENSE in place, before the first publish. LICENSE in place, before the first publish. A codec, since it converts both directions, and named
for the hub rather than the formats around it.
## The formats are API ## The formats are API
2026-08-23, content models 2026-09-16, the maintainer. Goals 1 and 6. Valid while consumers store 2026-08-23, strict input 2026-09-01, content models 2026-09-16, the maintainer. Goal 1.
what the library emits. Valid while consumers store what the library emits.
The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing
differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR. differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR.
Pre-1.0, normal 0.x rules. A spelled node's content model is part of that contract — leaf or Pre-1.0, normal 0.x rules. A spelled node's content model is part of that contract — leaf or
container is the model, not the syntax — so giving a spelled node's model content it had not, or container is the model, not the syntax — so giving a spelled node's model content it had not, or
taking it away, is MAJOR whatever ADF's own schema does. taking it away, is MAJOR whatever ADF's own schema does. Input reads the canonical directive
spelling alone — spacing, key order, each value's spelling — since loosening it later is MINOR.
The error surface is a contract too; `README.md` §The errors states it to the consumer, and the The error surface is a contract too; `README.md` §The errors states it to the consumer, and the
types in `src/result.ts` hold its shape. types in `src/result.ts` hold its shape.
## The code list ## The code list
2026-08-25, the maintainer; dated below where a rule came later. Goal 6. Valid while a consumer 2026-08-25, the maintainer; dated below where a rule came later. Goal 1. Valid while a consumer
switches on `code` with no `default`. switches on `code` with no `default`.
- Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose - Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose
@@ -210,34 +336,35 @@ switches on `code` with no `default`.
`unsupported-node-shape` — since a code no input reaches is one no consumer can switch on `unsupported-node-shape` — since a code no input reaches is one no consumer can switch on
(2026-09-20). (2026-09-20).
- A refusal no spelling recovers from is a gap in the flavour rather than a code: give the flavour - A refusal no spelling recovers from is a gap in the flavour rather than a code: give the flavour
the spelling and the code goes (`unspellable-link`, 2026-09-13). A cause the carry answers gets the spelling and the code goes (`unspellable-link`, 2026-09-13; `unspellable-character` and
no code: a mark no spelling writes rides the carry with its node. `unspellable-line-start`, 2026-10-04). A cause the carry answers gets no code: a mark no
spelling writes rides the carry with its node.
## Which code a cause takes ## Which code a cause takes
2026-08-28, the maintainer; dated below where a rule came later. Goal 6. Valid while a consumer 2026-08-28, the maintainer; dated below where a rule came later. Goal 1. Valid while a consumer
handles one cause alike whichever node, attribute or direction raised it. handles one cause alike whichever node, attribute or direction raised it.
- A code names the cause; where one cause recurs across node types, across one mark's attributes - A code names the cause; where one cause recurs across node types, across one mark's attributes
or across directions, one code covers them all and `path` and `message` say which — or across directions, one code covers them all and `path` and `message` say which —
`unsupported-nesting-depth` is the 500-level guard whichever direction hits it, `unsupported-nesting-depth` is the 500-level guard whichever direction hits it.
`unspellable-character` the text node and the code block alike. Where two codes stay apart, the `unspellable-whitespace` is the newline an inline directive's content slot spells in input; the
line between them is what they name: `unspellable-character` is a character CommonMark rewrites emitter carries such a node. A null character in that slot is `unsupported-node-shape`, since
wherever text holds it, `unspellable-whitespace` the newline no inline directive's content slot `unspellable-whitespace` does not read true of it (2026-10-04).
spans, in either direction.
- A claim code names the spelling claimed, never the node that spelling would have built: a - A claim code names the spelling claimed, never the node that spelling would have built: a
malformed `!adf:table` is a `malformed-directive`, and an alignment colon a malformed `!adf:table` is a `malformed-directive`, and an alignment colon a
`malformed-pipe-table` — the flavour's own delimiter row is `-` runs, so the grammar refuses the `malformed-pipe-table` — the flavour's own delimiter row is `-` runs, so the grammar refuses the
colon rather than ADF's missing column model doing it. What the grammar itself refuses stays a colon rather than ADF's missing column model doing it. What the grammar itself refuses stays a
claim code, key order among it, and a leaf given a body is refused at its opener, as a container claim code, key order among it, and a leaf given a body is refused at its opener, as a container
missing its closer is (2026-09-16). missing its closer is (2026-09-16).
- A directive whose name reads back to no node is `unknown-directive-name` rather than a claim - A directive whose name reads back to no node is `unknown-directive-name` rather than a claim code
code — the spelling is well formed, and telling that apart from a typo is what a consumer — the spelling is well formed, and telling that apart from a typo is what a consumer switches on
switches on when a later MINOR gives the name meaning. A reserved name is a known name, so never when a later MINOR gives the name meaning. A reserved name is a known name, so never that code,
that code, and the two the flavour reserves part on form: a form the grammar does not have is a and the names the flavour reserves part on form: a form the grammar does not have is a claim code
claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place is
is `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one type,
type (2026-09-01). `!adf:textBreak{}` anything but two adjacent text nodes CommonMark would read back as one,
`!adf:doc` standing beside another block (2026-09-01, the text break and `doc` 2026-10-03).
- A well-formed directive the node tables refuse — an attribute a node does not hold or spells - A well-formed directive the node tables refuse — an attribute a node does not hold or spells
elsewhere, a value outside its kind or its canonical spelling, an argument, or a body of a shape elsewhere, a value outside its kind or its canonical spelling, an argument, or a body of a shape
its content model does not take — is `unsupported-node-shape`, the emitter's code for the same its content model does not take — is `unsupported-node-shape`, the emitter's code for the same
@@ -248,21 +375,21 @@ handles one cause alike whichever node, attribute or direction raised it.
## `message` and `path` ## `message` and `path`
2026-09-03, the path 2026-09-23, the maintainer. Goals 3 and 6. Valid while a person fixing the 2026-09-03, the path 2026-09-23, the maintainer. Goals 1 and 5. Valid while a person fixing the
input reads `message`. input reads `message`.
- A message names the violation, not the rule alone — a rule by itself states a truth the reader - A message names the violation, not the rule alone — a rule by itself states a truth the reader
must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose
it names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and it names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and
inline alike, `\|` for every pipe row. inline alike, `\|` for every pipe row.
- `not-an-adf-document` carries the document's own path throughout: seven of the guard's eight - `not-an-adf-document` carries the document's own path throughout: seven of the guard's nine
branches read the document's own shape, and threading a path to the eighth — a malformed node branches read the document's own shape, and threading a path to the other two, which look anywhere
anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces, whose empty in the tree — a malformed node, or an object holding itself — wants the manual stack §Nothing
half no input reaches. The message names the violation instead. recurses unbounded forces. The message names the violation instead.
## Publish on a version bump ## Publish on a version bump
2026-08-23, converging 2026-09-03, the maintainer. Goal 7. Valid while CI on `main` holds the npm 2026-08-23, converging 2026-09-03, the maintainer. Goal 8. Valid while CI on `main` holds the npm
token. token.
`package.json` version on `main` is the source of truth. CI on `main`: tests green and the version `package.json` version on `main` is the source of truth. CI on `main`: tests green and the version
@@ -278,7 +405,7 @@ depend on a store that the gate would then have to keep.
## Docs describe the release being built ## Docs describe the release being built
2026-09-16, the maintainer. Goal 7. Valid while a bump on `main` publishes. 2026-09-16, the maintainer. Goal 8. Valid while a bump on `main` publishes.
Docs on `main` describe the release being built rather than the version npm holds, so they match it Docs on `main` describe the release being built rather than the version npm holds, so they match it
the moment the bump publishes; add no interim note marking the gap. the moment the bump publishes; add no interim note marking the gap.
@@ -294,19 +421,19 @@ nesting a spelling inside its own kind (`*(*a*)*`) names that mark once.
## The gate runs on Deno and Bun ## The gate runs on Deno and Bun
2026-09-01, Deno's reason 2026-09-28, the maintainer. Goals 7 and 8. Valid while the library claims 2026-09-01, Deno's reason 2026-09-28, the maintainer. Goals 4 and 8. Valid while the library claims
any ES2022 engine. any ES2022 engine.
The gate runs the suite under Deno and Bun as well as Node. Bun runs JavaScriptCore, the one engine The gate runs the suite under Deno and Bun as well as Node. Bun runs JavaScriptCore, the one engine
of the three that is not V8, where the Unicode property escapes emphasis matching leans on can of the three that is not V8, where the Unicode property escapes emphasis matching leans on can
disagree. Deno shares Node's V8 and stays to prove the library runs there too, catching what the disagree. Deno shares Node's V8 and stays to prove the library runs there too, catching what the
two runtimes leave undocumented. Both refuse a run matching no test, so Node's is the only two runtimes leave undocumented. Both refuse a run matching no test, so Node's is the only
vacuous-green guard, and `AGENTS.md` §10's `node:` shims rule is the price of proving those engines vacuous-green guard, and `AGENTS.md` §3's `node:` shims rule is the price of proving those engines
over the corpus rather than over a smoke import. over the corpus rather than over a smoke import.
## The gate installs the tarball ## The gate installs the tarball
2026-09-03, the maintainer. Goal 7. Valid while consumers install the packed package. 2026-09-03, the maintainer. Goal 8. Valid while consumers install the packed package.
The gate packs the build and installs the tarball under `package-tests/`, so `files`, `exports` The gate packs the build and installs the tarball under `package-tests/`, so `files`, `exports`
and `types` are proved on the artifact that ships rather than on the source tree a self-reference and `types` are proved on the artifact that ships rather than on the source tree a self-reference
@@ -318,7 +445,7 @@ resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` s
## Firefox reads the build ## Firefox reads the build
2026-09-04, the maintainer. Goal 7. Valid while the library claims a browser and no other leg runs 2026-09-04, the maintainer. Goal 8. Valid while the library claims a browser and no other leg runs
SpiderMonkey. SpiderMonkey.
A headless Firefox loads `dist/index.js` over HTTP and converts the round-trip, normalization and A headless Firefox loads `dist/index.js` over HTTP and converts the round-trip, normalization and
@@ -330,7 +457,9 @@ is the other's `127.0.0.1`; `--headless --screenshot` has no such channel, and l
`dist/index.js` in a globals-stripped realm buys one by not running a browser. The leg re-checks `dist/index.js` in a globals-stripped realm buys one by not running a browser. The leg re-checks
the conversions and nothing else — each fixture's emitted markdown, its parsed document, its error the conversions and nothing else — each fixture's emitted markdown, its parsed document, its error
code — leaving the corpus's pairing, uniqueness, source positions and byte-level equality to the code — leaving the corpus's pairing, uniqueness, source positions and byte-level equality to the
Node suite that owns them. Node suite that owns them. `selenium/standalone-firefox` runs it over the smaller
`instrumentisto/geckodriver`: the leg is worth a current SpiderMonkey, and that image fell four
Firefox majors behind.
## The coverage floors ## The coverage floors
@@ -344,8 +473,8 @@ compared against `undefined` — have a half no valid document reaches.
## The size ratchet ## The size ratchet
2026-09-20, the maintainer. Goal 10. Valid while no measure picks out what readers find hard 2026-09-20, the maintainer. KISS, a technical principle. Valid while no measure picks out what
better than a function's length. readers find hard better than a function's length.
`.oxlintrc.json`'s single rule, over the files `tsconfig.build.json` builds, is a per-function line `.oxlintrc.json`'s single rule, over the files `tsconfig.build.json` builds, is a per-function line
ceiling, set at that set's worst and moving only downward. It covers the built files alone, since ceiling, set at that set's worst and moving only downward. It covers the built files alone, since
@@ -359,6 +488,26 @@ config gone missing fails the leg instead of falling back to oxlint's own defaul
`--deny-warnings`, since a rule from a category this config never names arrives as a warning it `--deny-warnings`, since a rule from a category this config never names arrives as a warning it
exits 0 on. exits 0 on.
## The project ships under the comprehension floor until items 59, 60, 61 and 62 land
2026-10-03, the maintainer. KISS, a technical principle, and its comprehension floor of 7. Valid
while `todo.md` items 59, 60, 61 and 62 are open.
A four-seat comprehension panel scored the project under the floor of 7. Its scoring run of
2026-10-04 is the baseline. Since 2026-10-04, a chunk whose panel scores any dimension lower
improves what the seats named and scores again, and after three such rounds asks the maintainer.
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior | 7 | 5.5 | 6.5 | 6 | 6 |
| Mid | 7 | 5.5 | 6.5 | 6 | 6 |
| Senior | 7 | 5.5 | 6.5 | 5.5 | 6 |
| Architect | 7 | 5.5 | 6 | 6 | 6 |
| Mean | 7.00 | 5.50 | 6.38 | 5.88 | 6.00 |
Three panels on near-identical code scored overall means of 5.88, 5.75 and 5.75, and a seat moves
±0.5 between runs.
## Properties on a fixed seed ## Properties on a fixed seed
2026-09-14, the maintainer. Goal 1. Valid while a red gate must reproduce. 2026-09-14, the maintainer. Goal 1. Valid while a red gate must reproduce.
@@ -366,6 +515,18 @@ exits 0 on.
Beside the corpus, properties run over documents generated from the node tables and over generated Beside the corpus, properties run over documents generated from the node tables and over generated
markdown, on a fixed seed in the gate; a counterexample found becomes a round-trip fixture. markdown, on a fixed seed in the gate; a counterexample found becomes a round-trip fixture.
## The CommonMark suite checks three ways
2026-08-27, the maintainer. Goals 1 and 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 is a bug to fix, never an
exception.
## The flavour spec is read as a source ## The flavour spec is read as a source
2026-09-01, the maintainer. Goal 1. Valid while `spec/flavour.md` restates the node tables in 2026-09-01, the maintainer. Goal 1. Valid while `spec/flavour.md` restates the node tables in
@@ -401,6 +562,15 @@ format, as its parser does. A list giving way to the directive form refuses at z
than walking again; counting every list twice halved the list limit, counting the directive form than walking again; counting every list twice halved the list limit, counting the directive form
once doubled the parser's frames per level. once doubled the parser's frames per level.
## No value `JSON.parse` or `structuredClone` builds loops a call
2026-10-04, the maintainer. Goals 1 and 9. Valid while every persona hands in plain data, as
`JSON.parse` or `structuredClone` builds it.
The guards walk a value by `Object.values` and refuse a cycle in it. An inherited or non-enumerable
key, a getter, a Proxy or a custom iterator is the caller's to rule out: no persona builds one.
Goal 9's size counts ADF as JSON, so a shared object costs once per place it is held.
## Nothing spreads an unbounded array ## Nothing spreads an unbounded array
2026-09-18, the maintainer. Goal 1. Valid while engines cap a call's arguments. 2026-09-18, the maintainer. Goal 1. Valid while engines cap a call's arguments.
@@ -441,6 +611,17 @@ rebases; a read below re-spells, because a hit skips the depth guards the walk i
an ordered list past the marker cap gives way, spending two emitter levels where the parser spent an ordered list past the marker cap gives way, spending two emitter levels where the parser spent
one. Only what succeeded is kept, so no path minted at another position is ever read. one. Only what succeeded is kept, so no path minted at another position is ever read.
## Cost fixes are measured, never timed
2026-09-18, the maintainer and the stability-reviewer. Goal 9. Valid while Goal 9 promises growth
rather than a figure.
A cost fix that changes no behaviour lands on the suite staying green with no fixture output
changed, and a before-and-after figure in its PR; the gate times nothing. Measured and kept:
`adfDocumentFault`'s shape and depth walks stay two — the parting gives depth its own code — at
52 ms for a 9 MB document the emit takes 314 ms over; and `continuesContainer`'s re-scan per item
level stays, linear in the lines and bounded in depth by the 500-level guard.
## Only the hard break holds a raw newline ## Only the hard break holds a raw newline
2026-08-26, the maintainer. Goal 1. Valid while the whitespace carry finds a line edge by its raw 2026-08-26, the maintainer. Goal 1. Valid while the whitespace carry finds a line edge by its raw
@@ -451,7 +632,7 @@ refuses it — which is how the whitespace carry finds a line edge.
## Emphasis follows CommonMark's matching ## Emphasis follows CommonMark's matching
2026-08-27, the maintainer. Goals 1 and 8. Valid while CommonMark's emphasis rules are the 2026-08-27, the maintainer. Goals 1 and 4. Valid while CommonMark's emphasis rules are the
reader's. reader's.
Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text
@@ -460,7 +641,7 @@ only ones in play, and a pair that matching hands to another delimiter rides the
## Readable spellings take the `try` prefix ## Readable spellings take the `try` prefix
2026-09-21, the maintainer. Goals 1 and 4. Valid while a readable spelling's refusal would cost a 2026-09-21, the maintainer. Goals 1 and 6. Valid while a readable spelling's refusal would cost a
document the general form spells. document the general form spells.
A readable spelling tried ahead of a general one takes the `try` prefix and fails only where the A readable spelling tried ahead of a general one takes the `try` prefix and fails only where the
@@ -482,8 +663,8 @@ is the markdown flavour's choice, not ADF's.
## The source parts by ADF and format ## The source parts by ADF and format
2026-08-27, `adf/`'s bar 2026-09-21, the maintainer. Goal 2. Valid while each format has a reader 2026-08-27, placement 2026-09-18, `adf/`'s bar 2026-09-21, `plain/` 2026-10-03, the maintainer.
and a writer through ADF. Goal 2. Valid while each format has a reader and a writer through ADF.
`src/adf/` holds ADF's own knowledge, imports no format, and is where a construct both formats read `src/adf/` holds ADF's own knowledge, imports no format, and is where a construct both formats read
lives: the question is answered in ADF's vocabulary — a node type, an attribute kind, a content lives: the question is answered in ADF's vocabulary — a node type, an attribute kind, a content
@@ -492,15 +673,19 @@ is two constructs, the ADF question there and the spelling in each format, the s
`markAttributes` and `markSpellings` already draw; one that cannot be split is a gap to ask. `markAttributes` and `markSpellings` already draw; one that cannot be split is a gap to ask.
`markdown/` and `html/` are peers: neither imports the other, and no third directory sits between `markdown/` and `html/` are peers: neither imports the other, and no third directory sits between
them. A primitive knowing neither ADF nor a format stays at `src/` root. A construct rises to them. A primitive knowing neither ADF nor a format stays at `src/` root. A construct rises to
`adf/` on its second consumer, not in anticipation of one. `adf/` on its second consumer, not in anticipation of one. A directory follows a split
`spec/flavour.md` draws, and a placement nothing here settles goes beside its only reader, or in
what both read where there are two.
Each format directory parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest of it A flavour's own code, both directions included, sits in its own directory: `markdown/plain/`;
holding what both directions read. A construct's reader lives there beside the regex the emitter `todo.md` item 62 moves what still sits in `parse/` and `emit/`. Otherwise each format directory
escapes against, so the two cannot drift; a reader with no emit counterpart goes in `parse/`, parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest of it holding what both
unless it is part of a construct that side already holds — a grammar stays in one file rather than directions read. A construct's reader lives there beside the regex the emitter escapes against, so
splitting across the seam. A rule both directions must answer alike — whether a list marker the two cannot drift; a reader with no emit counterpart goes in `parse/`, unless it is part of a
interrupts a paragraph — is one function there too, never a copy per direction, however construct that side already holds — a grammar stays in one file rather than splitting across the
conservative the copy would be. Where the rule is the emitter's own choice, input consults it seam. A rule both directions must answer alike — whether a list marker interrupts a paragraph — is
rather than restating it, and that is the only import `parse/` takes from `emit/` — one function there too, never a copy per direction, however conservative the copy would be. Where
`commonMarkSpelling` and `openingLinkTakesDirective` — so no fixture the emitter writes can be the rule is the emitter's own choice, input consults it rather than restating it, and that is the
refused, and a spelling the emitter refuses gives its own error rather than a second name for it. only value import `parse/` takes from `emit/` — `commonMarkSpelling` and `openingLinkTakesDirective`
— so no fixture the emitter writes can be refused, and a spelling the emitter refuses gives its own
error rather than a second name for it.
+96 -60
View File
@@ -1,12 +1,14 @@
# The markdown flavour # The markdown flavour
The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain
CommonMark is a subset apart from raw HTML (below), with three carve-outs: literal text that matches CommonMark is a subset apart from raw HTML (below), with four carve-outs: literal text that matches
directive syntax below or reads as a pipe table is claimed by the flavour, and a matched `~~` pair directive syntax below or reads as a pipe table is claimed by the flavour, a matched `~~` pair
spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal) — and one gap: a CommonMark spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal), and a code fence whose info
image fits only as its own title-less paragraph — mid-text and titled images are named errors. The string opens `adf:` is the opaque carry (drop the info string and wrap the fence in
emitted form is contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this `!adf:codeBlock {language="adf:…"}` to keep it code) — and one gap: a CommonMark image fits only as
grammar in the sections below. its own title-less paragraph — mid-text and titled images are named errors. The emitted form is
contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this grammar in the
sections below.
## Canonical form ## Canonical form
@@ -44,7 +46,8 @@ normalizes to it through the round-trip.
CommonMark admits no spelling — the end of a block, inside an ATX heading — or where the node CommonMark admits no spelling — the end of a block, inside an ATX heading — or where the node
carries an attribute, it is the inline directive. carries an attribute, it is the inline directive.
- An empty paragraph — real payloads carry them — is an `!adf:paragraph` … `!adf:/paragraph` pair - An empty paragraph — real payloads carry them — is an `!adf:paragraph` … `!adf:/paragraph` pair
holding nothing. holding nothing, and one whose `content` is an empty array the pair
`!adf:paragraph {content=empty}` … `!adf:/paragraph` (Attributes).
- Links `[text](url)`; `<…>` around a destination containing spaces, `<>` an empty one beside a - Links `[text](url)`; `<…>` around a destination containing spaces, `<>` an empty one beside a
title; title in double quotes. A backslash escapes a parenthesis the destination leaves title; title in double quotes. A backslash escapes a parenthesis the destination leaves
unbalanced, and a quote inside the title; a balanced pair stays bare. `<url>` autolink form only unbalanced, and a quote inside the title; a balanced pair stays bare. `<url>` autolink form only
@@ -67,9 +70,10 @@ normalizes to it through the round-trip.
matching below, which is what lets the emitter decide its own pairings. matching below, which is what lets the emitter decide its own pairings.
- Blocks separated by one blank line at document level, inside a blockquote and between CommonMark - Blocks separated by one blank line at document level, inside a blockquote and between CommonMark
blocks; inside a directive container a pair holding a directive block takes none. No trailing blocks; inside a directive container a pair holding a directive block takes none. No trailing
whitespace outside a code whitespace outside a code block's content, single trailing newline. A document whose `content` is
block's an empty array is the empty string, and markdown holding no block reads back to it; a document
content, single trailing newline; a document with no blocks is the empty string. holding no `content` key is the leaf `!adf:doc {content=none}` as its only block, which is a
named error anywhere else or spelled any other way.
## Directives ## Directives
@@ -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` escaped, and erroring input gaining meaning later is MINOR, never a reparse (`docs/decisions.md`
§The formats are API). Each name belongs to one position, and a name the other one spells — a mark §The formats are API). Each name belongs to one position, and a name the other one spells — a mark
or an inline node written as a block directive, a block node written inline — is a different error, or an inline node written as a block directive, a block node written inline — is a different error,
naming the spelling it takes. Two reserved names read back to no node: `carry` for the opaque carry, naming the spelling it takes. Four reserved names read back to no node: `carry` for the inline
as both directive name and fence info string, and `listBreak` for the leaf that parts two adjacent opaque carry, `listBreak` for the leaf that parts two adjacent lists and `doc` for a document
lists (Canonical form). holding no `content` key (Canonical form), and `textBreak` for the leaf that parts two text nodes
(Inline nodes). Every fence info string opening `adf:` is reserved for the block carry (The opaque
carry).
**Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name` **Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name`
closes a container, and a name picks by what follows it in turn — a space or the line's end a block closes a container, and a name picks by what follows it in turn — a space or the line's end a block
@@ -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 quoted where bare carries it, an escape longer than it need be, an empty `{attrs}` on a block line
or after a `[content]`, and a number or `json` value outside its canonical JSON spelling are each a or after a `[content]`, and a number or `json` value outside its canonical JSON spelling are each a
named error naming the spelling to write instead. named error naming the spelling to write instead.
`attrs`, `content` and `marks` are reserved keys on every directive — block, inline node and mark —
whose bare value `empty` spells the node's or mark's key holding an empty object or array:
`!adf:underline[a]{attrs=empty}`, `!adf:date{content=empty}`, `!adf:hardBreak{marks=empty}`. A
container spelling `content=empty` closes with no body; `attrs=empty` stands beside no other
attribute, argument or content slot; and an inline node spelling `marks=empty` stands inside no
mark spelling. Any other value of a reserved key is a named error, except on a block's `marks`
(Block nodes).
**Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as **Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as
directive syntax — every literal `!adf:`, `]` inside content, a bracket a link's destination and directive syntax — every literal `!adf:`, `]` inside content, a bracket a link's destination and
@@ -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 unreinterpreted, and the next emit spells it canonically (`docs/decisions.md` §The round-trip is the
product). Block and inline positions canonicalize differently, each fitting where it sits: product). Block and inline positions canonicalize differently, each fitting where it sits:
- **Block position**: a fenced code block with info string `carry`, body = the node's JSON — - **Block position**: a fenced code block with info string `adf:` and the node's type, body = the
two-space indent, object keys sorted. node's JSON without its `type` — two-space indent, object keys sorted: ```` ```adf:blockCard ````.
A type no info string carries back, by the rule a `codeBlock`'s language follows, leaves the info
string `adf:` and keeps `type` in the body. A body holding `type` under a named type, or a fence
whose info string is `adf:` alone while its body's `type` could be spelled in the info string, is
a named error.
- **Inline position**: `!adf:carry{json="…"}` — compact serialization (keys sorted, no whitespace), - **Inline position**: `!adf:carry{json="…"}` — compact serialization (keys sorted, no whitespace),
JSON-string-escaped into the attribute. JSON-string-escaped into the attribute.
The info string `carry` is reserved: a genuine `codeBlock` whose `language` is exactly `carry` takes Every info string opening `adf:` is reserved: a genuine `codeBlock` whose `language` opens so takes
the attribute the section below keeps for a language no info string holds, so the reservation the attribute the section below keeps for a language no info string holds, so the reservation
stays absolute. stays absolute. In block-directive position `!adf:carry` is a named error — the carry's block form
In block-directive position `!adf:carry` is a named error — the carry's block form is the fence. is the fence.
## Raw HTML in input ## Raw HTML in input
CommonMark input may contain raw HTML. `markdownToAdf` routes each construct through the foreign CommonMark input may contain raw HTML. `markdownToAdf` routes each construct through the foreign
HTML element mapping (`docs/decisions.md` §Foreign HTML is refused by name; specified with the HTML HTML element mapping (`docs/decisions.md` §Foreign HTML sorts three ways; specified with the HTML
dialect, todo.md milestone 6) — ADF has no raw-HTML node, so a construct without a mapping, comments dialect, `todo.md` item 6) — ADF has no raw-HTML node, so a construct without a mapping, comments
and processing instructions included, is an error result naming it. The flavour never emits raw and processing instructions included, is an error result naming it. The flavour never emits raw
HTML. HTML.
@@ -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 payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by
type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare
(`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted), (`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted),
quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty; editor-normal quoted, `-0` spelled `-0`. `markdownToAdf` builds an `attrs`, `content` or `marks` key only where the
ADF reads an empty attrs object, marks array or content array as the absent key (`docs/decisions.md` markdown spells one, an empty one through its reserved key (Attributes), so a document reads back
§Equality is editor-normal) — the grammar's empty-`{attrs}` omission already collapses the two deep-equal (`docs/decisions.md` §Equality is deep). A node CommonMark spells takes the directive
spellings. form to hold an empty key.
Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a
`json` value: `!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`. `json` value, `marks=empty` where it is empty:
`!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`.
A section saying its body is inline takes at most one paragraph, whose inline content becomes A section saying its body is inline takes at most one paragraph, whose inline content becomes
the node's `content`; any other body is a named error, and an empty pair is a node holding none. the node's `content`; any other body is a named error, and an empty pair is a node holding none.
@@ -217,20 +235,24 @@ cannot — `localId` (string) on any of them, marks, and the values below — ta
form. form.
- `blockquote`, `bulletList`, `listItem` — containers, block body. Attributes: `localId` (string). - `blockquote`, `bulletList`, `listItem` — containers, block body. Attributes: `localId` (string).
- `codeBlock` — container, body one fenced code block whose info string is the language and whose - `codeBlock` — container, body one fenced code block per text node, each fence's info string the
content is the node's. Attributes: `hideLineNumbers` (boolean), `language` (string), `localId` language and its content the node's text; a node holding no `content` key is one empty fence.
(string), `uniqueId` (string), `wrap` (boolean). A language no info string carries back — empty, Attributes: `hideLineNumbers` (boolean), `language` (string), `localId` (string), `uniqueId`
the reserved `carry`, or holding a backtick, a backslash, a control character, edge whitespace or (string), `wrap` (boolean). A language no info string carries back — empty, opening the reserved
an entity reference — rides the `language` attribute instead and the fence carries no info `adf:`, or holding a backtick, a backslash, a control character, edge whitespace or an entity
string; writing it in the slot that rule leaves empty, or in both, is a named error. The body is reference — rides the `language` attribute instead and the fences carry no info string, and so
one ordinary code block, and a fence's info string decodes escapes and entity references as any does a language beside `content=empty`, which has no fence; writing it in the slot that rule
other does. leaves empty, or in both, is a named error, and so are fences whose info strings differ and an
empty fence beside another. Each fence is an ordinary code block, and its info string decodes
escapes and entity references as any other does. A node holding a child no fence holds — any but
a text node holding text free of carriage returns and null characters, and carrying no marks,
`attrs` or `content` — rides the block carry.
- `heading` — container, inline body. Attributes: `level` (number), `localId` (string). `level` is - `heading` — container, inline body. Attributes: `level` (number), `localId` (string). `level` is
the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
CommonMark spelling. CommonMark spelling.
- `orderedList` — container of `listItem`, block body. Attributes: `localId` (string), `order` - `orderedList` — container of `listItem`, block body. Attributes: `localId` (string), `order`
(number). `order` is the first marker, so a list carrying none, one that is no whole number (number). `order` is the first marker, so a list carrying none, one that is `-0` or no whole
from 0, or one whose markers would run past 999999999, has no CommonMark spelling. number from 0, or one whose markers would run past 999999999, has no CommonMark spelling.
- `paragraph` — container, inline body. Attributes: `localId` (string). - `paragraph` — container, inline body. Attributes: `localId` (string).
- `rule` — leaf. Attributes: `color` (string, `#rrggbb`), `localId` (string), `style` (`dashed` - `rule` — leaf. Attributes: `color` (string, `#rrggbb`), `localId` (string), `style` (`dashed`
`dotted` `fade` `sketch` `solid`), `weight` (number, 1–3). `dotted` `fade` `sketch` `solid`), `weight` (number, 1–3).
@@ -426,10 +448,11 @@ Right.
Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of Attributes and the carry fallback read as in the block sections, the carry in its inline form. Of
the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot as the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot as
plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
parsing to anything but one text node carrying neither marks, attributes nor content — adjacent text parsing to anything but one text node carrying neither marks, attributes nor content is a named
nodes with identical marks and no attributes merged first — is a named error, and so is a `text` key error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot.
in `{attrs}`. An enclosing mark spelling does not reach into the slot. The rest take no content, An `emoji`, `mention` or `status` whose `text` attribute holds a line ending or a null character
`!adf:text` included; content on a node that takes none is a named error. rides the inline carry; a content slot spelling one is a named error.
The rest take no content, `!adf:text` included; content on a node that takes none is a named error.
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds). - `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
- `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text` - `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text`
@@ -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}. Shipped !adf:emoji[🎉]{shortName=":tada:"} on !adf:date{timestamp=1756080000000}.
``` ```
**Whitespace CommonMark cannot hold.** A newline inside a text node, and a space or tab where **Text CommonMark cannot hold plainly.** A carriage return in text is `&#13;`, which CommonMark
CommonMark strips or refuses one — a block's inline content edges, either side of a line break, an reads back as the character. Three things are spelled `!adf:text{text="…"}`: a newline inside a text
em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled `!adf:text{text="…"}`, node; a null character, which CommonMark reads as U+FFFD; and a space or tab where CommonMark strips
the reserved key carrying the node's text, escaped by the attribute grammar and never literal: pipe or refuses one — a block's inline content edges, either side of a line break, an em, strong or
cells trim and pad. The emitter wraps the whitespace run alone and leaves the rest plain text; strike spelling's inner edges, a pipe cell's edges. The reserved key carries the node's text,
`markdownToAdf` merges adjacent text nodes carrying identical marks and no attributes escaped by the attribute grammar and never literal: pipe cells trim and pad. The emitter wraps the
(`docs/decisions.md` §Equality is editor-normal). Input reads that spelling alone: the value is one run alone and leaves the rest plain text, which the spelled run joins on reading. Input reads that
run of spaces and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark spelling alone: the value is one run of spaces and tabs, one run of newlines, or one run of null
carries plainly — is a named error. characters, and anything else — a mixed run, or text CommonMark carries plainly — is a named error.
``` ```
!adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines. !adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines.
``` ```
**Adjacent text nodes.** CommonMark reads two adjacent text nodes back as one where neither is
carried, neither holds `attrs` or an empty key, and their marks are identical, attributes included.
The reserved leaf `!adf:textBreak{}` parts such a pair, inside every mark spelling the two share; a
code span holds no directive, so it closes and reopens. It builds no node and reads only between
two such nodes: elsewhere, or with `[content]` or `{attrs}`, it is a named error (`docs/decisions.md`
§`!adf:textBreak{}` parts text CommonMark would join). A text node holding `attrs`, `content` or an
empty key, or holding no text, rides the inline carry.
```
Hello, !adf:textBreak{}world — **Hello, !adf:textBreak{}world** — `a`!adf:textBreak{}`b`
```
## Marks ## Marks
An inline node's marks ride the spelling wrapped around them, never the block sections' reserved An inline node's marks ride the spelling wrapped around them, never the block sections' reserved
@@ -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, A spelling adds its mark to every inline node it wraps, and nesting is the marks array in order,
outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the
reverse. `adfToMarkdown` nests in the order the array holds rather than sorting it — reverse. `adfToMarkdown` nests in the order the array holds rather than sorting it —
`docs/decisions.md` §Equality is editor-normal restores the array, not a set — and opens each `docs/decisions.md` §Equality is deep restores the array, not a set — and opens each spelling once
spelling once over the longest run of adjacent inline nodes carrying an identical mark, attributes over the longest run of adjacent inline nodes carrying an identical mark, attributes included, at
included, at that depth. A run breaks at every node the emitter carries, so no emitted carry sits that depth: `attrs: {}` differs from no `attrs`, and a directive spells it `{attrs=empty}`. A run
inside a mark spelling. breaks at every node the emitter carries, so no emitted carry sits inside a mark spelling.
An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs spelling does not list, a value that is not the spelling's type, an attribute the spelling needs and
and the mark lacks, an order putting a code span outside another mark, `code` over anything but a the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside
text node or over text holding a newline, or a spelling CommonMark's flanking rules cannot open or another mark, `code` over anything but a text node holding text, or over text holding a line ending
close where the run sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the or a null character, or a spelling CommonMark's flanking rules cannot open or close where the run
intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the intra-word `*` runs
merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a together with a neighbouring `**`, and the multiple-of-3 rule can leave the merged run's pairing to
mark spelling is a named error in input: the carry restores its node exactly, marks included another delimiter — rides the inline carry whole. An opaque carry inside a mark spelling is a named
(`docs/decisions.md` §Unknown nodes ride the carry). error in input: the carry restores its node exactly, marks included (`docs/decisions.md` §Unknown
nodes ride the carry).
``` ```
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed]. !adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
+20
View File
@@ -82,3 +82,23 @@ test("reads a node's siblings as a walk rather than as one call's arguments", ()
assert.equal(isAdfDocument(wide), true) assert.equal(isAdfDocument(wide), true)
assert.equal(fault(wide), 'accepted') assert.equal(fault(wide), 'accepted')
}) })
test('refuses an object holding itself, and accepts one shared', () => {
const holdsItself = 'an ADF document is a tree: an object in it holds itself'
const paragraph: { content: unknown[]; type: string } = { content: [], type: 'paragraph' }
paragraph.content.push(paragraph)
assert.equal(fault({ content: [paragraph], type: 'doc', version: 1 }), holdsItself)
const document: { content: unknown[]; type: string; version: number } = { content: [], type: 'doc', version: 1 }
document.content.push(document)
assert.equal(fault(document), holdsItself)
const attrs: Record<string, unknown> = {}
attrs['a'] = [attrs]
assert.equal(fault({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), holdsItself)
assert.equal(fault({ content: [{ marks: [{ attrs, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), holdsItself)
assert.equal(isAdfDocument({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), false)
const bold = { type: 'strong' }
const text = { marks: [bold], text: 'x', type: 'text' }
const shared = { content: [text, text], type: 'paragraph' }
const values = { b: 1 }
assert.equal(fault({ content: [shared, shared, { attrs: { a: [values, values] }, type: 'paragraph' }], type: 'doc', version: 1 }), 'accepted')
})
+68 -2
View File
@@ -1,6 +1,7 @@
import type { ConvertFault } from '../result.ts' import type { ConvertFault } from '../result.ts'
import { isJsonValue, overNested, type JsonValue } from '../json-value.ts' import { isJsonValue, overNested, type JsonValue } from '../json-value.ts'
import { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
import { serializeCanonicalJson } from '../canonical-json.ts'
export type AdfAttributes = { [key: string]: JsonValue } export type AdfAttributes = { [key: string]: JsonValue }
@@ -17,6 +18,8 @@ export type AdfNode = {
type: string type: string
} }
export type EmptyKey = 'attrs' | 'content' | 'marks'
export type AdfDocument = { export type AdfDocument = {
content?: AdfNode[] content?: AdfNode[]
type: 'doc' type: 'doc'
@@ -40,6 +43,7 @@ export function adfDocumentFault(value: unknown): ConvertFault | undefined {
const held: unknown = value['content'] const held: unknown = value['content']
if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(held)}`) if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(held)}`)
const content: readonly unknown[] = held const content: readonly unknown[] = held
if (holdsItself(content)) return notADocument('an ADF document is a tree: an object in it holds itself')
if (!isNodeArray(content)) return notADocument("an ADF document's content holds ADF nodes: one of them is not") if (!isNodeArray(content)) return notADocument("an ADF document's content holds ADF nodes: one of them is not")
return nestingFault(content) return nestingFault(content)
} }
@@ -48,11 +52,49 @@ export function attributeNestingMessage(key: string, type: string): string {
return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
} }
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean { export function holdsOnlyAttributes(node: AdfNode, attributes: readonly string[]): boolean {
if (nodeMarks(node).length > 0 || node.text !== undefined) return false if (nodeMarks(node).length > 0 || node.text !== undefined || emptyKeys(node).length > 0) return false
return holdsOnly(nodeAttrs(node), attributes) return holdsOnly(nodeAttrs(node), attributes)
} }
export function emptyKeys(held: { attrs?: AdfAttributes; content?: AdfNode[]; marks?: AdfMark[] }): EmptyKey[] {
const keys: EmptyKey[] = []
if (held.attrs !== undefined && Object.keys(held.attrs).length === 0) keys.push('attrs')
if (held.content?.length === 0) keys.push('content')
if (held.marks?.length === 0) keys.push('marks')
return keys
}
// No attrs, content or empty marks; a format may spell this node as text, and anything more rides a carry.
export function isBareText(node: AdfNode): boolean {
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
}
export function isUnmarkedBareText(node: AdfNode): boolean {
return isBareText(node) && node.marks === undefined
}
export function identicalMark(left: AdfMark, right: AdfMark): boolean {
return marksKey([left]) === marksKey([right])
}
export function identicalMarks(left: readonly AdfMark[], right: readonly AdfMark[]): boolean {
return marksKey(left) === marksKey(right)
}
export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] {
const merged: AdfNode[] = []
for (const node of nodes) {
const previous = merged[merged.length - 1]
if (previous !== undefined && joins(previous, node)) {
merged[merged.length - 1] = { ...previous, text: `${previous.text ?? ''}${node.text ?? ''}` }
continue
}
merged.push(node)
}
return merged
}
// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do. // Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do.
export function isAdfDocument(value: unknown): value is AdfDocument { export function isAdfDocument(value: unknown): value is AdfDocument {
const fault = adfDocumentFault(value) const fault = adfDocumentFault(value)
@@ -81,6 +123,23 @@ export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] {
return node.marks ?? [] return node.marks ?? []
} }
// Tracks ancestors alone: a tree may share an object, but an object holding itself walks the guards forever.
function holdsItself(root: object): boolean {
const pending = [{ depth: 0, item: root }]
const ancestors: object[] = []
const onPath = new Set<unknown>()
while (pending.length > 0) {
const entry = pending.pop()
if (entry === undefined) continue
while (ancestors.length > entry.depth) onPath.delete(ancestors.pop())
if (onPath.has(entry.item)) return true
ancestors.push(entry.item)
onPath.add(entry.item)
for (const child of Object.values(entry.item)) if (typeof child === 'object' && child !== null) pending.push({ depth: entry.depth + 1, item: child })
}
return false
}
function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] { function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
const pending: unknown[] = [...value] const pending: unknown[] = [...value]
while (pending.length > 0) { while (pending.length > 0) {
@@ -99,6 +158,13 @@ function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
return true return true
} }
function marksKey(marks: readonly AdfMark[]): string {
return serializeCanonicalJson(
marks.map((mark) => (mark.attrs === undefined ? [mark.type] : [mark.type, mark.attrs])),
'compact',
)
}
function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined { function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
const pending: AdfNode[] = [...nodes] const pending: AdfNode[] = [...nodes]
while (pending.length > 0) { while (pending.length > 0) {
+1 -1
View File
@@ -18,7 +18,7 @@ export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling)
const { depth, value: held } = next const { depth, value: held } = next
if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth) if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth)
else if (held !== null && typeof held === 'object') schedule(pending, '{', objectMembers(held, indent), '}', indent, depth) else if (held !== null && typeof held === 'object') schedule(pending, '{', objectMembers(held, indent), '}', indent, depth)
else text.push(JSON.stringify(held)) else text.push(Object.is(held, -0) ? '-0' : JSON.stringify(held))
} }
return text.join('') return text.join('')
} }
+33 -8
View File
@@ -2,13 +2,13 @@ import assert from 'node:assert/strict'
import fc from 'fast-check' import fc from 'fast-check'
import test from 'node:test' import test from 'node:test'
import type { AdfNode } from '../adf/document.ts'
import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts' import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts'
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts' import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { adfToPlainMarkdown } from '../markdown/emit/plain-reduction.ts' import { adfToPlainMarkdown, reduceToPlain } from '../markdown/plain/adf-to-plain-markdown.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts' import { directivePrefix } from '../markdown/directive-syntax.ts'
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts' import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { plainMarkdownToAdf } from '../markdown/parse/plain-lift.ts' import { toEditorNormal } from '../markdown/plain/editor-normal.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
const gateRuns = 1600 const gateRuns = 1600
const renamedPrefix = '!adg:' const renamedPrefix = '!adg:'
@@ -20,20 +20,34 @@ function readsNoDirective(markdown: string): boolean {
return read.ok && renamed.ok && JSON.stringify(read.value).replaceAll(directivePrefix, renamedPrefix) === JSON.stringify(renamed.value) 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.assert(
fc.property(adfDocument, (document) => { fc.property(adfDocument, (document) => {
const emitted = adfToMarkdown(document) const emitted = adfToMarkdown(document)
if (!emitted.ok) return assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
const read = markdownToAdf(emitted.value) const read = markdownToAdf(emitted.value)
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`) assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
assert.deepEqual(toEditorNormal(read.value), document, `reading ${JSON.stringify(emitted.value)}`) assert.deepEqual(read.value, document, `reading ${JSON.stringify(emitted.value)}`)
}), }),
propertyRuns(gateRuns), propertyRuns(gateRuns),
) )
}) })
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.assert(
fc.property(adfDocument, (document) => { fc.property(adfDocument, (document) => {
const written = adfToPlainMarkdown(document) const written = adfToPlainMarkdown(document)
@@ -41,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)}`) assert.ok(readsNoDirective(written.value), `a directive in ${JSON.stringify(written.value)}`)
const read = plainMarkdownToAdf(written.value) const read = plainMarkdownToAdf(written.value)
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(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)}`) assert.deepEqual(adfToPlainMarkdown(read.value), written, `reading ${JSON.stringify(written.value)}`)
}), }),
propertyRuns(gateRuns), propertyRuns(gateRuns),
) )
}) })
test('a generated document writes the plain markdown its editor-normal form writes', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
assert.deepEqual(adfToPlainMarkdown(document), adfToPlainMarkdown(toEditorNormal(document)))
}),
propertyRuns(gateRuns),
)
})
+4 -4
View File
@@ -13,7 +13,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus',
const checks = ['count', 'fixpoint', 'text'] as const const checks = ['count', 'fixpoint', 'text'] as const
type Check = (typeof checks)[number] type Check = (typeof checks)[number]
type ExceptionKind = 'mark-model' | 'pending' | 'unspellable' type ExceptionKind = 'mark-model' | 'pending'
type SpecExample = { example: number; html: string; markdown: string; section: string } type SpecExample = { example: number; html: string; markdown: string; section: string }
@@ -30,7 +30,7 @@ function isCheck(value: unknown): value is Check {
} }
function isKind(value: unknown): value is ExceptionKind { function isKind(value: unknown): value is ExceptionKind {
return value === 'mark-model' || value === 'pending' || value === 'unspellable' return value === 'mark-model' || value === 'pending'
} }
function isSpecExample(value: unknown): value is SpecExample { function isSpecExample(value: unknown): value is SpecExample {
@@ -77,12 +77,12 @@ test('the CommonMark spec suite is 0.31.2, vendored byte-exact', () => {
assert.equal(digest, 'd431b29d97b6f73e69d547109cf5081578fac931e72afe95639ebe766c1b2a20') assert.equal(digest, 'd431b29d97b6f73e69d547109cf5081578fac931e72afe95639ebe766c1b2a20')
}) })
test('every exception is unique, names a parsing example, and files a fixpoint only as unspellable', () => { test('every exception is unique, names a parsing example, and files no fixpoint divergence', () => {
assert.equal(exceptionIndex.size, exceptions.length, 'one exception repeats an example and check another holds') assert.equal(exceptionIndex.size, exceptions.length, 'one exception repeats an example and check another holds')
for (const entry of exceptions) { for (const entry of exceptions) {
assert.ok(spec.some((candidate) => candidate.example === entry.example), `exception ${entry.example} names no example in the suite`) assert.ok(spec.some((candidate) => candidate.example === entry.example), `exception ${entry.example} names no example in the suite`)
assert.equal(exampleToRefusal.get(entry.example), undefined, `exception ${entry.example} is on the refusal list, not an exception`) assert.equal(exampleToRefusal.get(entry.example), undefined, `exception ${entry.example} is on the refusal list, not an exception`)
if (entry.check === 'fixpoint') assert.equal(entry.kind, 'unspellable', `exception ${entry.example} files a fixpoint divergence as ${entry.kind}; a fixable hole is given the spelling instead`) assert.notEqual(entry.check, 'fixpoint', `exception ${entry.example} files a fixpoint divergence; the emitter spells every document the parser builds`)
} }
}) })
+4 -5
View File
@@ -9,7 +9,6 @@ import { isAdfDocument } from '../adf/document.ts'
import { isJsonValue } from '../json-value.ts' import { isJsonValue } from '../json-value.ts'
import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts' import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus') const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus')
const errorsRoot = join(corpusRoot, 'errors') const errorsRoot = join(corpusRoot, 'errors')
@@ -97,7 +96,7 @@ for (const directory of roundTripDirectories) {
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8')) const result = markdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(toEditorNormal(result.value), expected) assert.deepEqual(result.value, expected)
}) })
} }
} }
@@ -125,12 +124,12 @@ for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8')) const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
assert.deepEqual(toEditorNormal(result.value), expected) assert.deepEqual(result.value, expected)
const emitted = adfToMarkdown(result.value) const emitted = adfToMarkdown(result.value)
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`) assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
const again = markdownToAdf(emitted.value) const again = markdownToAdf(emitted.value)
assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`) assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`)
assert.deepEqual(toEditorNormal(again.value), expected) assert.deepEqual(again.value, expected)
}) })
} }
@@ -146,7 +145,7 @@ for (const name of names(realPayloadsRoot, '.json')) {
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`) assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
const parsed = markdownToAdf(emitted.value) const parsed = markdownToAdf(emitted.value)
assert.ok(parsed.ok, parsed.ok ? '' : `${parsed.error.code}: ${parsed.error.message}`) assert.ok(parsed.ok, parsed.ok ? '' : `${parsed.error.code}: ${parsed.error.message}`)
assert.deepEqual(toEditorNormal(parsed.value), payload) assert.deepEqual(parsed.value, payload)
}) })
} }
+3 -4
View File
@@ -31,7 +31,6 @@ import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { nodeContent, nodeMarks } from '../adf/document.ts' import { nodeContent, nodeMarks } from '../adf/document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
import { textDirectiveName } from '../markdown/text-directive.ts' import { textDirectiveName } from '../markdown/text-directive.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
type Choice = { arbitrary: Arbitrary<string>; hostile?: true; weight: number } type Choice = { arbitrary: Arbitrary<string>; hostile?: true; weight: number }
@@ -419,7 +418,7 @@ function holdsDirectiveShape(document: AdfDocument): boolean {
return false return false
} }
test('generated markdown refuses, or what it parses to refuses to emit, or its spelling reads back and spells itself', { timeout: propertyTimeout }, () => { test('generated markdown refuses, or its spelling reads back and spells itself', { timeout: propertyTimeout }, () => {
const parameters = propertyRuns(gateRuns) const parameters = propertyRuns(gateRuns)
let directiveShaped = 0 let directiveShaped = 0
let fixpoints = 0 let fixpoints = 0
@@ -428,12 +427,12 @@ test('generated markdown refuses, or what it parses to refuses to emit, or its s
const parsed = markdownToAdf(input) const parsed = markdownToAdf(input)
if (!parsed.ok) return if (!parsed.ok) return
const emitted = adfToMarkdown(parsed.value) const emitted = adfToMarkdown(parsed.value)
if (!emitted.ok) return assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message} — spelling ${JSON.stringify(input)}`)
fixpoints += 1 fixpoints += 1
if (holdsDirectiveShape(parsed.value)) directiveShaped += 1 if (holdsDirectiveShape(parsed.value)) directiveShaped += 1
const read = markdownToAdf(emitted.value) const read = markdownToAdf(emitted.value)
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`) assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
assert.deepEqual(toEditorNormal(read.value), toEditorNormal(parsed.value), `reading ${JSON.stringify(emitted.value)}`) assert.deepEqual(read.value, parsed.value, `reading ${JSON.stringify(emitted.value)}`)
const respelled = adfToMarkdown(read.value) const respelled = adfToMarkdown(read.value)
assert.ok(respelled.ok, respelled.ok ? '' : `${respelled.error.code}: ${respelled.error.message} — spelling ${JSON.stringify(emitted.value)} again`) assert.ok(respelled.ok, respelled.ok ? '' : `${respelled.error.code}: ${respelled.error.message} — spelling ${JSON.stringify(emitted.value)} again`)
assert.equal(respelled.value, emitted.value) assert.equal(respelled.value, emitted.value)
+55 -20
View File
@@ -2,16 +2,17 @@ import assert from 'node:assert/strict'
import { env } from 'node:process' import { env } from 'node:process'
import fc from 'fast-check' import fc from 'fast-check'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../adf/document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode, EmptyKey } from '../adf/document.ts'
import type { Arbitrary } from 'fast-check' import type { Arbitrary } from 'fast-check'
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { blockArgument } from '../markdown/block-directive.ts' import { blockArgument } from '../markdown/block-directive.ts'
import { blockNodes } from '../adf/block-nodes.ts' import { blockNodes } from '../adf/block-nodes.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts' import { directivePrefix } from '../markdown/directive-syntax.ts'
import { emptyKeys, mergeAdjacentText } from '../adf/document.ts'
import { inlineNodes } from '../adf/inline-nodes.ts' import { inlineNodes } from '../adf/inline-nodes.ts'
import { joinsWhenRead } from '../markdown/adjacent-text.ts'
import { markAttributes } from '../adf/mark-attributes.ts' import { markAttributes } from '../adf/mark-attributes.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
type Positions = { block: AdfNode; inline: AdfNode } type Positions = { block: AdfNode; inline: AdfNode }
@@ -23,7 +24,7 @@ export const propertyTimeout = 600000
const depthIdentifier = fc.createDepthIdentifier() const depthIdentifier = fc.createDepthIdentifier()
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' } const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
const flatCommonMarkShapeWeight = 4 const flatCommonMarkShapeWeight = 4
export const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`) export const markdownPieces = fc.constantFrom(...'aZ09 \t\n\r\0!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉日ー한𠀀', '==', '[!NOTE]', '[x]', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
const nestingCommonMarkShapeWeight = 21 const nestingCommonMarkShapeWeight = 21
const spelledTypes = new Set(['text', ...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes)]) const spelledTypes = new Set(['text', ...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes)])
@@ -36,7 +37,11 @@ export function textOf(minLength: number): Arbitrary<string> {
const text = textOf(1) const text = textOf(1)
const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type)) const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type))
const numberValue = fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 }) const numberValue = fc.oneof(
{ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 6 },
{ arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 2 },
{ arbitrary: fc.constant(-0), weight: 1 },
)
// V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash (https://issues.chromium.org/issues/521080746); Bun is unaffected. // V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash (https://issues.chromium.org/issues/521080746); Bun is unaffected.
const keyPiece = fc const keyPiece = fc
@@ -73,18 +78,35 @@ function heldAttributes(held: Readonly<Record<string, JsonValue | undefined>>):
return attrs return attrs
} }
// An empty attrs, content or marks key, and adjacent text CommonMark would read back as one, stay occasional: each takes a spelling outside CommonMark.
// The copy gives fast-check's null-prototype records the prototype a parsed node has.
function occasionallyEmpty<T extends AdfMark | AdfNode>(arbitrary: Arbitrary<T>): Arbitrary<T> {
return fc.tuple(arbitrary, fc.nat({ max: 9 })).map(([held, roll]) => (roll === 0 ? { ...held } : withoutEmptyKeys(held)))
}
function withoutEmptyKeys<T extends AdfMark | AdfNode>(held: T): T {
const kept: Partial<Record<EmptyKey, unknown>> & T = { ...held }
for (const key of emptyKeys(held)) delete kept[key]
return kept
}
function occasionallyApart(arbitrary: Arbitrary<AdfNode[]>): Arbitrary<AdfNode[]> {
return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes, joinsWhenRead)))
}
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode { function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))] const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))]
return { content: rows.map((content): AdfNode => ({ content, type: 'tableRow' })), type: 'table' } return { content: rows.map((content): AdfNode => ({ content, type: 'tableRow' })), type: 'table' }
} }
const mark: Arbitrary<AdfMark> = fc.oneof( const mark: Arbitrary<AdfMark> = occasionallyEmpty(fc.oneof(
{ arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 }, { arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 },
{ arbitrary: attributes({ color: 'string' }).map((attrs) => ({ attrs, type: 'backgroundColor' })), weight: 2 },
{ arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 }, { arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 },
) ))
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type }) const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
const textNode = fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' })) const textNode = occasionallyEmpty(fc.record({ marks, text: fc.oneof({ arbitrary: text, weight: 19 }, { arbitrary: fc.constant(''), weight: 1 }) }).map((held): AdfNode => ({ ...held, type: 'text' })))
const backtickRunNode = fc const backtickRunNode = fc
.record({ marks: fc.oneof(fc.constant<AdfMark[]>([]), fc.constant<AdfMark[]>([{ type: 'code' }]), marks), text: fc.string({ maxLength: 6, minLength: 1, unit: fc.constantFrom('`', '``', ' ', 'a') }) }) .record({ marks: fc.oneof(fc.constant<AdfMark[]>([]), fc.constant<AdfMark[]>([{ type: 'code' }]), marks), text: fc.string({ maxLength: 6, minLength: 1, unit: fc.constantFrom('`', '``', ' ', 'a') }) })
@@ -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' })) .map(({ href, marks: held }): AdfNode => ({ marks: [...held.filter((outer) => outer.type !== 'link'), { attrs: { href }, type: 'link' }], text: href, type: 'text' }))
const inlineArbitraries = Object.entries(inlineNodes).map(([type, model]) => const inlineArbitraries = Object.entries(inlineNodes).map(([type, model]) =>
fc.record({ attrs: attributes(model.attributes), marks }).map((held): AdfNode => ({ ...held, type })), occasionallyEmpty(fc.record({ attrs: attributes(model.attributes), marks }).map((held): AdfNode => ({ ...held, type }))),
) )
function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): { arbitrary: Arbitrary<AdfNode>; weight: number }[] { function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): { arbitrary: Arbitrary<AdfNode>; weight: number }[] {
@@ -104,10 +126,10 @@ function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): {
const positions = fc.letrec<Positions>((tie) => { const positions = fc.letrec<Positions>((tie) => {
const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 }) const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 })
const inlineContent = fc.array(tie('inline'), { depthIdentifier, maxLength: 4 }) const inlineContent = occasionallyApart(fc.array(tie('inline'), { depthIdentifier, maxLength: 4 }))
const contentByModel = { const contentByModel = {
block: blockContent, block: blockContent,
code: fc.array(text.map((held): AdfNode => ({ text: held, type: 'text' })), { maxLength: 2 }), code: occasionallyApart(fc.array(text.map((held): AdfNode => ({ text: held, type: 'text' })), { maxLength: 2 })),
inline: inlineContent, inline: inlineContent,
none: fc.constant<AdfNode[]>([]), none: fc.constant<AdfNode[]>([]),
} }
@@ -115,31 +137,40 @@ const positions = fc.letrec<Positions>((tie) => {
const blockArbitraries = Object.entries(blockNodes).map(([type, model]) => { const blockArbitraries = Object.entries(blockNodes).map(([type, model]) => {
const argument = blockArgument(type) const argument = blockArgument(type)
const vocabulary: AttributeVocabulary = argument === undefined ? model.attributes : { ...model.attributes, [argument]: 'string' } const vocabulary: AttributeVocabulary = argument === undefined ? model.attributes : { ...model.attributes, [argument]: 'string' }
const node = fc.record({ attrs: attributes(vocabulary), content: contentByModel[model.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type })) const node = occasionallyEmpty(fc.record({ attrs: attributes(vocabulary), content: contentByModel[model.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type })))
return { leaf: model.contentModel === 'code' || model.contentModel === 'none', node } return { leaf: model.contentModel === 'code' || model.contentModel === 'none', node }
}) })
const unknownNode = fc const unknownNode = occasionallyEmpty(
.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType }) fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType }),
.map((held): AdfNode => held) )
const leafBlocks = blockArbitraries.filter((entry) => entry.leaf).map((entry) => entry.node) const leafBlocks = blockArbitraries.filter((entry) => entry.leaf).map((entry) => entry.node)
const containerBlocks = blockArbitraries.filter((entry) => !entry.leaf).map((entry) => entry.node) const containerBlocks = blockArbitraries.filter((entry) => !entry.leaf).map((entry) => entry.node)
const misplacedWeight = 7 const misplacedWeight = 7
const paragraph = fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: fc.array(backtickRunNode, { maxLength: 4, minLength: 2 }), weight: 1 }).map((content): AdfNode => ({ content, type: 'paragraph' })) const paragraph = occasionallyEmpty(
fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: occasionallyApart(fc.array(backtickRunNode, { maxLength: 4, minLength: 2 })), weight: 1 }).map((content): AdfNode => ({ content, type: 'paragraph' })),
)
const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type })) const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type }))
const listItems = fc.array( const listItems = fc.array(
blockContent.map((content): AdfNode => ({ content, type: 'listItem' })), occasionallyEmpty(blockContent.map((content): AdfNode => ({ content, type: 'listItem' }))),
{ depthIdentifier, maxLength: 3, minLength: 1 }, { depthIdentifier, maxLength: 3, minLength: 1 },
) )
const flatCommonMarkShapes = [ const flatCommonMarkShapes = [
fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })), occasionallyEmpty(fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' }))),
paragraph, paragraph,
fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable), fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable),
] ]
const task = (type: string, content: Arbitrary<AdfNode[]>) =>
occasionallyEmpty(fc.record({ content, state: fc.constantFrom('DONE', 'TODO') }).map(({ content: held, state }): AdfNode => ({ attrs: { state }, content: held, type })))
const taskItem = fc.oneof({ arbitrary: task('taskItem', inlineContent), weight: 3 }, { arbitrary: task('blockTaskItem', blockContent), weight: 1 })
const nestingCommonMarkShapes = [ const nestingCommonMarkShapes = [
blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })), occasionallyEmpty(blockContent.map((content): AdfNode => ({ content, type: 'blockquote' }))),
fc.array(fc.oneof({ arbitrary: taskItem, weight: 3 }, { arbitrary: tie('block'), weight: 1 }), { depthIdentifier, maxLength: 3, minLength: 1 }).map((content): AdfNode => ({ content, type: 'taskList' })),
listItems.map((content): AdfNode => ({ content, type: 'bulletList' })), listItems.map((content): AdfNode => ({ content, type: 'bulletList' })),
fc fc
.record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) }) .record({
content: listItems,
order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 8 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 2 }, { arbitrary: fc.constant(-0), weight: 1 }),
})
.map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })), .map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })),
] ]
const flatBlocks = [...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)] const flatBlocks = [...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)]
@@ -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 } { export function propertyRuns(gateRuns: number): { gate: boolean; numRuns: number; seed?: number } {
const deepRuns = env[deepRunsVariable] const deepRuns = env[deepRunsVariable]
+3 -3
View File
@@ -1,8 +1,8 @@
// Export only the conversions, their types, isAdfDocument and what a guarantee or a persona needs.
export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts' export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts' export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
export type { JsonValue } from './json-value.ts' export type { JsonValue } from './json-value.ts'
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { adfToPlainMarkdown } from './markdown/emit/plain-reduction.ts' export { adfToPlainMarkdown } from './markdown/plain/adf-to-plain-markdown.ts'
export { isAdfDocument } from './adf/document.ts' export { isAdfDocument } from './adf/document.ts'
export { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' export { markdownToAdf, plainMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
export { plainMarkdownToAdf } from './markdown/parse/plain-lift.ts'
+12
View File
@@ -0,0 +1,12 @@
import type { AdfNode } from '../adf/document.ts'
import { identicalMarks, isBareText, nodeMarks } from '../adf/document.ts'
import { spellInlineLeafDirective } from './directive-syntax.ts'
export const textBreakName = 'textBreak'
export const textBreakSpelling = spellInlineLeafDirective(textBreakName, '')
// The lossless reader's rule: CommonMark reads the pair back as one text run (spec/flavour.md, Inline nodes).
export function joinsWhenRead(previous: AdfNode, node: AdfNode): boolean {
return isBareText(previous) && isBareText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node))
}
+11 -7
View File
@@ -2,9 +2,9 @@ import type { AdfMark } from '../adf/document.ts'
import type { BlockType } from '../adf/block-nodes.ts' import type { BlockType } from '../adf/block-nodes.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { blockNodeModel } from '../adf/block-nodes.ts' import { blockNodeModel } from '../adf/block-nodes.ts'
import { isAdfMark, nodeAttrs } from '../adf/document.ts' import { isAdfMark } from '../adf/document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
import { spellDirectiveOpener } from './directive-syntax.ts' import { spellAttributes, spellDirectiveOpener } from './directive-syntax.ts'
const argumentByType = new Map( const argumentByType = new Map(
Object.entries({ Object.entries({
@@ -14,6 +14,13 @@ const argumentByType = new Map(
} satisfies Partial<Record<BlockType, string>>), } satisfies Partial<Record<BlockType, string>>),
) )
export const documentName = 'doc'
// spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell.
export const documentAttribute = { key: 'content', value: 'none' }
export const documentSpelling = spellDirectiveOpener(documentName, undefined, spellAttributes([[documentAttribute.key, documentAttribute.value]]))
export const listBreakName = 'listBreak' export const listBreakName = 'listBreak'
export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '') export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
@@ -25,17 +32,14 @@ export function blockArgument(type: string): string | undefined {
} }
export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined { export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined {
if (name === listBreakName) return 'leaf' if (name === listBreakName || name === documentName) return 'leaf'
const model = blockNodeModel(name) const model = blockNodeModel(name)
if (model === undefined) return undefined if (model === undefined) return undefined
return model.contentModel === 'none' ? 'leaf' : 'container' return model.contentModel === 'none' ? 'leaf' : 'container'
} }
export function markValues(marks: readonly AdfMark[]): JsonValue { export function markValues(marks: readonly AdfMark[]): JsonValue {
return marks.map((mark) => { return marks.map((mark) => (mark.attrs === undefined ? { type: mark.type } : { attrs: mark.attrs, type: mark.type }))
const attrs = nodeAttrs(mark)
return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type }
})
} }
export function readMarkValues(value: JsonValue): AdfMark[] | undefined { export function readMarkValues(value: JsonValue): AdfMark[] | undefined {
+3 -5
View File
@@ -1,14 +1,12 @@
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { carryName } from './opaque-carry.ts' import { carryFenceType } from './opaque-carry.ts'
import { holdsControlCharacter } from './commonmark/grammar.ts' import { infoStringCarries } from './commonmark/grammar.ts'
import { holdsEntityReference } from './commonmark/entity-references.ts'
export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' } export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' }
// spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides. // spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides.
export function languageSlot(language: JsonValue | undefined): LanguageSlot { export function languageSlot(language: JsonValue | undefined): LanguageSlot {
if (language === undefined) return { kind: 'none' } if (language === undefined) return { kind: 'none' }
if (typeof language !== 'string' || language === '' || language === carryName) return { kind: 'attribute' } if (typeof language !== 'string' || carryFenceType(language) !== undefined || !infoStringCarries(language)) return { kind: 'attribute' }
if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return { kind: 'attribute' }
return { info: language, kind: 'fence' } return { info: language, kind: 'fence' }
} }
+6 -5
View File
@@ -1,4 +1,4 @@
import { readEntityReference, replacementCharacter } from './entity-references.ts' import { holdsEntityReference, readEntityReference, replacementCharacter } from './entity-references.ts'
export type LinePosition = 'first' | 'later' export type LinePosition = 'first' | 'later'
@@ -83,10 +83,6 @@ export function backslashEscape(text: string, index: number): string | undefined
return isAsciiPunctuation(escaped) ? escaped : undefined return isAsciiPunctuation(escaped) ? escaped : undefined
} }
export function claimsLine(line: string, position: LinePosition): boolean {
return escapesLineClaim(line, 0, position) || orderedListOpener.test(line)
}
export function claimsPipeLine(line: string): boolean { export function claimsPipeLine(line: string): boolean {
return pipeClaim.test(line) return pipeClaim.test(line)
} }
@@ -133,6 +129,11 @@ export function holdsControlCharacter(text: string): boolean {
return controlCharacter.test(text) return controlCharacter.test(text)
} }
// What a backtick fence's info string reads back verbatim: escapes and entity references decode, and the edges trim.
export function infoStringCarries(text: string): boolean {
return text !== '' && !/[`\\]/.test(text) && !holdsControlCharacter(text) && text === text.trim() && !holdsEntityReference(text)
}
export function holdsNullCharacter(text: string): boolean { export function holdsNullCharacter(text: string): boolean {
return nullCharacter.test(text) return nullCharacter.test(text)
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import { backslashEscape, decodeTextEscapes, holdsControlCharacter } from './grammar.ts' import { backslashEscape, decodeTextEscapes, holdsControlCharacter, holdsNullCharacter } from './grammar.ts'
import { holdsEntityReference } from './entity-references.ts' import { holdsEntityReference } from './entity-references.ts'
export type LinkDefinition = { destination: string; title?: string } export type LinkDefinition = { destination: string; title?: string }
@@ -117,5 +117,5 @@ export function escapeUnbalanced(spelling: string, opener: string, closer: strin
} }
function spellTitle(title: string): string | undefined { function spellTitle(title: string): string | undefined {
return /[\n\r\\]/.test(title) || holdsEntityReference(title) ? undefined : ` "${title.replaceAll('"', '\\"')}"` return /[\n\r\\]/.test(title) || holdsNullCharacter(title) || holdsEntityReference(title) ? undefined : ` "${title.replaceAll('"', '\\"')}"`
} }
+6 -6
View File
@@ -1,7 +1,7 @@
import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts' import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts'
import type { ConvertFault } from '../result.ts' import type { ConvertFault } from '../result.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { backslashEscape } from './commonmark/grammar.ts' import { backslashEscape, holdsNullCharacter } from './commonmark/grammar.ts'
import { backtickRun, closingBacktickRun } from './commonmark/backtick-runs.ts' import { backtickRun, closingBacktickRun } from './commonmark/backtick-runs.ts'
import { isJsonValue, overNested } from '../json-value.ts' import { isJsonValue, overNested } from '../json-value.ts'
import { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
@@ -100,10 +100,10 @@ export function readSoleStringAttribute(span: DirectiveSpan, key: string): Read<
return { value: spelled.decoded } return { value: spelled.decoded }
} }
// Both directions answer alike: an inline directive never spans lines, so no content slot holds a line ending. // An inline directive never spans lines, and no content slot holds a null character; the emitter carries such a node.
export function slotLineEndingFault(type: string, text: string): ConvertFault | undefined { export function slotFault(type: string, text: string): ConvertFault | undefined {
if (!/[\n\r]/.test(text)) return undefined if (/[\n\r]/.test(text)) return { code: 'unspellable-whitespace', message: `the ${type} content slot holds a newline no inline directive spans` }
return { code: 'unspellable-whitespace', message: `the ${type} content slot holds a newline no inline directive spans` } return holdsNullCharacter(text) ? unsupportedNodeShape(`the ${type} content slot holds a null character: write the node as !adf:carry`) : undefined
} }
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string { export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
@@ -139,7 +139,7 @@ export function spellStringAttribute(text: string): string {
export function spellAttributeValue(value: VocabularyValue): string { export function spellAttributeValue(value: VocabularyValue): string {
if (value.kind === 'boolean') return `${value.value}` if (value.kind === 'boolean') return `${value.value}`
if (value.kind === 'json') return spellJsonAttribute(value.value) if (value.kind === 'json') return spellJsonAttribute(value.value)
if (value.kind === 'number') return spellStringAttribute(JSON.stringify(value.value)) if (value.kind === 'number') return spellStringAttribute(serializeCanonicalJson(value.value, 'compact'))
return spellStringAttribute(value.value) return spellStringAttribute(value.value)
} }
+74 -67
View File
@@ -6,14 +6,13 @@ import type { JsonValue } from '../../json-value.ts'
import type { Result } from '../../result.ts' import type { Result } from '../../result.ts'
import { adfToMarkdown, markdownToAdf } from '../../index.ts' import { adfToMarkdown, markdownToAdf } from '../../index.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
function document(...content: AdfNode[]): AdfDocument { function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 } return { content, type: 'doc', version: 1 }
} }
function paragraph(...content: AdfNode[]): AdfNode { function paragraph(...content: AdfNode[]): AdfNode {
return { content, type: 'paragraph' } return content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' }
} }
function code(result: Result<string>): string { function code(result: Result<string>): string {
@@ -36,7 +35,7 @@ test('names the node a refusal came from, and no source the emitter never read',
const unspellable: AdfNode = { text: 'x', type: 'paragraph' } const unspellable: AdfNode = { text: 'x', type: 'paragraph' }
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' } const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' }
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0]) assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0])
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { type: 'text' })))), ['content', 0, 'content', 1]) assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { text: 'x', type: 'status' })))), ['content', 0, 'content', 1])
assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), []) assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), [])
assert.equal(position(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), undefined) assert.equal(position(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), undefined)
}) })
@@ -148,8 +147,16 @@ test('carries a mark the canonical spellings cannot nest', () => {
) )
}) })
test('refuses a line whose start block parsing would claim', () => { function readsBack(doc: AdfDocument): string {
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '```', type: 'text' })))), 'unspellable-line-start') const spelled = markdown(adfToMarkdown(doc))
assert.deepEqual(markdownToAdf(spelled), { ok: true, value: doc }, `reading ${JSON.stringify(spelled)}`)
return spelled
}
test('spells a code span opening a line, whose backticks open no fence', () => {
const fence: AdfNode = { marks: [{ type: 'code' }], text: '``', type: 'text' }
assert.equal(readsBack(document(paragraph(fence))), '``` `` ```\n')
assert.equal(readsBack(document(paragraph({ text: 'a', type: 'text' }, { type: 'hardBreak' }, fence))), 'a\\\n``` `` ```\n')
}) })
test('escapes the delimiter row a hard break leaves opening a pipe table with no leading pipe', () => { test('escapes the delimiter row a hard break leaves opening a pipe table with no leading pipe', () => {
@@ -168,21 +175,24 @@ test('parts two adjacent lists of the same kind, the marker spelling being what
const nested: AdfNode = { content: [{ content: [list, list], type: 'listItem' }], type: 'bulletList' } const nested: AdfNode = { content: [{ content: [list, list], type: 'listItem' }], type: 'bulletList' }
assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n !adf:listBreak\n\n - x\n') assert.equal(markdown(adfToMarkdown(document(nested))), '- - x\n\n !adf:listBreak\n\n - x\n')
const carried: AdfNode = { ...list, attrs: { unknown: 'x' } } const carried: AdfNode = { ...list, attrs: { unknown: 'x' } }
assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```carry\n')) assert.ok(markdown(adfToMarkdown(document(carried, carried))).includes('```\n\n```adf:bulletList\n'))
assert.ok(markdown(adfToMarkdown(document(carried, list))).endsWith('```\n\n- x\n')) assert.ok(markdown(adfToMarkdown(document(carried, list))).endsWith('```\n\n- x\n'))
assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```carry\n')) assert.ok(markdown(adfToMarkdown(document(list, carried))).startsWith('- x\n\n```adf:bulletList\n'))
}) })
test('carries a node type no section spells', () => { test('carries a node type no section spells, the fence naming the type wherever an info string carries it', () => {
assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```carry\n{\n "type": "blockCard"\n}\n```\n') assert.equal(markdown(adfToMarkdown(document({ type: 'blockCard' }))), '```adf:blockCard\n{}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```carry\n{\n "type": "toString"\n}\n```\n') assert.equal(markdown(adfToMarkdown(document({ type: 'toString' }))), '```adf:toString\n{}\n```\n')
assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), '!adf:carry{json="{\\"type\\":\\"blockCard\\"}"}\n') assert.equal(markdown(adfToMarkdown(document(paragraph({ type: 'blockCard' })))), '!adf:carry{json="{\\"type\\":\\"blockCard\\"}"}\n')
assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```carry\n{\n "text": "x",\n "type": "text"\n}\n```\n') assert.equal(markdown(adfToMarkdown(document({ text: 'x', type: 'text' }))), '```adf:text\n{\n "text": "x"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```carry\n{\n "type": "hardBreak"\n}\n```\n') assert.equal(markdown(adfToMarkdown(document({ type: 'hardBreak' }))), '```adf:hardBreak\n{}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'a&amp;b' }))), '```adf:\n{\n "type": "a&amp;b"\n}\n```\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'a\\b' }))), '```adf:\n{\n "type": "a\\\\b"\n}\n```\n')
}) })
test('spells the code block whose language is the reserved info string', () => { test('spells the code block whose language opens with the reserved info string prefix', () => {
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '!adf:codeBlock {language=carry}\n```\n```\n!adf:/codeBlock\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf:x' }, type: 'codeBlock' }))), '!adf:codeBlock {language="adf:x"}\n```\n```\n!adf:/codeBlock\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'carry' }, type: 'codeBlock' }))), '```carry\n```\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), '```adf\n```\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { language: 'adf' }, type: 'codeBlock' }))), '```adf\n```\n')
}) })
@@ -208,21 +218,17 @@ test('refuses a carried node nested deeper than the levels its position leaves',
assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth') assert.equal(code(adfToMarkdown(document(quoted))), 'unsupported-nesting-depth')
}) })
test('refuses a node whose content model the canonical form cannot emit', () => { test('carries a code block holding a node no fence holds', () => {
assert.equal( assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), '```adf:codeBlock\n{\n "content": [\n {\n "type": "paragraph"\n }\n ]\n}\n```\n')
markdown(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), for (const child of [{ attrs: {}, text: 'x', type: 'text' }, { content: [], text: 'x', type: 'text' }, { marks: [], text: 'x', type: 'text' }, { content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }]) {
'unsupported-node-shape: a codeBlock holds plain text nodes only: this paragraph node is not one', assert.ok(markdown(adfToMarkdown(document({ content: [child], type: 'codeBlock' }))).startsWith('```adf:codeBlock\n'), JSON.stringify(child))
) }
assert.equal(
markdown(adfToMarkdown(document({ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'x', type: 'text' }], type: 'codeBlock' }))),
'unsupported-node-shape: a codeBlock holds plain text nodes only: this text node is not one',
)
}) })
test('spells a list its own content shape cannot hold as a directive', () => { test('spells a list its own content shape cannot hold as a directive', () => {
assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), '!adf:bulletList\n!adf:paragraph\n!adf:/paragraph\n!adf:/bulletList\n') assert.equal(markdown(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), '!adf:bulletList\n!adf:paragraph\n!adf:/paragraph\n!adf:/bulletList\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), '!adf:bulletList\n!adf:/bulletList\n') assert.equal(markdown(adfToMarkdown(document({ type: 'bulletList' }))), '!adf:bulletList\n!adf:/bulletList\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {order=2}\n!adf:/orderedList\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), '!adf:orderedList {content=empty order=2}\n!adf:/orderedList\n')
}) })
test('spells an ordered list no marker fits as a directive', () => { test('spells an ordered list no marker fits as a directive', () => {
@@ -334,7 +340,7 @@ test('refuses a node carrying one mark type twice', () => {
}) })
test('parts a nested list the tight spelling would swallow from the block above it', () => { test('parts a nested list the tight spelling would swallow from the block above it', () => {
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' }) const item = (...content: AdfNode[]): AdfNode => (content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' })
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' }) const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' }) const outer = (...content: AdfNode[]): AdfDocument => document({ content: [item(...content)], type: 'bulletList' })
const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' } const ordered: AdfNode = { attrs: { order: 2 }, content: [item(text('b'))], type: 'orderedList' }
@@ -345,7 +351,7 @@ test('parts a nested list the tight spelling would swallow from the block above
const list: AdfNode = { content: [item(text('b'))], type: 'bulletList' } const list: AdfNode = { content: [item(text('b'))], type: 'bulletList' }
const panel: AdfNode = { attrs: { panelType: 'info' }, content: [text('p')], type: 'panel' } const panel: AdfNode = { attrs: { panelType: 'info' }, content: [text('p')], type: 'panel' }
assert.equal(markdown(adfToMarkdown(outer(panel, list))), '- !adf:panel info\n p\n !adf:/panel\n\n - b\n') assert.equal(markdown(adfToMarkdown(outer(panel, list))), '- !adf:panel info\n p\n !adf:/panel\n\n - b\n')
assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```carry\n')) assert.ok(markdown(adfToMarkdown(outer(text('a'), { ...list, attrs: { unknown: 'x' } }))).startsWith('- a\n\n ```adf:bulletList\n'))
}) })
test('refuses marks and attributes nested deeper than the emitter carries', () => { test('refuses marks and attributes nested deeper than the emitter carries', () => {
@@ -372,7 +378,7 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message) assert.ok(spelled.ok, spelled.ok ? '' : spelled.error.message)
const read = markdownToAdf(spelled.value) const read = markdownToAdf(spelled.value)
assert.ok(read.ok, read.ok ? '' : read.error.message) assert.ok(read.ok, read.ok ? '' : read.error.message)
assert.deepEqual(toEditorNormal(read.value), document(node)) assert.deepEqual(read.value, document(node))
} }
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em')) assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em'))
@@ -442,7 +448,7 @@ test('escapes a hyphen underline a hard break would expose', () => {
}) })
test('spells a list item whose marker completes a thematic break as a directive', () => { test('spells a list item whose marker completes a thematic break as a directive', () => {
const item = (...content: AdfNode[]): AdfNode => ({ content, type: 'listItem' }) const item = (...content: AdfNode[]): AdfNode => (content.length === 0 ? { type: 'listItem' } : { content, type: 'listItem' })
assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n') assert.equal(markdown(adfToMarkdown(document({ content: [item({ type: 'rule' })], type: 'bulletList' }))), '!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')
const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' } const nested: AdfNode = { content: [item({ content: [item()], type: 'bulletList' })], type: 'bulletList' }
assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n') assert.equal(markdown(adfToMarkdown(document(nested))), '- -\n')
@@ -458,31 +464,26 @@ test('spells a list item whose marker completes a thematic break as a directive'
) )
}) })
test('refuses the characters CommonMark rewrites', () => { test('spells the characters CommonMark rewrites', () => {
assert.equal( assert.equal(readsBack(document(paragraph({ text: 'a\r\r\nb', type: 'text' }))), 'a&#13;&#13;!adf:text{text="\\n"}b\n')
markdown(adfToMarkdown(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' }))), assert.equal(readsBack(document(paragraph({ marks: [{ type: 'strong' }], text: '\u0000\u0000a\u0000', type: 'text' }))), '**!adf:text{text="\\u0000\\u0000"}a!adf:text{text="\\u0000"}**\n')
'unspellable-character: a codeBlock holds no carriage return CommonMark keeps: this text holds one', assert.equal(readsBack(document(paragraph({ marks: [{ attrs: { href: 'u' }, type: 'link' }], text: 'a\rb\u0000', type: 'text' }))), '[a&#13;b!adf:text{text="\\u0000"}](u)\n')
) assert.equal(readsBack(document({ attrs: { level: 1 }, content: [{ text: '\r', type: 'text' }], type: 'heading' })), '# &#13;\n')
assert.equal( assert.equal(readsBack(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' })), '```adf:codeBlock\n{\n "content": [\n {\n "text": "a\\rb",\n "type": "text"\n }\n ]\n}\n```\n')
markdown(adfToMarkdown(document(paragraph({ text: 'a\rb', type: 'text' })))), readsBack(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))
'unspellable-character: a text node holds a carriage return CommonMark rewrites', readsBack(document(paragraph({ marks: [{ type: 'code' }], text: 'a\u0000b', type: 'text' })))
)
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\u0000b', type: 'text' })))), 'unspellable-character')
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))), 'unspellable-character')
assert.equal(
markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))),
'unsupported-node-shape: a codeBlock holds plain text nodes only: this text node is not one',
)
}) })
test('refuses a text node the spelling would empty out', () => { test('carries a text node holding no text, or holding content', () => {
const nested: AdfNode[] = [{ text: 'lost', type: 'text' }] const nested: AdfNode[] = [{ text: 'lost', type: 'text' }]
const empty = 'unsupported-node-shape: a text node holds text: this one has none' assert.equal(readsBack(document(paragraph({ text: '', type: 'text' }))), '!adf:carry{json="{\\"text\\":\\"\\",\\"type\\":\\"text\\"}"}\n')
const holding = 'unsupported-node-shape: a text node holds no content: this one holds some' assert.equal(readsBack(document(paragraph({ type: 'text' }, { text: 'a', type: 'text' }))), '!adf:carry{json="{\\"type\\":\\"text\\"}"}a\n')
assert.equal(markdown(adfToMarkdown(document(paragraph({ text: '', type: 'text' })))), empty) readsBack(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))), empty) readsBack(document(paragraph({ marks: [{ type: 'em' }], text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: '', type: 'text' })))
assert.equal(markdown(adfToMarkdown(document(paragraph({ content: nested, text: 'x', type: 'text' })))), holding) readsBack(document(paragraph({ content: nested, text: 'x', type: 'text' })))
assert.equal(markdown(adfToMarkdown(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))), holding) readsBack(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))
readsBack(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))
readsBack(document({ content: [{ text: 'a', type: 'text' }, { type: 'text' }], type: 'codeBlock' }))
}) })
test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => { test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => {
@@ -501,11 +502,11 @@ test('pads a code span whose edges CommonMark would strip', () => {
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: ' \t ', type: 'text' })))), '` \t `\n') assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: ' \t ', type: 'text' })))), '` \t `\n')
}) })
test('spells one code span over a run of code-marked nodes', () => { test('spells a code span per code-marked node, parted by the text break', () => {
const code_ = { type: 'code' } const code_ = { type: 'code' }
assert.equal( assert.equal(
markdown(adfToMarkdown(document(paragraph({ marks: [code_], text: 'a', type: 'text' }, { marks: [code_], text: 'b', type: 'text' })))), markdown(adfToMarkdown(document(paragraph({ marks: [code_], text: 'a', type: 'text' }, { marks: [code_], text: 'b', type: 'text' })))),
'`ab`\n', '`a`!adf:textBreak{}`b`\n',
) )
}) })
@@ -545,7 +546,7 @@ test('emits an empty list item without trailing whitespace', () => {
test('spells a block directive as its node type, arg and attributes', () => { test('spells a block directive as its node type, arg and attributes', () => {
const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' }) const panel = (attrs: AdfAttributes): AdfDocument => document({ attrs, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' })
assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), '!adf:panel warning\nx\n!adf:/panel\n') assert.equal(markdown(adfToMarkdown(panel({ panelType: 'warning' }))), '!adf:panel warning\nx\n!adf:/panel\n')
assert.equal(markdown(adfToMarkdown(panel({}))), '!adf:panel\nx\n!adf:/panel\n') assert.equal(markdown(adfToMarkdown(panel({}))), '!adf:panel {attrs=empty}\nx\n!adf:/panel\n')
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), '!adf:caption\nx\n!adf:/caption\n') assert.equal(markdown(adfToMarkdown(document({ content: [{ text: 'x', type: 'text' }], type: 'caption' }))), '!adf:caption\nx\n!adf:/caption\n')
assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '!adf:caption\n!adf:/caption\n') assert.equal(markdown(adfToMarkdown(document({ type: 'caption' }))), '!adf:caption\n!adf:/caption\n')
assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '!adf:syncBlock {localId=a}\n') assert.equal(markdown(adfToMarkdown(document({ attrs: { localId: 'a' }, type: 'syncBlock' }))), '!adf:syncBlock {localId=a}\n')
@@ -553,20 +554,20 @@ test('spells a block directive as its node type, arg and attributes', () => {
test('carries a directive attribute no section spells', () => { test('carries a directive attribute no section spells', () => {
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node))) const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```carry\n{\n "attrs": {\n "rounded": true\n },\n "type": "panel"\n}\n```\n') assert.equal(carried({ attrs: { rounded: true }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "rounded": true\n }\n}\n```\n')
assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```carry\n{\n "attrs": {\n "toString": "x"\n },\n "type": "panel"\n}\n```\n') assert.equal(carried({ attrs: { toString: 'x' }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "toString": "x"\n }\n}\n```\n')
assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```carry\n{\n "attrs": {\n "localId": 4\n },\n "type": "panel"\n}\n```\n') assert.equal(carried({ attrs: { localId: 4 }, type: 'panel' }), '```adf:panel\n{\n "attrs": {\n "localId": 4\n }\n}\n```\n')
assert.equal( assert.equal(
carried({ attrs: { width: '50' }, type: 'layoutColumn' }), carried({ attrs: { width: '50' }, type: 'layoutColumn' }),
'```carry\n{\n "attrs": {\n "width": "50"\n },\n "type": "layoutColumn"\n}\n```\n', '```adf:layoutColumn\n{\n "attrs": {\n "width": "50"\n }\n}\n```\n',
) )
assert.equal( assert.equal(
carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }), carried({ attrs: { isNumberColumnEnabled: 'true' }, type: 'table' }),
'```carry\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n },\n "type": "table"\n}\n```\n', '```adf:table\n{\n "attrs": {\n "isNumberColumnEnabled": "true"\n }\n}\n```\n',
) )
assert.equal( assert.equal(
carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }), carried({ content: [{ attrs: { alt: 4 }, type: 'media' }], type: 'mediaGroup' }),
'!adf:mediaGroup\n```carry\n{\n "attrs": {\n "alt": 4\n },\n "type": "media"\n}\n```\n!adf:/mediaGroup\n', '!adf:mediaGroup\n```adf:media\n{\n "attrs": {\n "alt": 4\n }\n}\n```\n!adf:/mediaGroup\n',
) )
}) })
@@ -574,15 +575,16 @@ test('carries an arg slot value no bare token spells', () => {
const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node))) const carried = (node: AdfNode): string => markdown(adfToMarkdown(document(node)))
assert.equal( assert.equal(
carried({ attrs: { panelType: 'extra info' }, type: 'panel' }), carried({ attrs: { panelType: 'extra info' }, type: 'panel' }),
'```carry\n{\n "attrs": {\n "panelType": "extra info"\n },\n "type": "panel"\n}\n```\n', '```adf:panel\n{\n "attrs": {\n "panelType": "extra info"\n }\n}\n```\n',
) )
assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```carry\n{\n "attrs": {\n "state": 2\n },\n "type": "taskItem"\n}\n```\n') assert.equal(carried({ attrs: { state: 2 }, type: 'taskItem' }), '```adf:taskItem\n{\n "attrs": {\n "state": 2\n }\n}\n```\n')
}) })
test('carries a block node mark in the reserved attribute', () => { test('carries a block node mark in the reserved attribute', () => {
const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' }) const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' })
assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n') assert.equal(markdown(adfToMarkdown(section({ type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n') assert.equal(markdown(adfToMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"attrs\\":{},\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n')
assert.equal(markdown(adfToMarkdown(section())), '!adf:layoutSection {marks=empty}\n!adf:/layoutSection\n')
}) })
test('refuses the content a directive body has no room for', () => { test('refuses the content a directive body has no room for', () => {
@@ -604,7 +606,7 @@ test('separates blocks in a container body by a blank line only where a directiv
test('spells the image form for exactly the centered external media shape', () => { test('spells the image form for exactly the centered external media shape', () => {
const url = 'https://example.com/moon.png' const url = 'https://example.com/moon.png'
const single = (attrs: AdfAttributes, ...content: AdfNode[]): AdfDocument => const single = (attrs: AdfAttributes, ...content: AdfNode[]): AdfDocument =>
document({ attrs: { layout: 'center' }, content: [{ attrs, content, type: 'media' }], type: 'mediaSingle' }) document({ attrs: { layout: 'center' }, content: [content.length === 0 ? { attrs, type: 'media' } : { attrs, content, type: 'media' }], type: 'mediaSingle' })
assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `![The moon](${url})\n`) assert.equal(markdown(adfToMarkdown(single({ alt: 'The moon', type: 'external', url }))), `![The moon](${url})\n`)
assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `![](${url})\n`) assert.equal(markdown(adfToMarkdown(single({ type: 'external', url }))), `![](${url})\n`)
assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`) assert.equal(markdown(adfToMarkdown(single({ alt: 'a [b] c', type: 'external', url }))), `![a \\[b\\] c](${url})\n`)
@@ -692,8 +694,12 @@ test('refuses the content and slot an inline directive has no room for', () => {
assert.equal(refused({ text: 'x', type: 'status' }), neither('status', 'text')) assert.equal(refused({ text: 'x', type: 'status' }), neither('status', 'text'))
assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), neither('hardBreak', 'content')) assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), neither('hardBreak', 'content'))
assert.equal(refused({ text: 'x', type: 'hardBreak' }), neither('hardBreak', 'text')) assert.equal(refused({ text: 'x', type: 'hardBreak' }), neither('hardBreak', 'text'))
assert.equal(refused({ attrs: { text: 'a\nb' }, type: 'status' }), 'unspellable-whitespace: the status content slot holds a newline no inline directive spans') })
assert.equal(refused({ attrs: { text: 'a\u0000b' }, type: 'status' }), 'unspellable-character: a status content slot holds a null character CommonMark replaces')
test('carries an inline node whose content slot holds a line ending or a null character', () => {
assert.equal(readsBack(document(paragraph({ attrs: { text: 'a\nb' }, type: 'status' }))), '!adf:carry{json="{\\"attrs\\":{\\"text\\":\\"a\\\\nb\\"},\\"type\\":\\"status\\"}"}\n')
readsBack(document(paragraph({ attrs: { text: 'a\rb' }, type: 'emoji' })))
readsBack(document(paragraph({ attrs: { id: 'x', text: 'a\u0000b' }, type: 'mention' })))
}) })
test('spells the directive marks around the longest run they cover', () => { test('spells the directive marks around the longest run they cover', () => {
@@ -701,7 +707,8 @@ test('spells the directive marks around the longest run they cover', () => {
const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content)))) const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content))))
const underline: AdfMark = { type: 'underline' } const underline: AdfMark = { type: 'underline' }
assert.equal(emitted(marked('x', underline)), '!adf:underline[x]\n') assert.equal(emitted(marked('x', underline)), '!adf:underline[x]\n')
assert.equal(emitted(marked('a', underline), marked('b', underline)), '!adf:underline[ab]\n') assert.equal(emitted(marked('a', underline), marked('b', underline)), '!adf:underline[a!adf:textBreak{}b]\n')
assert.equal(emitted(marked('a', { attrs: {}, type: 'underline' }), marked('b', underline)), '!adf:underline[a]{attrs=empty}!adf:underline[b]\n')
assert.equal(emitted(marked('x', { attrs: { type: 'sub' }, type: 'subsup' })), '!adf:subsup[x]{type=sub}\n') assert.equal(emitted(marked('x', { attrs: { type: 'sub' }, type: 'subsup' })), '!adf:subsup[x]{type=sub}\n')
assert.equal(emitted(marked('x', { attrs: { color: '#ae2e24' }, type: 'textColor' })), '!adf:textColor[x]{color="#ae2e24"}\n') assert.equal(emitted(marked('x', { attrs: { color: '#ae2e24' }, type: 'textColor' })), '!adf:textColor[x]{color="#ae2e24"}\n')
assert.equal(emitted(marked('x', { attrs: { color: '#091e42', size: 2 }, type: 'border' })), '!adf:border[x]{color="#091e42" size=2}\n') assert.equal(emitted(marked('x', { attrs: { color: '#091e42', size: 2 }, type: 'border' })), '!adf:border[x]{color="#091e42" size=2}\n')
@@ -746,5 +753,5 @@ test('carries whitespace CommonMark strips in the reserved text directive', () =
test("joins a mark run's segments as a walk rather than as one call's arguments", () => { test("joins a mark run's segments as a walk rather than as one call's arguments", () => {
const run = Array.from({ length: 200000 }, (): AdfNode => ({ marks: [{ type: 'strong' }], text: 'a', type: 'text' })) const run = Array.from({ length: 200000 }, (): AdfNode => ({ marks: [{ type: 'strong' }], text: 'a', type: 'text' }))
assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${'a'.repeat(200000)}**\n`) assert.equal(markdown(adfToMarkdown(document({ content: run, type: 'paragraph' }))), `**${Array.from({ length: 200000 }, () => 'a').join('!adf:textBreak{}')}**\n`)
}) })
+148 -79
View File
@@ -1,14 +1,16 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockNodeModel } from '../../adf/block-nodes.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import type { Flavour } from '../plain/conventions.ts'
import { blockDirectiveForm, listBreakSpelling } from '../block-directive.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 { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
import { carriedBlock } from '../opaque-carry.ts' import { carriedBlock } from '../opaque-carry.ts'
import { emitInlineLine } from './inline-line.ts' import { emitInlineLine } from './inline-line.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { fencedCodeBlock } from '../commonmark/backtick-runs.ts' import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts' import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts'
import { languageSlot } from '../code-language.ts' import { languageSlot, type LanguageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { spellBlockDirectiveOpener } from './block-directive-spelling.ts' import { spellBlockDirectiveOpener } from './block-directive-spelling.ts'
import { spellDirectiveCloser, spellDirectiveOpener } from '../directive-syntax.ts' import { spellDirectiveCloser, spellDirectiveOpener } from '../directive-syntax.ts'
@@ -20,32 +22,38 @@ type BlockSpelling = 'commonmark' | 'directive' | 'list'
type EmittedBlock = { headroom: number; spelling: BlockSpelling; text: string } type EmittedBlock = { headroom: number; spelling: BlockSpelling; text: string }
type KeptSpelling = { block: EmittedBlock | undefined; depth: number } type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
type PlacedBlock = Omit<EmittedBlock, 'headroom'> & { node: AdfNode } type PlacedBlock = Omit<EmittedBlock, 'headroom'> & { node: AdfNode }
type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
// Keyed by reference: only a caller building one object per position (the parse, the plain reduction) passes one; a consumer's document may share a node. // Keyed by reference: only a caller building one object per position (the parse, the plain reduction) passes one; a consumer's document may share a node.
export type SpellingMemo = Map<AdfNode, KeptSpelling> export type SpellingMemo = Map<AdfNode, KeptSpelling>
type Walk = { blocks: readonly PlacedBlock[]; headroom: number } type WalkedItem = { node: AdfNode; walk: PlacedBlocks }
type WalkedItem = { node: AdfNode; walk: Walk } export type Writing = { flavour: Flavour; memo: SpellingMemo | undefined }
export const largestListMarker = 999999999 export const largestListMarker = 999999999
// Bare because tryList admits no item carrying attributes, marks or text. // Bare because tryList admits no item carrying attributes, marks or text.
const listItemOpener = spellDirectiveOpener('listItem', undefined, '') const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
export function adfToMarkdown(document: AdfDocument): Result<string> { export function adfToMarkdown(document: AdfDocument): Result<string> {
return writeMarkdown(document, 'lossless')
}
export function writeMarkdown(document: AdfDocument, flavour: Flavour): Result<string> {
const fault = adfDocumentFault(document) const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, []) if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const walk = walkBlocks(nodeContent(document), [], 0, undefined) if (document.content === undefined) return success(`${documentSpelling}\n`)
const walk = walkBlocks(document.content, [], 0, { flavour, memo: undefined })
if (!walk.ok) return walk if (!walk.ok) return walk
const text = joinBlocks(walk.value.blocks, 'document') const text = joinBlocks(walk.value.blocks, 'document')
return success(text === '' ? '' : `${text}\n`) return success(text === '' ? '' : `${text}\n`)
} }
// headroom: the least slack any depth guard below the walk has. // headroom: the least slack any depth guard below the walk has.
function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<Walk> { function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
let headroom = largestNesting - depth let headroom = largestNesting - depth
if (headroom < 0) return tooDeep(path) if (headroom < 0) return tooDeep(path)
const blocks: PlacedBlock[] = [] const blocks: PlacedBlock[] = []
for (const [index, node] of nodes.entries()) { for (const [index, node] of nodes.entries()) {
const block = emitBlock(node, [...path, 'content', index], depth, memo) const block = emitBlock(node, [...path, 'content', index], depth, writing)
if (!block.ok) return block if (!block.ok) return block
headroom = Math.min(headroom, block.value.headroom) headroom = Math.min(headroom, block.value.headroom)
blocks.push({ ...block.value, node }) blocks.push({ ...block.value, node })
@@ -68,64 +76,133 @@ function joinBlocks(blocks: readonly PlacedBlock[], container: BlockContainer):
} }
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string { function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string {
const plainPair = previous.spelling !== 'directive' && next.spelling !== 'directive' const bothCommonMark = previous.spelling !== 'directive' && next.spelling !== 'directive'
if (plainPair && next.spelling === 'list') { if (bothCommonMark && next.spelling === 'list') {
if (previous.spelling === 'list' && previous.node.type === next.node.type) { if (previous.spelling === 'list' && previous.node.type === next.node.type) {
const gap = container === 'directive' ? '\n' : '\n\n' const gap = container === 'directive' ? '\n' : '\n\n'
return `${gap}${listBreakSpelling}${gap}` return `${gap}${listBreakSpelling}${gap}`
} }
if (container === 'list-item') return interruptsParagraph(next.node) ? '\n' : '\n\n' if (container === 'list-item') return interruptsParagraph(next.node) ? '\n' : '\n\n'
} }
return container === 'directive' && !plainPair ? '\n' : '\n\n' return container === 'directive' && !bothCommonMark ? '\n' : '\n\n'
} }
function interruptsParagraph(node: AdfNode): boolean { function interruptsParagraph(node: AdfNode): boolean {
const items = nodeContent(node) const items = nodeContent(node)
const empty = items[0] === undefined || nodeContent(items[0]).length === 0 const empty = node.type !== 'taskList' && (items[0] === undefined || nodeContent(items[0]).length === 0)
if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty) if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty)
return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty) return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty)
} }
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> { function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const model = blockNodeModel(node.type) const model = blockNodeModel(node.type)
if (model === undefined) return commonMarkLine(carriedBlock(node, path, depth)) if (model === undefined) return commonMarkLine(carriedBlock(node, path, depth))
const readable = readableBlock(node, path, depth, memo) const readable = readableBlock(node, path, depth, writing)
if (readable !== undefined) return readable if (readable !== undefined) return readable
return emitDirectiveBlock(node, 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 { export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<null> | undefined {
const readable = readableBlock(node, path, depth, memo) const readable = readableBlock(node, path, depth, writing)
if (readable === undefined) return undefined if (readable === undefined) return undefined
if (!readable.ok) return readable if (!readable.ok) return readable
return readable.value.spelling === 'directive' ? undefined : success(null) return readable.value.spelling === 'directive' ? undefined : success(null)
} }
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined { function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
const { memo } = writing
const kept = memo?.get(node) const kept = memo?.get(node)
if (kept !== undefined) { if (kept !== undefined) {
if (kept.block === undefined) return undefined if (kept.block === undefined) return undefined
// A read below the fill would skip the depth guards the walk it replaces runs (docs/decisions.md §The spelling memo). // A read below the fill would skip the depth guards the walk it replaces runs (docs/decisions.md §The spelling memo).
if (depth <= kept.depth) return success({ ...kept.block, headroom: kept.block.headroom + kept.depth - depth }) if (depth <= kept.depth) return success({ ...kept.block, headroom: kept.block.headroom + kept.depth - depth })
} }
const spelled = spellReadableBlock(node, path, depth, memo) const spelled = spellReadableBlock(node, path, depth, writing)
if (spelled === undefined) memo?.set(node, { block: undefined, depth }) if (spelled === undefined) memo?.set(node, { block: undefined, depth })
else if (spelled.ok) memo?.set(node, { block: spelled.value, depth }) else if (spelled.ok) memo?.set(node, { block: spelled.value, depth })
return spelled return spelled
} }
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, memo: SpellingMemo | undefined): Result<EmittedBlock> | undefined { function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
if (node.type === 'blockquote') return tryBlockquote(node, path, depth, memo) const plain = writing.flavour === 'plain' ? spellPlainBlock(node, path, depth, writing) : undefined
if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, memo) if (plain !== undefined) return plain
if (node.type === 'codeBlock') return tryCodeBlock(node, path) if (node.type === 'blockquote') return tryBlockquote(node, path, depth, writing)
if (node.type === 'heading') return tryHeading(node, path) if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, writing)
if (node.type === 'mediaSingle') return readableText(tryImage(node, path)) if (node.type === 'codeBlock') return readableText(tryCodeBlock(node))
if (node.type === 'paragraph') return tryParagraph(node, path) 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 === 'rule') return readableText(tryRule(node))
if (node.type === 'table') return readableText(tryPipeTable(node, path)) if (node.type === 'table') return readableText(tryPipeTable(node, path, writing.flavour))
return undefined return undefined
} }
// The plain flavour's nodes, in the shapes the plain reduction leaves them.
function spellPlainBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
if (node.type === 'panel') return quotedUnder(alertMarker(nodeAttrs(node)['panelType']), node, path, depth, writing)
if (node.type === 'taskList') return tryTaskList(node, path, depth, writing)
if (node.type !== 'expand' && node.type !== 'nestedExpand') return undefined
const title = nodeAttrs(node)['title']
if (typeof title !== 'string') return quotedUnder(foldedAlertMarker, node, path, depth, writing)
// The reader takes a title as lossless inline text.
const line = emitInlineLine([{ text: title, type: 'text' }], 'paragraph', path, 'lossless')
return line.ok ? quotedUnder(`${foldedAlertMarker} ${line.value}`, node, path, depth, writing) : line
}
function quotedUnder(head: string, node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
if (!inner.ok) return inner
const body = joinBlocks(inner.value.blocks, 'document')
return success(commonMarkText(quoted(body === '' ? head : `${head}\n\n${body}`), inner.value.headroom))
}
function quoted(text: string): string {
return text
.split('\n')
.map((line) => (line === '' ? '>' : `> ${line}`))
.join('\n')
}
function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const items: PlacedBlock[][] = []
let headroom = largestNesting - depth - 1
// A child other than a task nests in the task before it.
for (const [index, child] of nodeContent(node).entries()) {
const task = child.type === 'taskItem' || child.type === 'blockTaskItem'
const walk = task ? taskBlocks(child, [...path, 'content', index], depth + 1, writing) : placedBlock(child, [...path, 'content', index], depth + 1, writing)
if (!walk.ok) return walk
headroom = Math.min(headroom, walk.value.headroom)
const previous = items.at(-1)
if (task || previous === undefined) items.push([...walk.value.blocks])
else for (const block of walk.value.blocks) previous.push(block)
}
const lines = items.map((blocks) => tryListItemLines(joinBlocks(blocks, 'list-item'), '- '))
// The plain reduction leaves no task a list item cannot hold: a directive here would break the flavour.
return lines.includes(undefined) ? failure('unsupported-node-shape', 'a task holds blocks no list item spells', path) : success({ headroom, spelling: 'list', text: lines.join('\n') })
}
function placedBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
const block = emitBlock(node, path, depth, writing)
return block.ok ? success({ blocks: [{ ...block.value, node }], headroom: block.value.headroom }) : block
}
// The marker leads the first paragraph, or stands as one where the blocks open with another.
function taskBlocks(task: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
const marker = taskMarker(nodeAttrs(task)['state'])
const markerBlock: PlacedBlock = { node: { type: 'paragraph' }, spelling: 'commonmark', text: marker }
if (task.type === 'taskItem') {
const content = nodeContent(task)
const line = content.length === 0 ? success('') : emitInlineLine(content, 'paragraph', path, writing.flavour)
if (!line.ok) return line
return success({ blocks: [{ ...markerBlock, text: line.value === '' ? marker : `${marker} ${line.value}` }], headroom: Number.POSITIVE_INFINITY })
}
const walk = walkBlocks(nodeContent(task), path, depth, writing)
if (!walk.ok) return walk
const [first, ...rest] = walk.value.blocks
const blocks = first?.node.type === 'paragraph' ? [{ ...first, text: `${marker} ${first.text}` }, ...rest] : [markerBlock, ...walk.value.blocks]
return success({ blocks, headroom: walk.value.headroom })
}
function readableText(text: string | undefined): Result<EmittedBlock> | undefined { function readableText(text: string | undefined): Result<EmittedBlock> | undefined {
return text === undefined ? undefined : success(commonMarkText(text)) return text === undefined ? undefined : success(commonMarkText(text))
} }
@@ -143,7 +220,7 @@ function directivePair(node: AdfNode, opener: string, body: string, headroom: nu
return { headroom, spelling: 'directive', text: `${opener}\n${body === '' ? '' : `${body}\n`}${spellDirectiveCloser(node.type)}` } return { headroom, spelling: 'directive', text: `${opener}\n${body === '' ? '' : `${body}\n`}${spellDirectiveCloser(node.type)}` }
} }
function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number, walkBody: () => Result<Walk>): Result<EmittedBlock> { function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number, walkBody: () => Result<PlacedBlocks>): Result<EmittedBlock> {
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path) if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path) if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
if (model.contentModel === 'code') return emitCodeDirective(node, model, path, depth) if (model.contentModel === 'code') return emitCodeDirective(node, model, path, depth)
@@ -153,10 +230,10 @@ function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertE
return emitDirectiveBody(node, model, opener.value, path, walkBody) return emitDirectiveBody(node, model, opener.value, path, walkBody)
} }
function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string, path: ConvertErrorPath, walkBody: () => Result<Walk>): Result<EmittedBlock> { function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string, path: ConvertErrorPath, walkBody: () => Result<PlacedBlocks>): Result<EmittedBlock> {
if (blockDirectiveForm(node.type) === 'leaf') return success({ headroom: Number.POSITIVE_INFINITY, spelling: 'directive', text: opener }) if (blockDirectiveForm(node.type) === 'leaf') return success({ headroom: Number.POSITIVE_INFINITY, spelling: 'directive', text: opener })
if (model.contentModel === 'inline') { if (model.contentModel === 'inline') {
const line = emitInlineLine(nodeContent(node), 'paragraph', path) const line = emitInlineLine(nodeContent(node), 'paragraph', path, 'lossless')
if (!line.ok) return line if (!line.ok) return line
return success(directivePair(node, opener, line.value)) return success(directivePair(node, opener, line.value))
} }
@@ -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)) 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 { function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, [])) return undefined if (!holdsOnlyAttributes(node, [])) return undefined
const inner = walkBlocks(nodeContent(node), path, depth + 1, memo) const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
if (!inner.ok) return inner if (!inner.ok) return inner
const text = joinBlocks(inner.value.blocks, 'document') const text = joinBlocks(inner.value.blocks, 'document')
.split('\n') const alert = writing.flavour === 'plain' && leadingMarker(text, readAlertMarker) !== undefined
.map((line) => (line === '' ? '>' : `> ${line}`)) return success(commonMarkText(quoted(alert ? `\\${text}` : text), inner.value.headroom))
.join('\n')
return success(commonMarkText(text, inner.value.headroom))
} }
function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function tryCodeBlock(node: AdfNode): string | undefined {
if (!carriesOnly(node, ['language'])) return undefined if (!holdsOnlyAttributes(node, ['language'])) return undefined
const slot = languageSlot(nodeAttrs(node)['language']) const slot = languageSlot(nodeAttrs(node)['language'])
if (slot.kind === 'attribute') return undefined if (slot.kind === 'attribute') return undefined
const text = codeBlockText(node, path) const [only, ...others] = fencedTexts(node) ?? []
if (!text.ok) return text if (only === undefined || others.length > 0) return undefined
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value))) return fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', only)
} }
// spec/flavour.md, The CommonMark blocks: one fence per text node, the language on each; with no fence to carry it, the attribute does.
function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> { function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const slot = languageSlot(nodeAttrs(node)['language']) const texts = fencedTexts(node)
if (texts === undefined) return commonMarkLine(carriedBlock(node, path, depth))
const slot: LanguageSlot = texts.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language'])
const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language']) const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth)) if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (!opener.ok) return opener if (!opener.ok) return opener
const text = codeBlockText(node, path) const info = slot.kind === 'fence' ? slot.info : ''
if (!text.ok) return text return success(directivePair(node, opener.value, texts.map((text) => fencedCodeBlock(info, text)).join('\n')))
return success(directivePair(node, opener.value, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
} }
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> { // The text of each fence, or `undefined` where a child holds what no fence keeps, so the block carry takes the node.
let text = '' function fencedTexts(node: AdfNode): string[] | undefined {
for (const [index, child] of nodeContent(node).entries()) { if (node.content === undefined) return ['']
const childPath = [...path, 'content', index] const texts: string[] = []
if ( for (const child of node.content) {
child.type !== 'text' || if (!isUnmarkedBareText(child) || typeof child.text !== 'string' || child.text === '' || /\r/.test(child.text) || holdsNullCharacter(child.text)) return undefined
typeof child.text !== 'string' || texts.push(child.text)
child.text === '' ||
nodeContent(child).length > 0 ||
nodeMarks(child).length > 0 ||
Object.keys(nodeAttrs(child)).length > 0
) {
return failure('unsupported-node-shape', `a codeBlock holds plain text nodes only: this ${child.type} node is not one`, childPath)
} }
if (/\r/.test(child.text)) return failure('unspellable-character', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath) return texts
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
text += child.text
}
return success(text)
} }
function tryHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['level'])) return undefined if (!holdsOnlyAttributes(node, ['level'])) return undefined
const level = nodeAttrs(node)['level'] const level = nodeAttrs(node)['level']
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
const hashes = '#'.repeat(level) const hashes = '#'.repeat(level)
const content = nodeContent(node) const content = nodeContent(node)
if (content.length === 0) return success(commonMarkText(hashes)) if (content.length === 0) return success(commonMarkText(hashes))
const line = emitInlineLine(content, 'heading', path) const line = emitInlineLine(content, 'heading', path, flavour)
if (!line.ok) return line if (!line.ok) return line
return success(commonMarkText(`${hashes} ${line.value}`)) return success(commonMarkText(`${hashes} ${line.value}`))
} }
function 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' const ordered = node.type === 'orderedList'
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined if (!holdsOnlyAttributes(node, ordered ? ['order'] : [])) return undefined
const items = nodeContent(node) const items = nodeContent(node)
const start = listStart(node, items.length) const start = listStart(node, items.length)
if (start === undefined || items.length === 0) return undefined if (start === undefined || items.length === 0) return undefined
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined if (items.some((item) => item.type !== 'listItem' || !holdsOnlyAttributes(item, []))) return undefined
const walked: WalkedItem[] = [] const walked: WalkedItem[] = []
let headroom = Number.POSITIVE_INFINITY let headroom = Number.POSITIVE_INFINITY
for (const [offset, item] of items.entries()) { for (const [offset, item] of items.entries()) {
const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1, memo) const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1, writing)
if (!walk.ok) return walk if (!walk.ok) return walk
headroom = Math.min(headroom, walk.value.headroom) headroom = Math.min(headroom, walk.value.headroom)
walked.push({ node: item, walk: walk.value }) walked.push({ node: item, walk: walk.value })
} }
const lines: string[] = [] const lines: string[] = []
for (const [offset, item] of walked.entries()) { for (const [offset, item] of walked.entries()) {
const line = 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) { if (line === undefined) {
// The directive form spends a level the walk did not count. // The directive form spends a level the walk did not count.
if (headroom < 1) return tooDeep(path) if (headroom < 1) return tooDeep(path)
@@ -263,12 +333,11 @@ function directiveItems(items: readonly WalkedItem[]): PlacedBlock[] {
function listStart(node: AdfNode, items: number): number | undefined { function listStart(node: AdfNode, items: number): number | undefined {
if (node.type !== 'orderedList') return 0 if (node.type !== 'orderedList') return 0
const start = nodeAttrs(node)['order'] const start = nodeAttrs(node)['order']
if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) return undefined if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || Object.is(start, -0) || start > largestListMarker) return undefined
return start + items - 1 > largestListMarker ? undefined : start return start + items - 1 > largestListMarker ? undefined : start
} }
function tryListItemLines(blocks: readonly PlacedBlock[], marker: string): string | undefined { function tryListItemLines(inner: string, marker: string): string | undefined {
const inner = joinBlocks(blocks, 'list-item')
if (inner === '') return marker.trimEnd() if (inner === '') return marker.trimEnd()
const body = inner.split('\n') const body = inner.split('\n')
if (body.some((line) => line !== '' && isBlankLine(line))) return undefined if (body.some((line) => line !== '' && isBlankLine(line))) return undefined
@@ -278,15 +347,15 @@ function tryListItemLines(blocks: readonly PlacedBlock[], marker: string): strin
return lines.join('\n') 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) const content = nodeContent(node)
if (content.length === 0 || !carriesOnly(node, [])) return undefined if (content.length === 0 || !holdsOnlyAttributes(node, [])) return undefined
const line = emitInlineLine(content, 'paragraph', path) const line = emitInlineLine(content, 'paragraph', path, flavour)
if (!line.ok) return line if (!line.ok) return line
return success(commonMarkText(line.value)) return success(commonMarkText(line.value))
} }
function tryRule(node: AdfNode): string | undefined { function tryRule(node: AdfNode): string | undefined {
if (!carriesOnly(node, []) || nodeContent(node).length > 0) return undefined if (!holdsOnlyAttributes(node, []) || nodeContent(node).length > 0) return undefined
return '---' return '---'
} }
@@ -5,6 +5,7 @@ import { blockArgument, markValues, marksAttribute } from '../block-directive.ts
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { overNested } from '../../json-value.ts' import { overNested } from '../../json-value.ts'
import { spellEmptyKeys } from '../empty-keys.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, spelledByBody: readonly string[] = []): Result<string> | undefined { export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, spelledByBody: readonly string[] = []): Result<string> | undefined {
@@ -14,7 +15,7 @@ export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel,
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody] const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, spelled) const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, spelled)
if (pairs === undefined) return undefined if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs) const spelledPairs = [...spellVocabulary(pairs), ...spellEmptyKeys(node)]
const marks = nodeMarks(node) const marks = nodeMarks(node)
if (marks.length > 0) { if (marks.length > 0) {
const values = markValues(marks) const values = markValues(marks)
+5 -6
View File
@@ -1,23 +1,22 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { ConvertErrorPath } from '../../result.ts' import { holdsOnlyAttributes, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts' import { serializeCanonicalJson } from '../../canonical-json.ts'
import { tryImageLine } from './inline-line.ts' import { tryImageLine } from './inline-line.ts'
const centeredMediaSingle = '{"layout":"center"}' const centeredMediaSingle = '{"layout":"center"}'
const imageAttributes = ['alt', 'type', 'url'] const imageAttributes = ['alt', 'type', 'url']
export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefined { export function tryImage(node: AdfNode): string | undefined {
const image = imageShape(node) const image = imageShape(node)
if (image === undefined) return undefined if (image === undefined) return undefined
return tryImageLine(image.alt, image.url, [...path, 'content', 0]) return tryImageLine(image.alt, image.url)
} }
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined { function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
const content = nodeContent(node) const content = nodeContent(node)
const media = content[0] const media = content[0]
if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined if (!holdsOnlyAttributes(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined
if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || nodeContent(media).length > 0) return undefined if (media === undefined || content.length !== 1 || media.type !== 'media' || !holdsOnlyAttributes(media, imageAttributes) || nodeContent(media).length > 0) return undefined
const attrs = nodeAttrs(media) const attrs = nodeAttrs(media)
const alt = attrs['alt'] const alt = attrs['alt']
const url = attrs['url'] const url = attrs['url']
@@ -2,9 +2,10 @@ import type { AdfNode } from '../../adf/document.ts'
import type { InlineNodeModel } from '../../adf/inline-nodes.ts' import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
import { nodeAttrs } from '../../adf/document.ts' import { nodeAttrs } from '../../adf/document.ts'
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts' import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
import { spellEmptyKeys } from '../empty-keys.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellInlineNodeAttributes(node: AdfNode, model: InlineNodeModel): string | undefined { export function spellInlineNodeAttributes(node: AdfNode, model: InlineNodeModel): string | undefined {
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, model.textAttribute === undefined ? [] : [model.textAttribute]) const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, model.textAttribute === undefined ? [] : [model.textAttribute])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) return pairs === undefined ? undefined : spellAttributes([...spellVocabulary(pairs), ...spellEmptyKeys(node)])
} }
+86 -76
View File
@@ -1,18 +1,20 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { Flavour } from '../plain/conventions.ts'
import type { InlineNodeModel } from '../../adf/inline-nodes.ts' import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
import type { LineContainer } from '../line-container.ts' import type { LineContainer } from '../line-container.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts' import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts'
import { carriedInline } from '../opaque-carry.ts' import { carriedInline } from '../opaque-carry.ts'
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts' import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts' import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { identicalMark, isBareText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { highlightDelimiter } from '../plain/conventions.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../commonmark/backtick-runs.ts' import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
import { sameMark } from '../../adf/editor-normal.ts'
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
import { spellTextDirective } from '../text-directive.ts' import { spellTextDirective } from '../text-directive.ts'
@@ -24,6 +26,7 @@ type InlineContext = {
atBlockEnd: boolean atBlockEnd: boolean
bracketed: boolean bracketed: boolean
carried: ReadonlySet<number> carried: ReadonlySet<number>
flavour: Flavour
openingLinkAsDirective: boolean openingLinkAsDirective: boolean
path: ConvertErrorPath path: ConvertErrorPath
spansLines: boolean spansLines: boolean
@@ -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 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> { export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<string> {
const emitted = emitLine(nodes, container, path) const emitted = emitLine(nodes, container, path, flavour)
if (!emitted.ok) return emitted if (!emitted.ok) return emitted
return success(emitted.value.line) return success(emitted.value.line)
} }
export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<boolean> { export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<boolean> {
const emitted = emitLine(nodes, 'paragraph', path) const emitted = emitLine(nodes, 'paragraph', path, 'lossless')
if (!emitted.ok) return emitted if (!emitted.ok) return emitted
return success(emitted.value.openingLinkAsDirective) return success(emitted.value.openingLinkAsDirective)
} }
export function plainLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<PlainLineFallback | undefined> { 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.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) 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) return success(verdict.kind === 'line' ? undefined : verdict)
} }
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined { export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, flavour: Flavour): string | undefined {
const emitted = emitLine(nodes, 'table-cell', path) const emitted = emitLine(nodes, 'table-cell', path, flavour)
if (!emitted.ok) return undefined if (!emitted.ok) return undefined
if (emitted.value.segments.some((segment) => isSyntax(segment.escaping) && segment.text.includes('|'))) return undefined if (emitted.value.segments.some((segment) => isSyntax(segment.escaping) && segment.text.includes('|'))) return undefined
return emitted.value.line return emitted.value.line
} }
export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined { export function tryImageLine(alt: string | undefined, href: string): string | undefined {
if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined
const destination = spellDestination(href) const destination = spellDestination(href)
if (destination === undefined) return undefined if (destination === undefined) return undefined
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }] const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
const attempt = attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', path) return attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', 'lossless').line
return attempt.ok ? attempt.value.line : undefined
} }
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<EmittedLine> { function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<EmittedLine> {
const fallbacks: LineFallbacks = { carried: new Set(), openingLinkAsDirective: false } const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false }
// Terminates because takeFallback refuses a pass that took no new fallback. // Terminates because takeFallback refuses a pass that took no new fallback.
for (;;) { for (;;) {
const emission = lineSegments(nodes, container, path, fallbacks) const emission = lineSegments(nodes, container, path, fallbacks)
@@ -84,12 +86,11 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con
if (!taken.ok) return taken if (!taken.ok) return taken
continue continue
} }
const attempt = attemptLine(emission.value.segments, container, path) const attempt = attemptLine(emission.value.segments, container, flavour)
if (!attempt.ok) return attempt if (attempt.line !== undefined) {
if (attempt.value.line !== undefined) { return success({ line: attempt.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
return success({ line: attempt.value.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
} }
const taken = takeFallback(fallbacks, attempt.value.fallback, path) const taken = takeFallback(fallbacks, attempt.fallback, path)
if (!taken.ok) return taken if (!taken.ok) return taken
} }
} }
@@ -111,54 +112,51 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
const emission = emitRun(nodes, 0, 0, context) const emission = emitRun(nodes, 0, 0, context)
if (!emission.ok) return emission if (!emission.ok) return emission
if (emission.value.carry !== undefined) return emission if (emission.value.carry !== undefined) return emission
return success({ segments: carryStrippedWhitespace(emission.value.segments) }) return success({ segments: spellEdgeWhitespace(emission.value.segments) })
} }
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> { function attemptLine(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): LineAttempt {
const verdict = lineVerdict(segments, container) const verdict = lineVerdict(segments, container, flavour)
if (verdict.kind === 'opening-link') return success({ fallback: 'opening-link' }) if (verdict.kind === 'opening-link') return { fallback: 'opening-link' }
if (verdict.kind === 'unspellable-run') return success({ fallback: verdict.run }) if (verdict.kind === 'unspellable-run') return { fallback: verdict.runs[0] }
if (verdict.kind === 'claimed-line') return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(verdict.text)}`, path) return { line: verdict.text }
return success({ line: verdict.text })
} }
// The fallbacks in the order a line takes them, or the line where it takes none. // The fallbacks in the order a line takes them, or the line where it takes none.
function lineVerdict(segments: readonly InlineSegment[], container: LineContainer): PlainLineFallback | { kind: 'line'; text: string } { function lineVerdict(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): PlainLineFallback | { kind: 'line'; text: string } {
const assembled = assembleInlineLine(segments, container) const assembled = assembleInlineLine(segments, container, flavour)
if (assembled.openingLinkAsDirective) return { kind: 'opening-link' } if (assembled.openingLinkAsDirective) return { kind: 'opening-link' }
if (assembled.unspellableRun !== undefined) return { kind: 'unspellable-run', run: assembled.unspellableRun } const [run, ...others] = assembled.unspellableRuns
const lines = assembled.line.split('\n') return run === undefined ? { kind: 'line', text: assembled.line } : { kind: 'unspellable-run', runs: [run, ...others] }
const claimed = container === 'paragraph' ? lines.findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later')) : -1
return claimed === -1 ? { kind: 'line', text: assembled.line } : { kind: 'claimed-line', line: claimed, text: lines[claimed] ?? '' }
} }
// spec/flavour.md, Inline nodes. // spec/flavour.md, Inline nodes.
function carryStrippedWhitespace(segments: readonly InlineSegment[]): InlineSegment[] { function spellEdgeWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
const carried: InlineSegment[] = [] const spelled: InlineSegment[] = []
for (const [index, segment] of segments.entries()) { for (const [index, segment] of segments.entries()) {
const previous = segments[index - 1] const previous = segments[index - 1]
const next = segments[index + 1] const next = segments[index + 1]
const leading = previous === undefined || previous.text.includes('\n') const leading = previous === undefined || previous.text.includes('\n')
const trailing = next === undefined || next.text.includes('\n') const trailing = next === undefined || next.text.includes('\n')
carried.push(...carryEdges(segment, leading, trailing)) spelled.push(...edgeWhitespaceSegments(segment, leading, trailing))
} }
return carried return spelled
} }
function carryEdges(segment: InlineSegment, leading: boolean, trailing: boolean): InlineSegment[] { function edgeWhitespaceSegments(segment: InlineSegment, leading: boolean, trailing: boolean): InlineSegment[] {
if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment] if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment]
const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : '' const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : ''
const body = segment.text.slice(head.length) const body = segment.text.slice(head.length)
const middle = trailing ? trimTrailingSpace(body) : body const middle = trailing ? trimTrailingSpace(body) : body
const tail = body.slice(middle.length) const tail = body.slice(middle.length)
const edges: InlineSegment[] = [] const edges: InlineSegment[] = []
if (head !== '') edges.push(carriedText(head)) if (head !== '') edges.push(textDirectiveSegment(head))
if (middle !== '') edges.push({ escaping: segment.escaping, text: middle }) if (middle !== '') edges.push({ escaping: segment.escaping, text: middle })
if (tail !== '') edges.push(carriedText(tail)) if (tail !== '') edges.push(textDirectiveSegment(tail))
return edges return edges
} }
function carriedText(text: string): InlineSegment { function textDirectiveSegment(text: string): InlineSegment {
return syntax(spellTextDirective(text)) return syntax(spellTextDirective(text))
} }
@@ -182,6 +180,7 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c
const runs = inlineRuns(nodes, depth, firstIndex, context.carried) const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
const segments: InlineSegment[] = [] const segments: InlineSegment[] = []
for (const [offset, run] of runs.entries()) { for (const [offset, run] of runs.entries()) {
if (takesTextBreak(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling))
const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 } const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 }
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext) const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
if (!emitted.ok) return emitted if (!emitted.ok) return emitted
@@ -202,19 +201,30 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
continue continue
} }
const previous = runs[runs.length - 1] const previous = runs[runs.length - 1]
if (previous?.kind === 'marked' && sameMark(previous.mark, mark)) previous.nodes.push(node) if (previous?.kind === 'marked' && identicalMark(previous.mark, mark)) previous.nodes.push(node)
else runs.push({ index, kind: 'marked', mark, nodes: [node] }) else runs.push({ index, kind: 'marked', mark, nodes: [node] })
} }
return runs return runs
} }
function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean {
if (previous?.kind !== 'plain' || run.kind !== 'plain') return false
if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false
return joinsWhenRead(previous.node, run.node)
}
function nodePath(context: InlineContext, index: number): ConvertErrorPath { function nodePath(context: InlineContext, index: number): ConvertErrorPath {
return [...context.path, 'content', index] return [...context.path, 'content', index]
} }
// Decides the text-node and slot-fault carries up front, so a line holding many of them is emitted once.
function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean { function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
if (carried.has(index)) return true if (carried.has(index)) return true
return node.type !== 'text' && inlineNodeModel(node.type) === undefined if (node.type === 'text') return !isBareText(node) || typeof node.text !== 'string' || node.text === ''
const model = inlineNodeModel(node.type)
if (model === undefined) return true
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
return typeof slot === 'string' && slotFault(node.type, slot) !== undefined && nodeContent(node).length === 0 && node.text === undefined
} }
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> { function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> {
@@ -227,7 +237,7 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
const types = nodeMarks(node).map((mark) => mark.type) const types = nodeMarks(node).map((mark) => mark.type)
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path) if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
const model = inlineNodeModel(node.type) const model = inlineNodeModel(node.type)
if (model === undefined) return emitText(node, context, index, path) if (model === undefined) return success(emitText(node, context, index))
if (node.type === 'hardBreak') return emitHardBreak(node, model, context, index, path) if (node.type === 'hardBreak') return emitHardBreak(node, model, context, index, path)
return emitInlineDirective(node, model, index, path) return emitInlineDirective(node, model, index, path)
} }
@@ -248,33 +258,27 @@ function emitInlineDirective(node: AdfNode, model: InlineNodeModel, index: numbe
if (attributes === undefined) return success({ carry: { first: index, last: index } }) if (attributes === undefined) return success({ carry: { first: index, last: index } })
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute] const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
if (slot === undefined) return success({ segments: [syntax(spellInlineLeafDirective(node.type, attributes))] }) if (slot === undefined) return success({ segments: [syntax(spellInlineLeafDirective(node.type, attributes))] })
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } }) if (typeof slot !== 'string' || slotFault(node.type, slot) !== undefined) return success({ carry: { first: index, last: index } })
const spans = slotLineEndingFault(node.type, slot)
if (spans !== undefined) return faulted(spans, path)
if (holdsNullCharacter(slot)) return failure('unspellable-character', `a ${node.type} content slot holds a null character CommonMark replaces`, path)
const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }] const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }]
return success({ segments: [syntax(spellInlineDirectiveOpener(node.type)), ...content, syntax(`]${attributes}`)] }) return success({ segments: [syntax(spellInlineDirectiveOpener(node.type)), ...content, syntax(`]${attributes}`)] })
} }
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> { // spec/flavour.md, Text CommonMark cannot hold plainly.
if (Object.keys(nodeAttrs(node)).length > 0) return success({ carry: { first: index, last: index } }) function emitText(node: AdfNode, context: InlineContext, index: number): Emission {
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path) if (!isBareText(node) || typeof node.text !== 'string' || node.text === '') return { carry: { first: index, last: index } }
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash' const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
const parts = node.text.split(/(\n+)/).filter((part) => part !== '') const parts = node.text.split(/(\n+|\0+|\r)/).filter((part) => part !== '')
return success({ segments: parts.map((part) => (part.startsWith('\n') ? carriedText(part) : { escaping, text: part })) }) return { segments: parts.map((part) => (part === '\r' ? syntax('&#13;') : /^[\n\0]/.test(part) ? textDirectiveSegment(part) : { escaping, text: part })) }
} }
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> { function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
const path = nodePath(context, index)
const range: NodeRange = { first: index, last: index + nodes.length - 1 } const range: NodeRange = { first: index, last: index + nodes.length - 1 }
if (mark.type === 'backgroundColor' && context.flavour === 'plain') return emitHighlight(nodes, depth, range, context)
const spelling = markSpelling(mark.type) const spelling = markSpelling(mark.type)
if (spelling === undefined) return success({ carry: range }) if (spelling === undefined) return success({ carry: range })
const attributes = spellMarkAttributes(mark, spelling.attributes) const attributes = spellMarkAttributes(mark, spelling)
if (attributes === undefined) return success({ carry: range }) if (attributes === undefined) return success({ carry: range })
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path) if (spelling.kind === 'code') return success(emitCodeSpan(nodes, depth, range))
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context) if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined
if (link !== undefined) return link if (link !== undefined) return link
@@ -288,29 +292,35 @@ function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number
const inner = emitRun(nodes, depth + 1, range.first, context) const inner = emitRun(nodes, depth + 1, range.first, context)
if (!inner.ok) return inner if (!inner.ok) return inner
if (inner.value.carry !== undefined) return inner if (inner.value.carry !== undefined) return inner
const carried = carryStrippedWhitespace(inner.value.segments) const spelled = spellEdgeWhitespace(inner.value.segments)
return success({ return success({
segments: [ segments: [
{ emphasis: 'open', escaping: 'none', nodes: { ...range, depth }, text: spelling }, { emphasis: 'open', escaping: 'none', nodes: { ...range, depth }, text: spelling },
...carried, ...spelled,
{ emphasis: 'close', escaping: 'none', nodes: { ...range, depth }, text: spelling }, { emphasis: 'close', escaping: 'none', nodes: { ...range, depth }, text: spelling },
], ],
}) })
} }
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> { function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRange, context: InlineContext): Result<Emission> {
let text = '' const inner = emitRun(nodes, depth + 1, range.first, context)
for (const node of nodes) { if (!inner.ok || inner.value.carry !== undefined) return inner
if (node.type !== 'text' || nodeMarks(node).length !== depth + 1) return success({ carry: range }) const run = { ...range, depth }
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path) return success({
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path) segments: [{ escaping: 'none', highlight: 'open', nodes: run, text: highlightDelimiter }, ...inner.value.segments, { escaping: 'none', highlight: 'close', nodes: run, text: highlightDelimiter }],
text += node.text })
} }
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) // 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) {
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) const fence = '`'.repeat(longestBacktickRun(text) + 1)
const padded = needsPadding(text) ? ` ${text} ` : text spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`)
return success({ segments: [syntax(`${fence}${padded}${fence}`)] }) }
return { segments: [syntax(spans.join(textBreakSpelling))] }
} }
function needsPadding(text: string): boolean { 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 type { LineContainer } from '../line-container.ts'
import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark/grammar.ts' import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark/grammar.ts'
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts' import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
import { claimsDirectivePrefix } from '../directive-syntax.ts' import { claimsDirectivePrefix } from '../directive-syntax.ts'
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts' import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
import { highlightDelimiter, highlightFlanking } from '../plain/conventions.ts'
import { isBareDelimiterRow } from '../pipe-table-syntax.ts' import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts' import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
import { readEntityReference } from '../commonmark/entity-references.ts' import { readEntityReference } from '../commonmark/entity-references.ts'
export type EmphasisRole = 'close' | 'open' export type DelimiterRole = 'close' | 'open'
export type InlineEscaping = 'backslash' | 'bracketed' | 'bracketed-link-target' | 'none' export type InlineEscaping = 'backslash' | 'bracketed' | 'bracketed-link-target' | 'none'
@@ -16,11 +18,12 @@ export type NodeRange = { first: number; last: number }
export type MarkRun = NodeRange & { depth: number } export type MarkRun = NodeRange & { depth: number }
export type InlineSegment = export type InlineSegment =
| { emphasis: EmphasisRole; escaping: 'none'; nodes: MarkRun; text: string } | { emphasis: DelimiterRole; escaping: 'none'; highlight?: undefined; nodes: MarkRun; text: string }
| { emphasis?: undefined; escaping: 'none'; nodes: NodeRange; text: string } | { emphasis?: undefined; escaping: 'none'; highlight: DelimiterRole; nodes: MarkRun; text: string }
| { emphasis?: undefined; escaping: InlineEscaping; nodes?: undefined; 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 } type ScanLine = { position: LinePosition; start: number; text: string }
@@ -32,8 +35,8 @@ const delimiters = ['*', '_', '`', '~']
const followsLinkText = /[([]/ const followsLinkText = /[([]/
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine { export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): AssembledLine {
return escape(resolveEmphasis(segments), container) return escape(resolveEmphasis(segments), container, flavour === 'plain')
} }
function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] { function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
@@ -65,11 +68,11 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
return resolved return resolved
} }
function escape(segments: readonly InlineSegment[], container: LineContainer): AssembledLine { function escape(segments: readonly InlineSegment[], container: LineContainer, highlights: boolean): AssembledLine {
const scan = segments.map((segment) => segment.text).join('') const scan = segments.map((segment) => segment.text).join('')
const escapings: InlineEscaping[] = [] const escapings: InlineEscaping[] = []
for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) escapings.push(segment.escaping) for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) escapings.push(segment.escaping)
const escaped = escapeClosedRuns(scan, escapings, escapeClaims(scan, escapings, container)) const escaped = escapeClosedRuns(scan, escapings, escapeClaims(scan, escapings, container, highlights))
const placements: number[] = [] const placements: number[] = []
let output = '' let output = ''
for (let index = 0; index < scan.length; index += 1) { for (let index = 0; index < scan.length; index += 1) {
@@ -78,34 +81,45 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
output += scan.charAt(index) output += scan.charAt(index)
} }
if (container === 'paragraph' && opensLinkDefinition(output)) { if (container === 'paragraph' && opensLinkDefinition(output)) {
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRun: undefined } if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRuns: [] }
return { line: `\\${output}`, unspellableRun: unspellableRun(segments, output, placements) } return { line: `\\${output}`, unspellableRuns: unspellableRuns(segments, output, placements) }
} }
return { line: output, unspellableRun: unspellableRun(segments, output, placements) } return { line: output, unspellableRuns: unspellableRuns(segments, output, placements) }
} }
function 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 escaped = new Set<number>()
const linkClose = lastLinkClose(scan, escapings) const linkClose = lastLinkClose(scan, escapings)
let line = scanLine(scan, 0) let line = scanLine(scan, 0)
let afterEscape = false let afterEscape = false
// Whether the `=` before opens a `==` the reader takes whole, so this one starts nothing.
let pairsEquals = false
for (let index = 0; index < scan.length; index += 1) { for (let index = 0; index < scan.length; index += 1) {
if (index > line.start + line.text.length) line = scanLine(scan, line.start + line.text.length + 1) if (index > line.start + line.text.length) line = scanLine(scan, line.start + line.text.length + 1)
const escaping = escapings[index] const escaping = escapings[index]
const escapable = escaping === 'backslash' || escaping === 'bracketed' const escapable = escaping === 'backslash' || escaping === 'bracketed'
const opensEquals: boolean = highlights && !pairsEquals && scan.startsWith(highlightDelimiter, index)
const claimed: boolean = const claimed: boolean =
(escapable && (escapable &&
(claimsLineStart(line, index, container) || ((opensEquals && claimsHighlight(scan, index)) ||
claimsLineStart(line, index, container) ||
mergesWithSyntax(scan, escapings, index) || mergesWithSyntax(scan, escapings, index) ||
opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, afterEscape))) || opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, afterEscape))) ||
(escaping === 'bracketed-link-target' && (escaping === 'bracketed-link-target' &&
((scan.charAt(index) === '`' && opensCodeSpan(scan, index, afterEscape)) || claimsDirectivePrefix(scan, index))) ((scan.charAt(index) === '`' && opensCodeSpan(scan, index, afterEscape)) || claimsDirectivePrefix(scan, index)))
if (claimed) escaped.add(index) if (claimed) escaped.add(index)
afterEscape = claimed afterEscape = claimed
pairsEquals = opensEquals && !claimed
} }
return escaped return escaped
} }
// Like an emphasis run, a `==` in text escapes where the reader can open or close with it.
function claimsHighlight(scan: string, index: number): boolean {
const flanking = highlightFlanking(scan, index)
return flanking.opens || flanking.closes
}
// CommonMark reads no escape inside a code span, so a backtick string an escape forms or splits off still closes one an earlier bare run opens. // CommonMark reads no escape inside a code span, so a backtick string an escape forms or splits off still closes one an earlier bare run opens.
function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], claimed: ReadonlySet<number>): ReadonlySet<number> { function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], claimed: ReadonlySet<number>): ReadonlySet<number> {
const escaped = new Set(claimed) const escaped = new Set(claimed)
@@ -134,10 +148,27 @@ function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], cl
return escaped 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 { nodes, runs } = emittedRuns(segments, placements, output)
const pair = misflanked(runs) ?? unpaired(runs) const pair = misflanked(runs) ?? unpaired(runs)
return pair === undefined ? undefined : nodes[pair] const run = pair === undefined ? undefined : nodes[pair]
return run === undefined ? unreadHighlights(segments, output, placements) : [run]
}
// No `==` in text can open or close, and highlights never nest, so a pair reads back where each delimiter flanks.
function unreadHighlights(segments: readonly InlineSegment[], output: string, placements: readonly number[]): MarkRun[] {
const unread: MarkRun[] = []
let cursor = 0
for (const segment of segments) {
const start = placements[cursor] ?? 0
cursor += segment.text.length
if (segment.highlight === undefined) continue
const flanking = highlightFlanking(output, start)
const flanks = segment.highlight === 'open' ? flanking.opens : flanking.closes
if (!flanks && unread.at(-1) !== segment.nodes) unread.push(segment.nodes)
}
return unread
} }
function misflanked(runs: readonly EmittedRun[]): number | undefined { function misflanked(runs: readonly EmittedRun[]): number | undefined {
+8 -7
View File
@@ -1,10 +1,11 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { ConvertErrorPath } from '../../result.ts' import type { 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 { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
import { tryPipeCell } from './inline-line.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) const rows = pipeRows(node)
if (rows === undefined) return undefined if (rows === undefined) return undefined
const lines: string[] = [] const lines: string[] = []
@@ -12,7 +13,7 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
const cells: string[] = [] const cells: string[] = []
for (const [cellIndex, paragraph] of row.entries()) { for (const [cellIndex, paragraph] of row.entries()) {
const content = nodeContent(paragraph) const content = nodeContent(paragraph)
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0]) const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], flavour)
if (line === undefined) return undefined if (line === undefined) return undefined
cells.push(line) cells.push(line)
} }
@@ -25,16 +26,16 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
function pipeRows(node: AdfNode): AdfNode[][] | undefined { function pipeRows(node: AdfNode): AdfNode[][] | undefined {
const rows = nodeContent(node) const rows = nodeContent(node)
const columns = rows[0] === undefined ? 0 : nodeContent(rows[0]).length const columns = rows[0] === undefined ? 0 : nodeContent(rows[0]).length
if (!carriesOnly(node, []) || columns === 0) return undefined if (!holdsOnlyAttributes(node, []) || columns === 0) return undefined
const grid: AdfNode[][] = [] const grid: AdfNode[][] = []
for (const [index, row] of rows.entries()) { for (const [index, row] of rows.entries()) {
const cells = nodeContent(row) const cells = nodeContent(row)
if (row.type !== 'tableRow' || !carriesOnly(row, []) || cells.length !== columns) return undefined if (row.type !== 'tableRow' || !holdsOnlyAttributes(row, []) || cells.length !== columns) return undefined
const wanted = index === 0 ? 'tableHeader' : 'tableCell' const wanted = index === 0 ? 'tableHeader' : 'tableCell'
const paragraphs: AdfNode[] = [] const paragraphs: AdfNode[] = []
for (const cell of cells) { for (const cell of cells) {
const paragraph = plainParagraph(cell) const paragraph = plainParagraph(cell)
if (paragraph === undefined || cell.type !== wanted || !carriesOnly(cell, [])) return undefined if (paragraph === undefined || cell.type !== wanted || !holdsOnlyAttributes(cell, [])) return undefined
paragraphs.push(paragraph) paragraphs.push(paragraph)
} }
grid.push(paragraphs) grid.push(paragraphs)
@@ -45,6 +46,6 @@ function pipeRows(node: AdfNode): AdfNode[][] | undefined {
function plainParagraph(cell: AdfNode): AdfNode | undefined { function plainParagraph(cell: AdfNode): AdfNode | undefined {
const content = nodeContent(cell) const content = nodeContent(cell)
const paragraph = content[0] const paragraph = content[0]
if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !carriesOnly(paragraph, [])) return undefined if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !holdsOnlyAttributes(paragraph, [])) return undefined
return paragraph return paragraph
} }
+32
View File
@@ -0,0 +1,32 @@
import type { DirectiveAttributes, DirectiveValue, Read } from './directive-syntax.ts'
import type { EmptyKey } from '../adf/document.ts'
import { emptyKeys } from '../adf/document.ts'
import { unsupportedNodeShape } from './directive-syntax.ts'
type EmptyKeysRead = { empty: ReadonlySet<EmptyKey>; rest: Map<string, DirectiveValue> }
const emptyValue = 'empty'
// spec/flavour.md, Attributes: no attribute value spells an empty object or array.
export function spellEmptyKeys(held: Parameters<typeof emptyKeys>[0]): [string, string][] {
return emptyKeys(held).map((key) => [key, emptyValue])
}
export function spellsEmpty(value: DirectiveValue | undefined): boolean {
return value?.spelling === emptyValue
}
// `held` is whether the directive spells an attribute outside {attrs}: an argument or a content slot.
export function readEmptyKeys(type: string, attributes: DirectiveAttributes, keys: readonly EmptyKey[], held: boolean): Read<EmptyKeysRead> {
const rest = new Map(attributes)
const empty = new Set<EmptyKey>()
for (const key of keys) {
const spelled = rest.get(key)
if (spelled === undefined) continue
if (!spellsEmpty(spelled)) return { fault: unsupportedNodeShape(`the reserved key ${key} takes only the value ${emptyValue}: this one spells ${key}=${spelled.spelling}`) }
empty.add(key)
rest.delete(key)
}
if (empty.has('attrs') && (held || [...rest.keys()].some((key) => key !== 'marks'))) return { fault: unsupportedNodeShape(`${type} spells attrs=empty beside another attribute, an argument or a [content] slot: drop attrs=empty, or the rest`) }
return { value: { empty, rest } }
}
+7 -3
View File
@@ -7,6 +7,7 @@ import { holdsEntityReference } from './commonmark/entity-references.ts'
import { isAutolink } from './commonmark/grammar.ts' import { isAutolink } from './commonmark/grammar.ts'
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts' import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
import { nodeAttrs, nodeMarks } from '../adf/document.ts' import { nodeAttrs, nodeMarks } from '../adf/document.ts'
import { spellEmptyKeys } from './empty-keys.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string } type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string }
@@ -55,7 +56,10 @@ function isBareLink(nodes: readonly AdfNode[], href: string, marksInside: number
return nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === marksInside return nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === marksInside
} }
export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined { // `undefined` where the spelling holds no such attrs: only a directive spells attrs=empty.
const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, []) export function spellMarkAttributes(mark: AdfMark, spelling: MarkSpelling): string | undefined {
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) const pairs = vocabularyPairs(nodeAttrs(mark), spelling.attributes, [])
const empty = spellEmptyKeys(mark)
if (pairs === undefined || (empty.length > 0 && spelling.kind !== 'directive')) return undefined
return spellAttributes([...spellVocabulary(pairs), ...empty])
} }
+48 -15
View File
@@ -1,32 +1,49 @@
import type { AdfNode } from '../adf/document.ts' import type { AdfNode } from '../adf/document.ts'
import type { DirectiveSpan, Read } from './directive-syntax.ts' import type { DirectiveSpan, Read } from './directive-syntax.ts'
import type { JsonSpelling } from '../canonical-json.ts' import type { JsonSpelling } from '../canonical-json.ts'
import type { JsonValue } from '../json-value.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
import { fencedCodeBlock } from './commonmark/backtick-runs.ts' import { fencedCodeBlock } from './commonmark/backtick-runs.ts'
import { infoStringCarries } from './commonmark/grammar.ts'
import { isAdfNode } from '../adf/document.ts' import { isAdfNode } from '../adf/document.ts'
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts' import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
import { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
import { malformedDirective, readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
export const carryFencePrefix = 'adf:'
export const carryName = 'carry' export const carryName = 'carry'
const jsonAttribute = 'json' const jsonAttribute = 'json'
// spec/flavour.md, The opaque carry: the info string names the type wherever it carries the type back.
export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<{ headroom: number; text: string }> { export function carriedBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<{ headroom: number; text: string }> {
const json = carriedJson(node, 'two-space', path, largestNesting - depth) const { type, ...untyped } = node
const named = infoStringCarries(type)
const json = carriedJson(node, named ? untyped : node, 'two-space', path, largestNesting - depth)
if (!json.ok) return json if (!json.ok) return json
return success({ headroom: json.value.headroom, text: fencedCodeBlock(carryName, json.value.json) }) return success({ headroom: json.value.headroom, text: fencedCodeBlock(`${carryFencePrefix}${named ? type : ''}`, json.value.json) })
} }
export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> { export function carriedInline(node: AdfNode, path: ConvertErrorPath): Result<string> {
const json = carriedJson(node, 'compact', path, largestNesting) const json = carriedJson(node, node, 'compact', path, largestNesting)
if (!json.ok) return json if (!json.ok) return json
return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value.json)]]))) return success(spellInlineLeafDirective(carryName, spellAttributes([[jsonAttribute, spellStringAttribute(json.value.json)]])))
} }
export function readCarriedBlock(body: string, depth: number): Read<AdfNode> { // The type a fence's info string names, `undefined` where the info string opens no carry.
return readCarriedJson(body, 'two-space', largestNesting - depth) export function carryFenceType(info: string): string | undefined {
return info.startsWith(carryFencePrefix) ? info.slice(carryFencePrefix.length) : undefined
}
export function readCarriedBlock(type: string, body: string, depth: number): Read<AdfNode> {
if (type !== '' && !infoStringCarries(type)) {
return { fault: unsupportedNodeShape(`the carry fence names a type no info string carries back: spell it ${carryFencePrefix} with the type in the JSON; ${fenceEscape(type)}`) }
}
const read = readCarriedJson(body, 'two-space', largestNesting - depth, type)
if (read.fault !== undefined || type !== '' || !infoStringCarries(read.value.type)) return read
return { fault: unsupportedNodeShape(`the ${carryFencePrefix} fence holds a type its info string can carry: spell the fence ${carryFencePrefix}${read.value.type} and drop type from the JSON`) }
} }
export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefined { export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefined {
@@ -36,28 +53,44 @@ export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefine
return readCarriedJson(spelled.value, 'compact', largestNesting) return readCarriedJson(spelled.value, 'compact', largestNesting)
} }
function carriedJson(node: AdfNode, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> { function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, path: ConvertErrorPath, levels: number): Result<{ headroom: number; json: string }> {
const headroom = levels - nestingDepth(node) const headroom = levels - nestingDepth(node)
if (!isJsonValue(node) || headroom < 0) { if (!isJsonValue(spelled) || headroom < 0) {
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path) return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path)
} }
return success({ headroom, json: serializeCanonicalJson(node, spelling) }) return success({ headroom, json: serializeCanonicalJson(spelled, spelling) })
} }
function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number): Read<AdfNode> { // `type` is the one the fence's info string names, `undefined` for the inline carry.
function readCarriedJson(raw: string, spelling: JsonSpelling, levels: number, type?: string): Read<AdfNode> {
const wayOut = type === undefined ? directiveEscape : fenceEscape(type)
const parsed = parseJsonText(raw) const parsed = parseJsonText(raw)
if (parsed === undefined) return { fault: malformedDirective('the opaque carry holds invalid JSON') } if (parsed === undefined) return { fault: malformedDirective(`the opaque carry holds invalid JSON; ${wayOut}`) }
const { value } = parsed const { value } = parsed
if (!isJsonValue(value)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') } if (!isJsonValue(value)) return { fault: unsupportedNodeShape('the opaque carry holds a number JSON cannot spell') }
if (overNested(value, levels)) { const typed = type === undefined || type === '' ? { value } : typedValue(value, type)
if (typed.fault !== undefined) return typed
const held = typed.value
if (overNested(held, levels)) {
return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${levels} levels its position leaves` } } return { fault: { code: 'unsupported-nesting-depth', message: `a carried node's JSON nests deeper than the ${levels} levels its position leaves` } }
} }
if (serializeCanonicalJson(value, spelling) !== raw) { if (serializeCanonicalJson(value, spelling) !== raw) {
const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted' const shape = spelling === 'compact' ? 'compact, keys sorted' : 'two-space indent, keys sorted'
return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}`) } return { fault: unsupportedNodeShape(`the opaque carry spells its node's JSON canonically: ${shape}; ${wayOut}`) }
} }
if (!isAdfNode(value)) return { fault: unsupportedNodeShape("the opaque carry holds one ADF node's JSON: this JSON is no ADF node") } if (!isAdfNode(held)) return { fault: unsupportedNodeShape(`the opaque carry holds one ADF node's JSON: this JSON is no ADF node; ${wayOut}`) }
return { value } return { value: held }
}
// A code fence the carry claims stays code under the directive, whose language rides the attribute.
function fenceEscape(type: string): string {
return `${spellDirectiveOpener('codeBlock', undefined, spellAttributes([['language', spellStringAttribute(`${carryFencePrefix}${type}`)]]))} around a bare fence keeps it a code block`
}
function typedValue(value: JsonValue, type: string): Read<JsonValue> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { value }
if ('type' in value) return { fault: unsupportedNodeShape(`the ${carryFencePrefix}${type} fence names its node's type: this JSON holds a type as well; ${fenceEscape(type)}`) }
return { value: { ...value, type } }
} }
function parseJsonText(raw: string): { value: unknown } | undefined { function parseJsonText(raw: string): { value: unknown } | undefined {
+6 -2
View File
@@ -3,8 +3,9 @@ import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes } from '../directive-syntax.ts' import type { DirectiveAttributes } from '../directive-syntax.ts'
import type { MarkSpelling } from '../mark-spellings.ts' import type { MarkSpelling } from '../mark-spellings.ts'
import { directivePrefix } from '../directive-syntax.ts' import { directivePrefix } from '../directive-syntax.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { markSpelling } from '../mark-spellings.ts' import { markSpelling } from '../mark-spellings.ts'
import { readEmptyKeys } from '../empty-keys.ts'
import { readVocabulary } from './directive-attributes.ts' import { readVocabulary } from './directive-attributes.ts'
export function readDirectiveMark(name: string, attributes: DirectiveAttributes, path: ConvertErrorPath): Result<AdfMark> | undefined { export function readDirectiveMark(name: string, attributes: DirectiveAttributes, path: ConvertErrorPath): Result<AdfMark> | undefined {
@@ -12,8 +13,11 @@ export function readDirectiveMark(name: string, attributes: DirectiveAttributes,
if (spelling === undefined) return undefined if (spelling === undefined) return undefined
const markdown = markdownForm(spelling) const markdown = markdownForm(spelling)
if (markdown !== undefined) return failure('unsupported-node-shape', `${name} is spelled ${markdown}, never as a directive`, path) if (markdown !== undefined) return failure('unsupported-node-shape', `${name} is spelled ${markdown}, never as a directive`, path)
const attrs = readVocabulary(name, attributes, spelling.attributes, undefined, path) const empty = readEmptyKeys(name, attributes, ['attrs'], false)
if (empty.fault !== undefined) return faulted(empty.fault, path)
const attrs = readVocabulary(name, empty.value.rest, spelling.attributes, undefined, path)
if (!attrs.ok) return attrs if (!attrs.ok) return attrs
if (empty.value.empty.has('attrs')) return success({ attrs: {}, type: name })
return success(Object.keys(attrs.value).length === 0 ? { type: name } : { attrs: attrs.value, type: name }) return success(Object.keys(attrs.value).length === 0 ? { type: name } : { attrs: attrs.value, type: name })
} }

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