201 Commits

Author SHA1 Message Date
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
lilleman 2a3c90d63b 37 - Deno stays to prove the library runs there
CI / gate (push) Successful in 41s
CI / publish (push) Successful in 7s
2026-09-28 18:45:09 +02:00
lilleman bbfc8724fc 36c - review: a Deno premise that holds today, the plain reduction's memo named
CI / gate (push) Successful in 42s
CI / publish (push) Successful in 6s
2026-09-28 11:14:27 +02:00
lilleman fa6df33987 36c - review: premises that expire, no todo-history citations, the memo's key at the code, Goal 10 checkable
CI / gate (push) Successful in 43s
CI / publish (push) Has been skipped
2026-09-28 11:08:17 +02:00
lilleman 8a898436ea 36c - Goal 10's continuation indent
CI / gate (push) Successful in 42s
CI / publish (push) Has been skipped
2026-09-28 11:00:42 +02:00
lilleman 621dc75f86 36c - Goal 10: source a contributor can hold
CI / gate (push) Successful in 42s
CI / publish (push) Has been cancelled
2026-09-28 11:00:39 +02:00
lilleman d5dae87c9b 36c - §10 and §11's decisions move to docs/decisions.md
CI / gate (push) Successful in 43s
CI / publish (push) Has been cancelled
2026-09-28 10:55:07 +02:00
lilleman 348865398c 36 - review: goal 8 covers what other parsers read, goal 1's tagline names both halves
CI / gate (push) Successful in 43s
CI / publish (push) Successful in 7s
2026-09-28 10:41:49 +02:00
lilleman ff23145a5a 36 - Goal 1 holds that every call answers, Goal 8 says what correct means
CI / gate (push) Successful in 43s
CI / publish (push) Has been cancelled
2026-09-28 10:40:48 +02:00
lilleman d2a3d74030 36b - Goals 8 and 9: correct before fast, fast once correct
CI / gate (push) Successful in 42s
CI / publish (push) Successful in 7s
2026-09-28 10:22:08 +02:00
lilleman 50f97513e8 36b - review: publish entry matches publish.sh
CI / gate (push) Successful in 1m3s
CI / publish (push) Has been skipped
2026-09-28 03:56:16 +02:00
lilleman e5771bc60b 36b - review: no stale private flag, no undefined freeze
CI / gate (push) Successful in 43s
CI / publish (push) Has been cancelled
2026-09-28 03:55:29 +02:00
lilleman fb97285899 36b - §8, §9 and §14's decisions move to docs/decisions.md
CI / gate (push) Successful in 42s
CI / publish (push) Has been cancelled
2026-09-28 03:54:34 +02:00
lilleman c64eed9301 36 - review: one spelling for public npm, a premise the goal does not restate
CI / gate (push) Successful in 42s
CI / publish (push) Successful in 6s
2026-09-28 02:45:20 +02:00
lilleman d10da170b1 36 - Goal 7 names public npm, the Public on npm entry cites it
CI / gate (push) Successful in 46s
CI / publish (push) Has been skipped
2026-09-28 02:35:09 +02:00
lilleman c694896090 36a - review: README links the decision log, premises that can lapse, citations fixed
CI / gate (push) Successful in 42s
CI / publish (push) Successful in 6s
2026-09-28 00:39:05 +02:00
lilleman becd12e294 36a - §1–§6's decisions move to docs/decisions.md, indexed from AGENTS.md
CI / gate (push) Successful in 42s
CI / publish (push) Has been skipped
2026-09-28 00:37:01 +02:00
lilleman f855e1b348 36 - review: wrap
CI / gate (push) Successful in 42s
CI / publish (push) Successful in 6s
2026-09-28 00:04:44 +02:00
lilleman c6781aa9b3 36 - review: CHANGELOG flags the breaking readings and codes, README links it
CI / gate (push) Successful in 42s
CI / publish (push) Has been skipped
2026-09-28 00:04:38 +02:00
lilleman 740a7b1e59 36 - plan: todo.md by release, CHANGELOG.md, done items leave todo.md
CI / gate (push) Successful in 42s
CI / publish (push) Has been skipped
2026-09-28 00:03:37 +02:00
lilleman a74e8742ff 35 - todo taglines state the target
CI / gate (push) Successful in 42s
CI / publish (push) Successful in 5s
2026-09-27 23:41:46 +02:00
lilleman 11e55732e7 35 - review: the error table names the lossless flavour
CI / gate (push) Successful in 41s
CI / publish (push) Has been skipped
2026-09-27 23:32:09 +02:00
lilleman 8e09cd2214 35 - review: the README names the lossless and plain flavours
CI / gate (push) Successful in 41s
CI / publish (push) Has been cancelled
2026-09-27 23:31:51 +02:00
lilleman 5d5952c576 35 - goals: ADF is the hub, plain markdown planned as a flavour of the grammar
CI / gate (push) Successful in 1m39s
CI / publish (push) Has been cancelled
2026-09-27 23:30:35 +02:00
lilleman 1271365b38 10 - decisions: the lossy pair never saves back, a callout's title is its marker line, task ids by position, 5g names the flavours
CI / gate (push) Successful in 42s
CI / publish (push) Successful in 6s
2026-09-26 14:45:29 +02:00
lilleman 1f4a94974a 10c - prose: the lossy pair folds into §1, §15's ask rule said once
CI / gate (push) Successful in 41s
CI / publish (push) Successful in 6s
2026-09-26 13:33:36 +02:00
lilleman 326352fa98 10c - review: README says which pair saves back, where plainMarkdownToAdf refuses, and the localId the schema wants
CI / gate (push) Successful in 41s
CI / publish (push) Has been skipped
2026-09-26 13:32:25 +02:00
lilleman 9e2c0fda15 10c - a list standing in a list or task list keeps its numbers as text past the marker cap
CI / gate (push) Successful in 41s
CI / publish (push) Has been skipped
2026-09-26 13:28:13 +02:00
lilleman f594dafc5b 10c - review: link and code span edges named in the README, 10e filed, leafEdges trail simplified
CI / gate (push) Successful in 40s
CI / publish (push) Has been skipped
2026-09-26 13:26:20 +02:00
lilleman 613dc0edbb 10c - adfToPlainMarkdown and plainMarkdownToAdf exported, whitespace-only leaves trimmed at a line end
CI / gate (push) Successful in 41s
CI / publish (push) Has been skipped
2026-09-26 13:20:16 +02:00
lilleman 2f03e20549 10b - 34 files the flanking read beside an astral symbol
CI / gate (push) Successful in 34s
CI / publish (push) Successful in 7s
2026-09-25 21:10:23 +02:00
lilleman ae2dc6054b 10b - a == pair is bounded by the code point outside it, CommonMark's word character test 2026-09-25 21:10:05 +02:00
lilleman 7a53b6f7a0 10b - tests: a symbol outside the BMP bounds a == pair 2026-09-25 21:09:12 +02:00
lilleman 1eb7b54f19 10b - item 10's highlight row needs the pair bounded outside
CI / gate (push) Successful in 35s
CI / publish (push) Has been skipped
2026-09-25 21:06:23 +02:00
lilleman 4df17055ef 10b - an empty alert holds an empty paragraph, a == pair is bounded outside and passes over held nodes 2026-09-25 21:05:58 +02:00
lilleman 8cb85f3d27 10b - tests: an empty alert holds an empty paragraph, a == pair is bounded outside and passes over held nodes 2026-09-25 21:04:50 +02:00
lilleman 969326b776 10b - 10d names the highlight that reads back wrong 2026-09-25 21:03:09 +02:00
lilleman b42218e655 10b - error reads as an error alert, 10d files the literal marker, and 10b is done
CI / gate (push) Successful in 35s
CI / publish (push) Has been skipped
2026-09-25 20:55:00 +02:00
lilleman c1fed0885b 10b - a code span inside a == pair keeps code and takes no highlight 2026-09-25 20:54:46 +02:00
lilleman bf87e6ea18 10b - tests: a code span inside a == pair takes no highlight 2026-09-25 20:54:04 +02:00
lilleman 53ff7e0204 10b - the lift, with alert words, task markers and == shared with the reduction 2026-09-25 20:52:44 +02:00
lilleman d1a208146f 10b - tests: the lift reads alerts, folded callouts, task markers and == pairs back into their nodes 2026-09-25 20:52:44 +02:00
lilleman 687ba9bf90 10a - three comments restating their function go
CI / gate (push) Successful in 35s
CI / publish (push) Successful in 6s
2026-09-25 19:40:27 +02:00
lilleman 0094b361ab 10a - item 10's list row keeps a broken numbering's numbers
CI / gate (push) Successful in 34s
CI / publish (push) Has been cancelled
2026-09-25 19:40:13 +02:00
lilleman dbd80b98c3 10a - numbered lists whose numbering breaks merge as one bullet list keeping their numbers 2026-09-25 19:39:54 +02:00
lilleman 8572d76ee9 10a - tests: a marker-shaped number opening an item stays escaped 2026-09-25 19:39:16 +02:00
lilleman 62ece3f9ac 10a - tests: numbered lists whose numbering breaks keep their numbers as text 2026-09-25 19:38:46 +02:00
lilleman 8f33f67f72 10a - a value blank once cleaned counts as empty 2026-09-25 19:37:21 +02:00
lilleman 3a18627389 10a - tests: a value blank once cleaned leaves the note or fallback 2026-09-25 19:36:59 +02:00
lilleman dc11daa540 10a - a list a numbered list becomes merges with its neighbours, and a note's name is cleaned before it is checked
CI / gate (push) Successful in 34s
CI / publish (push) Has been skipped
2026-09-25 19:33:26 +02:00
lilleman 7b814e02dc 10a - tests: the give-way case spells its nested list tight 2026-09-25 19:32:45 +02:00
lilleman 5e55ce2a8e 10a - tests: an overflowing numbered list merges with its neighbours, and a blank key names an extension 2026-09-25 19:32:13 +02:00
lilleman cf9a737e71 10a - 10c's property holds the plain output to no directive 2026-09-25 19:31:59 +02:00
lilleman 1c0a6526b1 10a - item 10's list row keeps a long list's numbers, and 33 names the reduction's retry
CI / gate (push) Successful in 36s
CI / publish (push) Has been skipped
2026-09-25 19:29:12 +02:00
lilleman 8418150cd3 10a - notes clean their names, leading rules give way, an overflowing numbered list keeps its numbers, one fallback order 2026-09-25 19:28:53 +02:00
lilleman d35637c8fa 10a - tests: a marker-shaped number opening an item stays escaped 2026-09-25 19:28:53 +02:00
lilleman c46b8243ba 10a - tests: clean note names, drop every leading rule, keep an overflowing numbered list, random localIds 2026-09-25 19:27:40 +02:00
lilleman 69f116db1b 10a - item 10's rows carry the panels' verdicts, and 10a is done
CI / gate (push) Successful in 34s
CI / publish (push) Has been skipped
2026-09-25 19:14:05 +02:00
lilleman 1104c57c0e 10a - a rule opening a list item gives way, and the list stays a list 2026-09-25 19:13:26 +02:00
lilleman c6acef081f 10a - the reduction keeps what a reader sees or follows, and names what the document only references 2026-09-25 19:10:47 +02:00
lilleman 59a37d1945 10a - the reduction spells a document in the flavour without directives 2026-09-25 19:03:10 +02:00
lilleman 37abc1ddc1 10a - the line emitter names the first fallback a plain line would take, and the mark run it drops 2026-09-25 19:03:10 +02:00
lilleman cb30512a90 10 - goal 1 wraps
CI / gate (push) Successful in 33s
CI / publish (push) Successful in 5s
2026-09-25 18:49:38 +02:00
lilleman 3fce43e884 10 - referenced content leaves a note naming it
CI / gate (push) Successful in 1m30s
CI / publish (push) Has been cancelled
2026-09-25 18:49:19 +02:00
lilleman 0644d1bc5d 10 - lossy conversion keeps the content, and a reader panel settles what the audience expects 2026-09-25 08:14:57 +02:00
lilleman b1dc6a1f45 27 - the level the list's directive form spends is stated at the check that pays it
CI / gate (push) Successful in 30s
CI / publish (push) Successful in 5s
2026-09-24 00:19:45 +02:00
lilleman 0fd412e426 27 - directiveItems loses the comment that explained the removed subtraction
CI / gate (push) Successful in 36s
CI / publish (push) Has been skipped
2026-09-24 00:19:05 +02:00
lilleman 3d0f82cbd4 27 - the history keeps the item as written
CI / gate (push) Successful in 34s
CI / publish (push) Has been skipped
2026-09-24 00:14:46 +02:00
lilleman 68942c9919 27 - a placed block carries no headroom, so the list's directive items write none
CI / gate (push) Successful in 30s
CI / publish (push) Has been skipped
2026-09-24 00:05:08 +02:00
lilleman d5370b126d 26 - the container stack owns the directive depths, and the escape claim hands over after it records
CI / gate (push) Successful in 30s
CI / publish (push) Successful in 5s
2026-09-24 00:02:58 +02:00
lilleman 15fb7fca81 26 - the escape phases and the container stack hold their invariants in types
CI / gate (push) Successful in 30s
CI / publish (push) Has been skipped
2026-09-23 23:48:08 +02:00
lilleman b90baa257c 25 - the definitions §11 held sit at the code, and §8 says only what the README does not
CI / gate (push) Successful in 30s
CI / publish (push) Successful in 5s
2026-09-23 23:36:24 +02:00
lilleman d0088fc922 25 - AGENTS.md §8 and §11 have sub-headings and say only what the code cannot
CI / gate (push) Successful in 30s
CI / publish (push) Has been skipped
2026-09-23 23:29:38 +02:00
lilleman 2b15530a57 24 - the history line says what src/ root holds
CI / gate (push) Successful in 1m46s
CI / publish (push) Successful in 5s
2026-09-23 23:24:09 +02:00
lilleman 132f9f7467 24 - the conformance gates live in src/conformance/
CI / gate (push) Successful in 39s
CI / publish (push) Has been skipped
2026-09-23 23:21:06 +02:00
lilleman 7be4f59f0b 23 - the block directive's spellings are one file
CI / publish (push) Waiting to run
CI / gate (push) Successful in 39s
2026-09-23 23:13:01 +02:00
lilleman d3a2129967 32 - §8's depth sentences say what the guard does, and item 32 moves to history
CI / gate (push) Successful in 37s
CI / publish (push) Successful in 5s
2026-09-23 22:52:26 +02:00
lilleman 6715c97599 32 - the deeper helpers read the one limit, and the code directive's refusal is tested
CI / gate (push) Successful in 31s
CI / publish (push) Has been skipped
2026-09-23 22:51:03 +02:00
lilleman 75791783d8 32 - a mark's attribute depth is counted from its value, the marks spelling refusing its own
CI / gate (push) Successful in 29s
CI / publish (push) Has been skipped
2026-09-22 21:51:47 +02:00
lilleman 6944d505f1 22 - the seam is a rule in the consultation sentence, not a list
CI / gate (push) Successful in 29s
CI / publish (push) Waiting to run
2026-09-22 21:37:18 +02:00
lilleman 754f1e3b33 22 - the seam's asks bring the types their signatures name
CI / gate (push) Successful in 30s
CI / publish (push) Has been cancelled
2026-09-22 21:36:39 +02:00
lilleman 2f7005590c 22 - the line container is markdown's, so the seam is the two spelling asks
CI / gate (push) Successful in 29s
CI / publish (push) Has been skipped
2026-09-21 22:15:27 +02:00
lilleman 67c3345fd2 21 - the table's row is the node's model, never the node
CI / gate (push) Successful in 29s
CI / publish (push) Successful in 5s
2026-09-21 21:45:11 +02:00
lilleman 7f290d220d 21 - the parse walk reads blocks, so no name means two things
CI / gate (push) Successful in 30s
CI / publish (push) Has been skipped
2026-09-21 21:40:43 +02:00
lilleman c2acee7a1d 21 - the ADF tables carry ADF's nouns
CI / gate (push) Successful in 29s
CI / publish (push) Has been skipped
2026-09-21 21:39:59 +02:00
lilleman e964abaa4b 20 - every spelling tried ahead of a directive form takes the try prefix
CI / gate (push) Successful in 30s
CI / publish (push) Successful in 5s
2026-09-21 21:20:47 +02:00
lilleman 3d0926dcaf 19 - adf/ is what both formats read, and a construct rises on its second consumer
CI / gate (push) Successful in 30s
CI / publish (push) Successful in 4s
2026-09-21 10:09:32 +02:00
lilleman 3ee1605ed3 30 - AGENTS.md says each thing once, and the ask protocol has a heading
CI / gate (push) Successful in 29s
CI / publish (push) Successful in 5s
2026-09-20 23:45:08 +02:00
lilleman 0c8fd36ef8 29 - the README states the raw HTML rule once, and the element set is the exception
CI / gate (push) Successful in 36s
CI / publish (push) Successful in 5s
2026-09-20 22:47:43 +02:00
lilleman 39ea801fbe 6 - script and style drop whole, and the set sorts every element three ways
CI / gate (push) Successful in 29s
CI / publish (push) Successful in 4s
2026-09-20 22:08:51 +02:00
lilleman c2bf5c0ea8 6 - the div unwraps, details is an expand, and a comment has nothing to hold it
CI / gate (push) Successful in 29s
CI / publish (push) Successful in 4s
2026-09-20 21:59:03 +02:00
lilleman eed1ac0290 29 - the spec stands: raw HTML routes through the element mapping
CI / gate (push) Successful in 29s
CI / publish (push) Waiting to run
2026-09-20 21:53:07 +02:00
lilleman 77ae596d2e Prose pass: the config is discovered, so -c guards only its absence
CI / gate (push) Successful in 29s
CI / publish (push) Successful in 4s
2026-09-20 18:29:39 +02:00
lilleman f79241f56f Review: name what each ratchet switch guards, IIFEs included
CI / gate (push) Successful in 30s
CI / publish (push) Has been skipped
2026-09-20 18:23:46 +02:00
lilleman b969bb36d6 Review: the ratchet counts an IIFE, and no missing config or stray warning passes it
CI / gate (push) Successful in 30s
CI / publish (push) Has been skipped
2026-09-20 18:20:10 +02:00
lilleman 6efbac4598 17 - the gate holds every built function to 52 lines
CI / gate (push) Successful in 29s
CI / publish (push) Has been skipped
2026-09-20 18:10:21 +02:00
lilleman 4b2f3b1bb3 AGENTS.md 8: a refusal from our own invariant takes the nearest existing code
CI / gate (push) Successful in 35s
CI / publish (push) Successful in 5s
2026-09-20 18:03:55 +02:00
lilleman 057729f830 Prose pass: the guard's comment goes, and one spelling states the coverage bar
CI / gate (push) Successful in 29s
CI / publish (push) Has been skipped
2026-09-20 17:59:34 +02:00
lilleman 8b5e05f5a9 Review nit: the attempt names the fallback it takes, so no arm is reached by elimination
CI / gate (push) Successful in 28s
CI / publish (push) Has been skipped
2026-09-20 17:57:13 +02:00
lilleman ee10b63fe7 Tick 28, moving its text to todo-history.md
CI / gate (push) Successful in 27s
CI / publish (push) Has been skipped
2026-09-20 17:49:54 +02:00
lilleman e34d39fdee 28 - the line's retry takes one fallback per pass, so it cannot spin 2026-09-20 17:49:54 +02:00
lilleman 5c47fb69ad File the emitLine spin as 28, first in the 0.2.0 order
CI / gate (push) Successful in 27s
CI / publish (push) Successful in 5s
2026-09-20 17:23:52 +02:00
lilleman 61faf6f493 The panel's findings become items 19 to 27, and 17 becomes a size ratchet
CI / gate (push) Successful in 27s
CI / publish (push) Successful in 5s
2026-09-20 16:45:01 +02:00
lilleman 665c63af4b The README states its goals and its audience under their own headings
CI / gate (push) Successful in 28s
CI / publish (push) Successful in 5s
2026-09-20 13:15:08 +02:00
lilleman 78b2924fe1 The leg rule covers the host legs and reaches past the function a leg calls
CI / gate (push) Successful in 27s
CI / publish (push) Successful in 5s
2026-09-20 00:12:05 +02:00
lilleman 1fb712c76d A legged function chains with && : the || that captures the status suspends set -e
CI / gate (push) Successful in 28s
CI / publish (push) Has been skipped
2026-09-20 00:07:06 +02:00
lilleman be25a7a3f5 Tick 4d, moving its text to todo-history.md
CI / gate (push) Successful in 28s
CI / publish (push) Has been skipped
2026-09-19 23:57:06 +02:00
lilleman 6ffafe7536 4d - every gate leg names itself, its image and its seconds 2026-09-19 23:57:06 +02:00
lilleman 00e18d66b5 The memo stops the per-level re-spelling; it does not promise one spelling
CI / gate (push) Successful in 27s
CI / publish (push) Successful in 4s
2026-09-19 13:48:55 +02:00
lilleman cd3bdf0d5d Pin the rebase's magnitude: one overflow list makes a constant accept what the emitter refuses
CI / gate (push) Successful in 27s
CI / publish (push) Has been skipped
2026-09-19 13:43:34 +02:00
lilleman d6756ea5e6 A read below the depth that filled the memo re-spells, so the depth guards still run
CI / gate (push) Successful in 28s
CI / publish (push) Has been skipped
2026-09-19 13:37:58 +02:00
lilleman 3f8afcb9d7 Tick 18, moving its text to todo-history.md
CI / gate (push) Successful in 1m26s
CI / publish (push) Has been skipped
2026-09-19 13:18:33 +02:00
lilleman 4ab1ed5132 18 - the parse keeps each node's readable spelling, so the directive ask spells it once 2026-09-19 13:18:33 +02:00
lilleman 7d1463da0d One comment guards the input's shape, the other the field
CI / gate (push) Successful in 37s
CI / publish (push) Successful in 4s
2026-09-19 02:33:23 +02:00
lilleman 4a80e38f19 Pin the deactivation where an image close folds the evidence away
CI / gate (push) Successful in 36s
CI / publish (push) Has been skipped
2026-09-19 02:31:03 +02:00
lilleman 20cd21789b Product nits: the decision keeps its asymmetry, the migration row its repair
CI / gate (push) Successful in 36s
CI / publish (push) Has been skipped
2026-09-19 02:24:19 +02:00
lilleman 8961cbd20a Literal brackets put nothing inside a mark, so the nested-link check runs ahead of both guards
CI / gate (push) Successful in 37s
CI / publish (push) Has been skipped
2026-09-19 02:21:38 +02:00
lilleman a2928a8789 Review nits: the walk's start needs no clamp, and the invariant needs one copy
CI / gate (push) Successful in 36s
CI / publish (push) Has been skipped
2026-09-19 02:15:15 +02:00
lilleman 9d5a8e4137 Pin what the doomed openers keep: the carry and the image literal brackets hold
CI / gate (push) Successful in 36s
CI / publish (push) Has been skipped
2026-09-19 02:09:19 +02:00
lilleman 6e9e38776f Review nits: deactivate the openers a nested link dooms, and pin the refusal order
CI / gate (push) Successful in 38s
CI / publish (push) Has been skipped
2026-09-19 02:02:01 +02:00
lilleman 3a22ff388d 16 - no link wraps a link: the outer brackets go literal, the directive form is refused
CI / gate (push) Successful in 37s
CI / publish (push) Has been skipped
2026-09-19 01:48:30 +02:00
lilleman b695c4c88c Review nits: drop the spec's restated carry rule, the test's duplicate paths and todo.md's incident note
CI / gate (push) Successful in 36s
CI / publish (push) Successful in 4s
2026-09-18 23:14:37 +02:00
lilleman 88e5213326 Tick 15, moving its text to todo-history.md and pointing the next session at a fetch first
CI / gate (push) Successful in 36s
CI / publish (push) Has been skipped
2026-09-18 23:08:57 +02:00
lilleman 451a617b13 15 - refuse the directive link spelling no href, so the mark has one spelling 2026-09-18 23:08:34 +02:00
lilleman ed099a093d AGENTS.md §11: a sort keys on the name a line introduces, so an import sorts on its first binding 2026-09-18 23:03:13 +02:00
lilleman 5b78e608f0 Tick 14, moving its text to todo-history.md and filing the import-sort ask
CI / gate (push) Successful in 36s
CI / publish (push) Successful in 4s
2026-09-18 21:31:01 +02:00
lilleman f303a815f7 AGENTS.md §11: the shared side is everything outside emit/ and parse/, not the root alone
CI / gate (push) Successful in 36s
CI / publish (push) Successful in 5s
2026-09-18 21:29:00 +02:00
lilleman 42ab63a03b 14 - the CommonMark subset moves under src/markdown/commonmark/
CI / gate (push) Successful in 36s
CI / publish (push) Has been skipped
2026-09-18 20:27:22 +02:00
lilleman 128e55e448 AGENTS.md §15: a session works one chunk and stops, the release being a chain of them
CI / gate (push) Successful in 37s
CI / publish (push) Successful in 5s
2026-09-18 20:21:35 +02:00
lilleman 39039081ee Point the next session at 14, and at a fetch before branching
CI / gate (push) Successful in 36s
CI / publish (push) Successful in 5s
2026-09-18 20:04:19 +02:00
lilleman dfc91a651b Restore the list-item walk's three re-scans in 4c's record, and what became of each 2026-09-18 20:03:56 +02:00
lilleman 0361646e1a 4c - the guard's spread was a class: the code block's held lines and a mark run's segments too 2026-09-18 20:03:56 +02:00
lilleman 047ce3bc42 One release for everything known: 0.2.1 and 0.3.0 fold into 0.2.0 2026-09-18 20:03:56 +02:00
lilleman a774be59f4 Tick 4c, moving its text to todo-history.md and filing its last site as 18 2026-09-18 20:03:56 +02:00
lilleman 7b726ef6ec 4c - the directive scan keeps the spans it read, so the slot parse never scans them again 2026-09-18 20:03:56 +02:00
lilleman 503efa4f82 4c - the label trim, the segment tail, the break run and the guard's spread stop re-walking 2026-09-18 20:03:56 +02:00
lilleman 538e0ce665 AGENTS.md §8 admits a plainly named new code in 0.x, §11 names from the spec
CI / publish (push) Successful in 4s
CI / gate (push) Successful in 33s
2026-09-18 19:38:58 +02:00
lilleman 4147f6623c AGENTS.md §15 asks land as rules; todo.md carries the next-session prompt
CI / gate (push) Successful in 34s
CI / publish (push) Has been skipped
2026-09-18 19:31:41 +02:00
78 changed files with 4289 additions and 2078 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": { "correctness": "off" },
"ignorePatterns": ["src/**/*.test.ts", "src/conformance/property-harness.ts"],
"rules": {
"eslint/max-lines-per-function": ["error", { "IIFEs": true, "max": 52, "skipBlankLines": false, "skipComments": false }]
}
}
+131 -340
View File
@@ -1,391 +1,182 @@
# Working in this repo # Working in this repo
Decisions a reader would otherwise relitigate, and the rules for every collaborator, human or The rules for every collaborator, human or agent, and an index of the decisions a reader would
agent. Using the library: `README.md`. What is still to build: `todo.md`. otherwise relitigate. Using the library: `README.md`. What is still to build: `todo.md`.
## 1. Three formats, ADF is the hub ## Decisions
ADF, one markdown flavour, one HTML dialect. Six directions exposed, but markdown↔HTML compose In `docs/decisions.md`:
through ADF: four conversions exist to keep correct — never write a fifth. No fourth format, ever;
each one doubles the directions.
## 2. The round-trip is the product - Plain markdown is a flavour of the grammar
- The round-trip is the product
- Markdown in is a canonical fixpoint
- Equality is editor-normal
- Unknown nodes ride the carry
- Foreign HTML sorts three ways
- Names stay text
- Directives under `!adf:`
- CommonMark is a subset
- Tables
- Links
- Ids stay site-local
- Plain task ids come from position
- A callout title keeps its link targets
- The plain flavour's spellings
- The HTML dialect
- No runtime dependencies
- Standards ship as data
- fast-check
- Any ES2022 engine
- ESM only
- One built entrypoint
- Public on npm
- The formats are API
- The code list
- Which code a cause takes
- `message` and `path`
- Publish on a version bump
- Docs describe the release being built
- No schema validation
- The gate runs on Deno and Bun
- The gate installs the tarball
- Firefox reads the build
- The coverage floors
- The size ratchet
- Properties on a fixed seed
- The CommonMark suite checks three ways
- The flavour spec is read as a source
- The node tables answer to Atlassian's schema
- Nothing recurses unbounded
- Nothing spreads an unbounded array
- A retry loop checks its own termination
- Readers scan by index
- The spelling memo
- Cost fixes are measured, never timed
- Only the hard break holds a raw newline
- Emphasis follows CommonMark's matching
- Readable spellings take the `try` prefix
- The attribute vocabulary is ADF's
- The source parts by ADF and format
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything ## 1. Nothing about any consumer
less silently destroys content an editor could not represent, in a document it did not author.
When losslessness and readability conflict, losslessness wins.
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
back yields the library's canonical spelling, and that spelling round-trips byte-identically —
where there is a way back. CommonMark spells some things the flavour has no escape for — a
paragraph opening with a code span whose backticks read back as a fence — so a parse succeeding
does not imply a spellable document;
`corpus/commonmark-spec/exceptions.json` names those.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content
array the absent key — the only domain markdown can restore.
Round-trip equality is a property tested over a corpus, not a claim made in prose.
## 3. Unknown input policy
- Unknown ADF node: carried opaquely — raw JSON rides a dedicated syntax in both formats and
restores to a deep-equal node. The round-trip holds for documents newer than the library. So
does a known node no section spells where it stands: a markdown serializer spells a node by type
without checking its position, and refusing loses a document ADF itself keeps in an
`unsupportedBlock`. Where a container's own spelling cannot hold the child it has — a
`bulletList` outside `listItem`, a `codeBlock` outside text — the error result names that
instead.
- Unmappable foreign HTML element: error result naming the element — never a silent drop.
- Bare `@name` / `:smile:` in typed text: stays a text node. Only directives produce
mention/emoji/media nodes; resolving names to ids needs I/O, which is the consumer's job.
## 4. The flavour
- Directives, one grammar for everything markdown lacks, namespaced under `!adf:`: `!adf:panel info`
… `!adf:/panel` blocks, `!adf:mention[@Mikael]{id=5b10a2}` inline, `\!adf:` the one escape. Not
CommonMark's generic-directives proposal: its `:::` claims a form prose writes, and its
fence-length discipline ties a container's opener to its own body, where closing from the opener
nests by itself and leaf versus container falls out of the node's content model.
- Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a
directive, a pipe table or a `~~` pair is claimed — plus one image gap.
- Tables: one header row plus plain inline cells → pipe table; anything richer → directive form.
- Links: `[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark
spells the mark; `!adf:link[text]{attrs}` where it does not — an attribute CommonMark cannot
hold, an `href` or `title` no canonical escape spells, a paragraph opening whose CommonMark
spelling would read as a link reference definition — and a directive link CommonMark could spell
is refused (the maintainer, 2026-09-13).
- Identity-bearing nodes carry their ids in attributes; a document is only portable within its
site — accepted.
- The HTML dialect mirrors this: semantic elements, stable `adf-*` classes, `data-*` for what HTML
cannot express, text always escaped. No stylesheet ships.
## 5. Dependencies
`dependencies` is empty. A runtime dependency enters only through a decision entry here stating
why ~20 lines of own code cannot do the job, who maintains it, and what auditing it costs. So the
CommonMark and HTML parsers are written in this repo. A table a standard fixes is data rather than
a dependency: HTML5's 2125 semicolon-terminated character references ship packed in their own
module, so entity decoding is complete without one. The CommonMark spec suite is the same shape of
data and ships vendored at `corpus/commonmark-spec/` rather than as the `commonmark-spec` dev
dependency — that package is CommonJS-only, and Renovate auto-bumping a spec version would silently
point the vendored exception list's example numbers at a renumbered suite. A spec bump is a
deliberate re-pin, exceptions re-derived by hand beside it. Atlassian's ADF JSON Schemas ship
vendored the same way, at `spec/adf-schema/`, rather than as the `@atlaskit/adf-schema` dev
dependency — CommonJS-only, some fifty packages with React among them, and a release most days for
Renovate to automerge — re-pinned by hand when a payload or a report shows the need.
`devDependencies`: few, each earning its keep; they never reach a consumer. `fast-check` earns its
place shrinking a failing generated document to the nodes that break it.
## 6. The package contract
- Runs on any ES2022 engine, not only Node — a browser as readily as a server. The shipped source
is ECMAScript and nothing else: no host import, no host global, no DOM. `tsconfig.build.json` is
that gate, typechecking and emitting the shipped files alone, so `node:fs`, `process` and an
ES2024 method are compile errors here rather than a consumer's crash there. The standard is the line, never an
engine list: one implementing it in part — Hermes is the live doubt, on §10's property escapes
and on lookbehind — is out of scope rather than a bug. Node's test runner, the corpus reads and
the build are the repo's own,
never the library's, and `engines.node` states the floor the shipped JavaScript needs — `>=18` —
never the higher one those repo-only tools want.
- ESM only — no CommonJS build, no dual-package hazard.
- One entrypoint: built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint —
Node refuses to type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`),
so it cannot serve an npm consumer.
- Published to public npmjs as `@larvit/adf-codec`. Public source: the Gitea repo
goes public, LICENSE in place, before the first publish.
- Exact versions: `save-exact=true` in `.npmrc`.
## 7. Nothing about any consumer
No Jira client, no HTTP, no REST shapes, no issue keys, no actual consumer named anywhere. Design No Jira client, no HTTP, no REST shapes, no issue keys, no actual consumer named anywhere. Design
against the README's personas. against the README's personas.
## 8. Semver: the formats are API ## 2. Release automation
The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing - The bump commit renames `CHANGELOG.md`'s `## Unreleased` to the version.
differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR. - Exact versions: `save-exact=true` in `.npmrc`.
Pre-1.0, normal 0.x rules. A spelled node's content model is part of that contract — leaf or
container is the model, not the syntax — so giving a spelled node's model content it had not, or
taking it away, is MAJOR whatever ADF's own schema does.
The error surface is a contract too. `ConvertError` is `{ code, message, path, position? }` — the
code from a closed list a consumer may switch exhaustively, the message free text, the path the
node's place from the document root, the position where a parse read the refusal in its input.
A message names the violation, not the rule alone — a rule by itself states a truth the reader
must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose it
names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and inline
alike, `\|` for every pipe row.
Adding, removing or renaming a code is breaking, so a milestone meeting a new failure cause
reuses a code where one fits; the list is complete at `0.1.0`. A code names the
cause; where one cause recurs across node types, across one mark's attributes or across
directions, one code covers them all and
`path` and `message` say which — `unsupported-nesting-depth` is the 500-level guard whichever
direction hits it, `unspellable-character` the text node and the code block alike. Where two codes stay
apart, the line between them is what they name: `unspellable-character` is a character CommonMark
rewrites wherever text holds it, `unspellable-whitespace` the newline no inline directive's
content slot spans, in either direction. A claim code names the spelling claimed, never the node that spelling would have built:
a malformed `!adf:table` is a `malformed-directive`, and an alignment colon a `malformed-pipe-table` —
the flavour's own delimiter row is `-` runs, so the grammar refuses the colon rather than ADF's
missing column model doing it. A refusal no spelling recovers from is a gap in the flavour rather
than a code: give the flavour the spelling and the code goes, which the freeze is the last moment
for — `unspellable-link` went at `0.2.0`, the directive link spelling the `href` and `title` it
refused and the attributes the carry held (the maintainer, 2026-09-13). A cause the carry answers gets no code: a mark no
spelling writes rides the carry with its node. A directive whose name reads back to no node is
`unknown-directive-name` rather than a claim code — the spelling is well formed, and telling that
apart from a typo is what a consumer switches on when a later MINOR gives the name meaning. A
reserved name is a known name, so never that code, and the two the flavour reserves part on form:
a form the grammar does not have is a claim code — `!adf:carry`, whose carry is the fence — and a
well-formed form in the wrong place is `unsupported-node-shape`, `!adf:listBreak` parting anything
but two adjacent lists of one type. What
the grammar itself refuses stays a claim code, key order among it, and a leaf given a body is refused
at its opener, as a container missing its closer is (the maintainer, 2026-09-16); a well-formed
directive the node tables refuse — an attribute a node does not hold or spells elsewhere, a value
outside its kind or its canonical spelling, an argument, or a body of a shape its content model does
not take — is
`unsupported-node-shape`, the emitter's code for the same mismatch read the other way — one code
across both directions for good, since the call site knows which direction it called and parting
them after `0.1.0` is MAJOR. `unmappable-html` names the version rather than the element: this one
converts no raw HTML, so at `0.2.0` the mapped elements stop erroring and the code stays for what
no ADF node carries. A refusal found before its path is known — the block walk's, a directive
reader's — is a `ConvertFault`, the code and message alone; the node walk attaches the path as it
descends, so a document reports its first error in document order. `not-an-adf-document` carries
the document's own path throughout: eight of the guard's nine branches read the document's own
shape, and threading a path to the ninth — a malformed node anywhere in the tree — wants the
manual stack §11's no-recursion rule forces, whose empty half no input reaches. The message names
the violation instead. Depth is not one of the nine: `adfDocumentFault` returns the code with the
message, so an attribute value past 500 levels is `unsupported-nesting-depth` from the emitter as
it already is from the parser, both directions refusing the same value. A node's attribute is
counted from the value itself, never from the `attrs` object holding it; a mark's is counted three
levels in, because the block directive spells the whole mark set as one JSON attribute and the
parser reads the value at the bottom of array, mark and `attrs`. `isAdfDocument` is true for a depth fault:
a deep document is a document, as the 2000-level blocks and the 600-deep marks the guard already
waves through are, and depth is the walks' answer rather than the shape's. A non-finite number
stays parted where depth is joined: the parse says `unsupported-node-shape` because the markdown is
at fault, the emit `not-an-adf-document` because the input is, and unlike depth nothing round-trips
inconsistently between them.
`position` is the parse side's alone: an emitter reads no source, so an emit error carries `path`
and nothing more. It is `{ line, offset }` at the start of the line the block holding the refusal
begins on — the offset indexing the string the caller passed, the line counted from 1 — minted by
the block walk and attached as results return, so the innermost block wins, the emitter's own
refusals the parser re-enters for the CommonMark spelling included.
A parse names a position for every refusal it returns, so the type says so rather than the prose:
`Result<T, E extends ConvertError = ConvertError>`, and a direction reading a source returns
`Result<T, ParseError>` — `ConvertError` with `position` required. An optional field a direction
always fills is a branch a consumer cannot take, and the `!` §11 bans is how they take it anyway.
`htmlToAdf` inherits this at `0.2.0`; the composed `markdownToHtml` and `htmlToMarkdown` keep the
wide `Result<T>`, since half their refusals come from an emit stage that read no source.
## 9. Release automation
- `package.json` version on `main` is the source of truth. CI on `main`: tests green and version
differs from npm → publish and tag `vX.Y.Z`. No bump, no deploy; the bump is each shipping PR's
deliberate semver judgment. `publish.sh` is that job, and `private: true` stops it before it
reads the token, so the pipeline is live and silent until the maintainer's first bump drops the
field.
- Docs on `main` describe the release being built rather than the version npm holds, so they match
it the moment the bump publishes; add no interim note marking the gap (the maintainer,
2026-09-16).
- The publish and the tag each observe their own end state — the version on npm, the tag on the
remote — and neither gates the other, so a run that dies between them converges on the next push
to `main` rather than leaving npm ahead of the tags. An unanswered registry reads the same as an
unpublished version, which npm's own duplicate rejection is what catches. The job rebuilds rather
than taking the gate's `dist`: the lockfile is committed, the image is patch-pinned and `tsc` is
deterministic, so the two builds agree, and promoting an artifact would make the release path
depend on a store that the gate would then have to keep.
- Renovate watches devDependencies, Docker pins and action tags; automerges everything on green CI. - Renovate watches devDependencies, Docker pins and action tags; automerges everything on green CI.
- Docker images pin the full patch version (`node:24.19.0-alpine3.24`, never `node:24`), as - Docker images pin the full patch version (`node:24.19.0-alpine3.24`, never `node:24`), as
specific as the publisher tags: `oven/bun:1.4.0-alpine` pins Bun's patch and leaves the base specific as the publisher tags: `oven/bun:1.4.0-alpine` pins Bun's patch and leaves the base
floating because Bun publishes nothing narrower. Actions pin semver tags. floating because Bun publishes nothing narrower. Actions pin semver tags.
## 10. Tests first, in Docker ## 3. Tests first, in Docker
Test for the behaviour wanted first, then implement until green. `node --test`, beside the code. Test for the behaviour wanted first, then implement until green. `node --test`, beside the code.
Node, tsc and npm never run on the host — only via the pinned images (§9). Tests are independent, Node, tsc and npm never run on the host — only via the pinned images (§2). Tests are independent,
coverage does not decline, containers are torn down after a run. containers are torn down after a run. A test reaches only for what Node's, Deno's and Bun's `node:`
shims all carry.
The gate runs that same suite under Deno and Bun as well as Node, the three images pinned alike, Every leg announces its name and, where a container is in play, the image, before it runs and its
and neither extra leg is Node's proof twice. Deno refuses an extensionless or directory specifier, elapsed time after, `publish.sh` alongside `ci.sh`, so a long run reads as progress rather than as
so it holds the module graph to the fully-spelled form a browser can load; Bun runs a hang. A leg added later owes the same marker, and a function a leg reaches chains its statements
JavaScriptCore, the one engine of the three that is not V8, where the Unicode property escapes with `&&`, because the `||` that captures the leg's status suspends `set -e` for everything it
emphasis matching leans on can disagree. Both refuse a run matching no test, so Node's is the only calls. A leg whose output is both streamed and grepped keeps the copy in a `mktemp`
vacuous-green guard, and a test may reach only for what all three `node:` shims carry — the price file: `tee /dev/stderr` reopens fd 2, and under `./ci.sh > log 2>&1` the two offsets punch NUL
of proving those engines over the corpus rather than over a smoke import. holes through each other's lines.
The gate then packs the build and installs the tarball under `package-tests/`, so `files`, `PROPERTY_RUNS=<runs>` raises the property runs and randomizes the seed for local digging. The
`exports` and `types` are proved on the artifact that ships rather than on the source tree a generators and run parameters properties share live in `src/conformance/property-harness.ts`,
self-reference would resolve against. `consumer.ts` typechecks the emitted `.d.ts` from outside outside the build and coverage.
`tsconfig.build.json` — declaration emit leaves the `.ts` specifiers
`rewriteRelativeImportExtensions` rewrites in the JavaScript, and this is what says a consumer's
resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` stays unproven.
`node-floor.js` round-trips the installed package under a Node pinned to `engines.node`'s floor.
A fourth engine reads the build rather than the source: a headless Firefox loads `dist/index.js` Each `- ` bullet in `spec/flavour.md`'s `## Block nodes`, `## Inline nodes` and `## Marks` declares
over HTTP and converts the round-trip, normalization and error fixtures and the real payloads — the nodes named before its first em dash, with the attributes following `Attributes: ` — a
the `commonmark-spec` sort is the Node suite's to check — which is §6's browser half and the only parenthesized value set reading `string`; fenced examples are skipped. Keep prose out of a bullet.
SpiderMonkey there is — the gate's other three engines are two V8s and a JavaScriptCore that is
not Safari's.
A WebDriver session is what carries a verdict back out, the driver and the page's server sharing
one network namespace so each is the other's `127.0.0.1`; `--headless --screenshot` has no such
channel, and loading `dist/index.js` in a globals-stripped realm buys one by not running a browser.
The leg re-checks the conversions and nothing else — each fixture's emitted markdown, its parsed
document, its error code — leaving the corpus's pairing, uniqueness, source positions and
byte-level equality to the Node suite that owns them.
The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and ## 4. Code rules
functions, and a branch floor that only ever moves upward. It sits below 100 because the guards
`noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index
compared against `undefined` — have a half no valid document reaches.
The corpus, all checked in: hand-built fixtures per node and combination; real ADF Atlassian's - Two-space indent, English everywhere. Alphabetical order wherever order carries no meaning,
editor wrote; the CommonMark spec suite against `markdownToAdf` and `markdownToHtml`. keyed on the name a line introduces: an import sorts on its first binding, type imports ahead of
value imports, so moving or renaming a module reorders nothing.
Beside the corpus, properties run over documents generated from the node tables and over generated
markdown, on a fixed seed in the gate; `PROPERTY_RUNS=<runs>` raises the runs and randomizes the
seed for local digging, and a counterexample found becomes a round-trip fixture. The generators and
run parameters properties share live in `src/property-harness.ts`, outside the build and coverage.
`spec/flavour.md` is read as a source too, so the node tables cannot drift from the prose they
copy: each `- ` bullet in `## Block nodes`, `## Inline nodes` and `## Marks` declares the nodes
named before its first em dash, with the attributes following `Attributes: ` — a parenthesized
value set reading `string` — and must equal the tables in `adf/`. Keep prose in those sections out
of a bullet; fenced examples are skipped. It guards the attributes alone: nodes that differ in
content model share a bullet, and the argument attribute is spelled ahead of `Attributes: `, so
both answer to the round-trip corpus and to nothing else where a node has no fixture.
The tables answer to Atlassian's schema too (§5): for every node and mark they spell, the attribute
names and kinds equal what `full.json` and `stage-0.json` hold between them. Value sets stay
documentation, since any value round-trips. What the schema holds and the tables do not spell is
pinned by name — an attribute as a gap, a type as carried — so a re-pin adding either goes red until
someone spells it or pins it.
## 11. Code rules
- Two-space indent, strict TypeScript, English everywhere. Alphabetical order wherever order
carries no meaning.
- Failures are values: everything returns
`Result<T>` — `{ ok: true; value } | { ok: false; error: ConvertError }` — nothing throws.
`try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on
the spot. A reader with no path to name returns `Read<T>` instead, the same two arms over a
`ConvertFault`, and `faulted` attaches the path where the walk knows it.
- Only the hard break's inline segment holds a raw newline — every other spelling escapes one or
refuses it — which is how the whitespace carry finds a line edge.
- Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text
escapes wherever CommonMark could open or close with it, leaving the emitter's own delimiters the
only ones in play, and a pair that matching hands to another delimiter rides the carry instead.
`matchEmphasis` transcribes the reference `process_emphasis` line for line, and its closer walk and
opener search stay whole: broken into named steps they drift from the algorithm being faithful is
the whole point of.
- A readable spelling tried ahead of a general one — a CommonMark block, the image, the pipe
table, a pipe cell — gives way with `undefined` for every shape it cannot spell, and fails only
where the general form fails on the same node. Refusing there refuses a document the general
form spells, so a refusal the general form does not share belongs in the general form or
nowhere — save the nested list a tight spelling would swallow, whose refusal the
tight-versus-blank answer owns (`todo.md` 2b). A readable spelling that must spell its subtree
before it can give way — the list, whose thematic-break first line and blank lines exist only
spelled — hands that one walk to the general form instead: giving way after the walk walks
again at every level, doubling per level (4b).
- Nothing recurses unbounded: the guards walk iteratively, and blocks, marks and JSON values — an
attribute's and a carried node's alike — are all held to 500 levels, so a deep document is a
`Result` rather than the stack overflow that waits near 2000. A level is one block-list
recursion in either direction: a readable list's items sit one below it, its directive
spelling's two. So a list giving way after its walk owes the directive form a level the walk
did not count, and the walk reports its headroom — the least slack any depth guard below it
has — for the fallback to refuse at zero rather than walk again; counting every list twice
halved the list limit, counting the directive form once doubled the parser's frames per level
(the maintainer, 2026-09-18).
- Nothing spreads an unbounded array into a call — a node's siblings, a code block's held lines, a
mark run's segments: the argument list caps near 125k and throws a `RangeError` where a `Result`
is owed. A walk pushes one at a time. A literal spread (`[...value]`) is not the same thing and
is fine (4c).
- A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the
line before it reads, `indexOf` — never a fresh slice per character, and a per-character walk
hoists the scan that does not vary with the character. The pipeline persona feeds documents
nobody typed, and a megabyte through a quadratic walk is a minute rather than a millisecond. A
scan may keep what it read for a later walk of the same text, and the fallback where it kept
nothing must be the same reader over the same text at the same index, so the two cannot disagree
— which is what makes the kept value a memo rather than a second spelling (4c).
- No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the - No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the
fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
unrepresentable. unrepresentable.
- `src/adf/` holds ADF's own knowledge and imports no format. Each format directory (`markdown/`, - Failures are values: everything returns `Result<T>`, nothing throws. `try/catch` only wrapped
`html/`) parts into `emit/` (ADF→format) and `parse/` (format→ADF), its root holding what both tightly around a call that genuinely throws, converted to a result on the spot. A reader with no
directions read. A construct's reader lives in that root beside the regex the emitter escapes path to name returns `Read<T>`, and the walk attaches the path where it knows it.
against, so the two cannot drift; a reader with no emit counterpart goes in `parse/`, unless it is
part of a construct the root already holds — a grammar stays in one file rather than splitting
across the seam. A rule both
directions must answer alike — whether a list marker interrupts a paragraph — is one function
there too, never a copy per direction, however conservative the copy would be. Where the rule is
the emitter's own choice, input consults it rather than restating it: the parser asks
`commonMarkSpelling` which form the emitter picks, and `openingLinkTakesDirective` whether the
line a paragraph's opening link starts forces the directive link, so no fixture the emitter
writes can be refused, and a spelling the emitter refuses gives its own error rather than a
second name for it.
- The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a
format spells the narrowed value. A spelling that re-checks the type is the check's second copy.
Reading a spelling back is the format's own: the reader sits beside the spelling it inverts, so
decode-respell-compare cannot drift, and each format writes its own — canonical JSON for a
number is the markdown flavour's choice, not ADF's.
- Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`); a
file does not repeat its directory in its name — `adf/document.ts`, never
`adf/adf-document.ts`.
- Reuse before adding; the smallest sufficient diff is the benchmark; no speculative generality — - Reuse before adding; the smallest sufficient diff is the benchmark; no speculative generality —
a second consumer, or it goes. a second consumer, or it goes.
- Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`); a file
does not repeat its directory in its name — `adf/document.ts`, never `adf/adf-document.ts`. A name
is the noun `spec/flavour.md` or ADF's schema uses for the thing.
## 12. Prose to a minimum ## 5. Prose to a minimum
Applies everywhere: comments, every markdown file in this repo (this one included), PR text. Applies everywhere: comments, every markdown file in this repo (this one included), PR text.
- Default is no comment. One earns its single line only by naming an invariant, footgun or - Default is no comment. One earns its single line only by naming an invariant, footgun or
external constraint the code cannot show — never restatement, history, absence or arrangement. external constraint the code cannot show — never restatement, history, absence or arrangement.
A second line belongs in the commit message or a decision entry here. A second line belongs in the commit message or a `docs/decisions.md` entry.
- Every prose comment in a diff is a review question; the default answer is delete.
- A doc paragraph says what the repo cannot say for itself, or it goes. The fix for a redundant - A doc paragraph says what the repo cannot say for itself, or it goes. The fix for a redundant
one is deletion, not trimming. A false claim in any doc is a bug, fixed where found. one is deletion, not trimming. A false claim in any doc is a bug, fixed where found.
- Published text — npm README, error messages, API docs — never references internal systems, - Published text — npm README, error messages, API docs — never references internal systems,
tickets or repos. tickets or repos.
## 13. Commits and PRs ## 6. Commits and PRs
One-line commit messages and PR titles; short PR summaries. No AI-attribution markers, ever. One-line commit messages and PR titles; short PR summaries. No AI-attribution markers, ever.
## 14. Non-goals ## 7. The working loop
No wiki markup (§1), no network or filesystem I/O, no name→id resolution (§3), no ADF schema A session works one chunk, starting from the first item in `todo.md` not waiting on an unmet "Lands
validation or exported validator — a refusal that keeps the round-trip is not schema validation, after", and stops when that chunk merges, whatever it was asked to finish: a release is a chain of
so the one a spelled node carrying the same mark type twice earns stays, and input nesting a sessions, so an instruction to work until a release is done names the chain, not the session. An
spelling inside its own kind (`*(*a*)*`) names that mark once, no shipped CSS (§4), no open PR is a chunk already in flight, and finishing it is the session.
streaming APIs, no performance budget past §11's scanning rule — nothing here is tuned, and no Per chunk:
figure is promised. A CLI is a later goal (`todo.md`), not a non-goal.
## 15. The working loop 1. Fresh worktree off updated `origin/main`; implement tests-first (§3).
One unchecked `todo.md` item per session, in the smallest PR-able chunk — split a big milestone
into sub-items in `todo.md` before starting it. A chunk running a little over or under that is not
worth deliberating; what matters is that nothing is left undone in the end. Per chunk:
1. Fresh worktree off updated `origin/main`; implement tests-first (§10).
2. Run the larv-review flow until it passes and CI is green. A reviewer launch states the latest 2. Run the larv-review flow until it passes and CI is green. A reviewer launch states the latest
gate result (commit and outcome); a reviewer does not re-run `ci.sh` or the tests when a gate result (commit and outcome); a reviewer does not re-run `ci.sh` or the tests when a
result exists for the commit under review, or when the diff since that result cannot affect result exists for the commit under review, or when the diff since that result cannot affect
it (docs-only) — re-run only what its own findings or fixes invalidate. it (docs-only) — re-run only what its own findings or fixes invalidate.
3. Merge the PR (standing authorization, this repo only, granted through the `0.2.0` release — 3. Merge the PR (standing authorization, this repo only, granted by the maintainer through the
the maintainer, 2026-09-13), check the box in `todo.md` and move the item's text to `0.2.0` release), report, stop.
`todo-history.md`, leaving its title behind, report, stop. The next chunk gets a fresh session.
Ask, don't guess: any choice where what the maintainer would pick is not near-certain gets asked, Reserved for the maintainer whatever any rule here says: changing `version` in `package.json` (a
and the answer lands as a decision in this file. The confidence bar is very high — asking too bump on `main` publishes, `docs/decisions.md` §Publish on a version bump — every release is the
often is the accepted cost, guessing wrong is not. maintainer's) and the `NPM_TOKEN` secret.
Reserved for the maintainer, never the agent: changing `version` in `package.json` (a bump on ### Ask, don't guess
`main` publishes, §9 — every release is the maintainer's) and the `NPM_TOKEN` secret.
A continuous loop session (`/loop`) counts as a chain of sessions: one chunk per iteration, each Any choice where what the maintainer would pick is not near-certain gets asked. The confidence bar
iteration starting by re-reading `AGENTS.md` and `todo.md` and trusting them over anything is very high — asking too often is the accepted cost, guessing wrong is not.
remembered from earlier iterations. The loop session is a thin driver: each chunk's work runs in
a fresh-context subagent holding this file as its charter, and the driver only relays maintainer An ask is a gap in `docs/decisions.md`, and its answer is the entry that closes it, landing there —
questions, runs the review flow, merges, and cleans up. The loop stops when only never the instance alone; an answer that is a goal lands in the README, one that is a working rule
maintainer-reserved acts remain. here. Before asking, name the class the question belongs to and the entries of that class; where
one already decides it, apply it without asking, and where it reads two ways on this input, that
reading is the ask. Never ask "A or B?": state the gap, the earlier entries of its class, the
nearest text, a candidate entry in that file's voice, and the instance it yields. An entry that
keeps collecting instances is wrong: rewrite it.
Which output the audience expects — README goal 5 — is settled by a reader panel rather than
asked: three fresh-context readers, one per README persona the conversion serves, each given only
`## Audience` and the input, writing what they expect before picking among outputs the goals
allow, rendered, shuffled, with no rationale and nothing saying what is implemented. Three agreeing
settle it; otherwise four more read, five of seven settle it, and less is a missing goal, asked.
The verdict lands in `docs/decisions.md`.
### Stated numbers
A stated number — 500 levels, the branch floor — is kept; a chunk that cannot keep it asks, naming
the number it can reach. A number the code needs and no entry states is a gap.
### The continuous loop
A `/loop` session counts as a chain of sessions, each iteration starting by re-reading `AGENTS.md`
and `todo.md` and trusting them over anything remembered from earlier iterations. The loop session
is a thin driver: each chunk's work runs in a fresh-context subagent holding this file as its
charter, and the driver only relays maintainer questions, runs the review flow, merges, and cleans
up. The loop stops when only maintainer-reserved acts remain.
+28
View File
@@ -0,0 +1,28 @@
# Changelog
## Unreleased
- **Breaking:** directives, the opaque carry among them (now `carry`), are spelled under an `!adf:`
prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`, `!adf:name arg {attrs}`) in place
of the `:::`/`::`/`:name` forms: text holding an unescaped `!adf:` is claimed, and `adf` is an
ordinary code block language. Convert stored markdown per `MIGRATION.md`.
- **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no
CommonMark escape spells is written as `!adf:link[text]{attrs}`.
- **Breaking:** some directive refusals carry `malformed-directive` where they carried
`unsupported-node-shape`, and an empty node's leaf and closed spellings swap which one parses;
`MIGRATION.md` lists each.
- **Breaking:** a link whose text holds another link keeps the inner link and leaves the outer
brackets literal text, where `0.1.0` split the outer link around it; see `MIGRATION.md`.
- Add `adfToPlainMarkdown` and `plainMarkdownToAdf`, a lossy pair converting ADF to and from
markdown GitHub, GitLab and Obsidian render: alerts, callouts, task lists, `==highlights==` and
pipe tables.
- Spell `rule`'s `color`, `style` and `weight`, `layoutSection`'s `columnRuleStyle` and a link's
`collection`, `id` and `occurrenceKey` directly where they rode the opaque carry.
- Fix an image inside another image's description: it flattens into the alt text, where it was
refused.
## 0.1.0
- First release: lossless conversion between ADF and an extended markdown flavour —
`adfToMarkdown`, `markdownToAdf` and `isAdfDocument`. Nothing throws, and every error carries a `code` from a
closed list.
+9 -1
View File
@@ -6,7 +6,7 @@ Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling with
turning each directive into text and each carried node into an `adf` code block. Before `0.2.0` turning each directive into text and each carried node into an `adf` code block. Before `0.2.0`
reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the
recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by
the spelling table. Stored ADF needs no change. the tables below. Stored ADF needs no change.
### Convert markdown ### Convert markdown
@@ -47,6 +47,14 @@ function migrateMarkdown(stored: string) {
A colon run and `:name[` are plain text now, and `adf` an ordinary code block language; text A colon run and `:name[` are plain text now, and `adf` an ordinary code block language; text
holding an unescaped `!adf:` and a `carry` fence are claimed instead. holding an unescaped `!adf:` and a `carry` fence are claimed instead.
### Readings
Markdown the spelling table leaves alone, which `0.2.0` reads as a different document.
| Input | `0.1.0` | `0.2.0` |
| --- | --- | --- |
| a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them |
### Error codes ### Error codes
`unspellable-link` leaves `ConvertErrorCode`: a `switch` naming it stops compiling, and the link `unspellable-link` leaves `ConvertErrorCode`: a `switch` naming it stops compiling, and the link
+136 -43
View File
@@ -5,7 +5,10 @@ an HTML dialect.
**Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at **Status: published — the markdown round-trip (`adfToMarkdown`, `markdownToAdf`); HTML at
`0.2.0`.** `0.2.0`.**
Plan: `todo.md`. Decisions: `AGENTS.md`. The flavour's grammar: Plan:
[`todo.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/todo.md). Decisions:
[`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md). Changes:
[`CHANGELOG.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/CHANGELOG.md). The lossless flavour's grammar:
[`spec/flavour.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/spec/flavour.md). [`spec/flavour.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/spec/flavour.md).
Upgrading from `0.1.0`: [convert your markdown first](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/MIGRATION.md). Upgrading from `0.1.0`: [convert your markdown first](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/MIGRATION.md).
@@ -19,6 +22,36 @@ one-directional and lossy. A consumer that shows a document and lets someone edi
directions lossless — otherwise saving destroys the panels, mentions and attachments it could not directions lossless — otherwise saving destroys the panels, mentions and attachments it could not
represent. represent.
## Goals
The most useful ADF conversion library available, judged by these goals, in priority order:
1. **Lossless, and every call returns a result, never a throw.**
2. **ADF is the hub.**
3. **Each format reads and writes as its standard says.**
4. **Our markdown is CommonMark, extended only where CommonMark has no spelling.**
5. **No surprises: output reads and edits the way its audience expects.**
6. **Lossy conversion drops form, never content.**
7. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.**
8. **Fast, and linear in the document's size.**
9. **Easy to find, and clear at a glance what it does.**
## Audience
Application developers embedding the library, in four personas. All four rely on the guarantees
below and on an error's `code` being a closed list; none may rely on an error message's wording,
which is free text.
- **Viewer/editor app** — shows a document, lets a human edit, posts it back. Relies on the
round-trip holding for whatever the site's editor wrote, unknown node types included, and on a
refusal arriving before the save rather than after.
- **Bot posting content** — turns generated markdown into ADF. Relies on plain CommonMark being
valid input, so nothing upstream has to learn a flavour.
- **Export/indexing tool** — converts ADF to markdown or HTML in bulk. Relies on readable output
and on every refusal being deterministic, so a document that fails fails the same way next run.
- **LLM/agent pipeline** — hands documents to a model as markdown and writes the edits back.
Relies on the round-trip and on markdown a reader half-knowing the lossless flavour can still edit.
## The shape ## The shape
```sh ```sh
@@ -37,25 +70,84 @@ if (result.ok) {
} }
``` ```
Pure functions, no I/O, no configuration. ADF is the hub: markdown↔HTML compose through it. Serves Goals 1, 2 and 7. Pure functions, each taking a whole document and returning a whole
result; no I/O, no configuration. `markdownToHtml` and `htmlToMarkdown` convert through ADF:
they keep only what ADF holds, and refuse what `markdownToAdf` or `htmlToAdf` refuses.
```ts ```ts
adfToMarkdown(doc: AdfDocument): Result<string> adfToMarkdown(doc: AdfDocument): Result<string>
markdownToAdf(markdown: string): Result<AdfDocument, ParseError> markdownToAdf(markdown: string): Result<AdfDocument, ParseError>
isAdfDocument(v: unknown): v is AdfDocument isAdfDocument(v: unknown): v is AdfDocument
adfToPlainMarkdown(doc: AdfDocument): Result<string>
plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError>
adfToHtml(doc: AdfDocument): Result<string> // 0.2.0 adfToHtml(doc: AdfDocument): Result<string> // 0.2.0
htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.2.0 htmlToAdf(html: string): Result<AdfDocument, ParseError> // 0.2.0
markdownToHtml(markdown: string): Result<string> // 0.2.0, via ADF markdownToHtml(markdown: string): Result<string> // 0.2.0
htmlToMarkdown(html: string): Result<string> // 0.2.0, via ADF htmlToMarkdown(html: string): Result<string> // 0.2.0
``` ```
`Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws. `Result<T>` is `{ ok: true; value: T } | { ok: false; error: ConvertError }` — nothing throws.
## Plain markdown
Serves Goal 6. Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` writes
markdown other tools render — GitHub, GitLab, Obsidian and the like — keeping the content and
dropping the rest: attributes, colours, layout, identity. Content is what a reader of the rendered
document sees or follows: its text, images and link targets. It refuses only
`not-an-adf-document`, `unsupported-document-version` and `unsupported-nesting-depth`, and writes
no directive.
`plainMarkdownToAdf` reads what `markdownToAdf` reads and refuses what it refuses, and reads the
conventions below as nodes, taking other tools' spellings too; a backslash keeps a marker as text:
`\==x==`, `> \[!NOTE]`, `- \[x]`. Markdown `adfToPlainMarkdown` wrote reads back and writes again
byte for byte; the document it came from does not come back.
To edit a document and save it back, use `adfToMarkdown` and `markdownToAdf`: saving what this pair
read replaces mentions, attachments and macros with text.
| ADF | Written | Read back |
| --- | --- | --- |
| `panel` | a GitHub alert, `> [!WARNING]`: info `NOTE`, note `IMPORTANT`, tip and success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE` | `NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error, and Obsidian's: hint tip; success, check, done success; attention warning; danger, failure, fail, missing, bug, error error; any other word info — in any case; the rest of the marker's line is the first paragraph |
| `expand`, `nestedExpand` | Obsidian's folded callout, `> [!NOTE]- Title` | `-` or `+` after any word, the rest of the marker's line the title; a link reads `text (target)`, or its text alone where the text is the target with or without `mailto:`; an expand inside an expand is a `nestedExpand` |
| `taskList` | `- [x] Done`, `- [ ] Todo` | a bullet list whose every item is so marked, `[X]` too |
| `backgroundColor` | `==text==` | `==text==` on one line, the text touching both delimiters, bounded outside by whitespace, punctuation or a line edge, or touching a Han, Hangul, Hiragana, Katakana, Thai, Lao, Khmer or Myanmar character on either side, in the editor's default highlight `#f8e6a0` |
| `table` | a pipe table: the first row its header, a cell's blocks on one line, a span kept under its header by empty cells | — |
| `decisionList` | a bullet list | — |
| `mention`, `status`, `emoji`, `date` | their text: `@` kept, a mention with none `@` and its id, an emoji its `shortName` without, a date `2026-09-13` in UTC | — |
| `inlineCard`, `blockCard`, `embedCard` | a link to the card's URL, else its name | — |
| external `media` | `![alt](url)` in a block, `[alt](url)` inline | — |
| stored `media`, `mediaInline`, `extension`, `inlineExtension` | their `alt` or `text` | — |
| `layoutSection`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`, `extensionFrame`, `caption`, a node this version does not know | its blocks or its text | — |
| `placeholder` | nothing | — |
- Content the document only references, with no text of its own to keep, leaves an italic note
naming it where it stood: `_(image not included)_` for stored media with no `alt`,
`_(link card not included)_` for a card with neither URL nor name, an extension with no `text`
its key, `_(jira-issues-table not included)_`, or `_(extension not included)_` without one, and
`_(synced block not included)_` for a `syncBlock`.
- `backgroundColor`, `code`, `em`, `link`, `strike` and `strong` stay; every other mark drops,
keeping its text, and so does a mark the flavour cannot spell where it stands.
- A newline in text is a hard break and in an expand's title a space, edge whitespace outside a
link or code span is trimmed, carriage returns and null characters are removed, and an empty
paragraph drops.
- An ordered list numbered past `999999999`, or adjacent ordered lists whose numbering does not
continue, is one bullet list keeping its numbers as text.
- A task list beside a bullet or decision list, or holding a block other than a task, joins one
bullet list keeping its states as text: `- \[x] Done`.
- Text that would read as a marker takes a backslash: `==` wherever it could open or close a
highlight, `[!…]` opening a quote, and `[x]` or `[ ]` opening any list item, since GitHub reads
that marker per item.
- A node read back carries no `localId` except a `taskList`, `taskItem` or `blockTaskItem`, which
Atlassian's schema requires one on: each gets a UUID v4 hashed from the whole markdown and its
position, the same on every read. Join markdown bound for one document and read it once: the same
markdown read twice into one document repeats its ids.
## The errors ## The errors
An ADF node type this version does not know is not an error: it is carried opaquely and restores Serves Goal 1. An ADF node type this version does not know is not an error: the lossless pair
unchanged (AGENTS.md §3). carries it opaquely and restores it unchanged ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
`ConvertError` is `{ code, message, path, position? }`. `code` is the exported `ConvertErrorCode`, `ConvertError` is `{ code, message, path, position? }`. `code` is the exported `ConvertErrorCode`,
stable across minors and safe to `switch` on exhaustively with no `default`; `message` is free text stable across minors and safe to `switch` on exhaustively with no `default`; `message` is free text
@@ -75,15 +167,15 @@ UTF-16 code unit, a JavaScript string index rather than a codepoint or a byte of
or before the refusal — currently the start of the line the enclosing block begins on; a later or before the refusal — currently the start of the line the enclosing block begins on; a later
minor may narrow that, never widen it. minor may narrow that, never widen it.
Parsing — `markdownToAdf`, and `htmlToAdf` at `0.2.0`: Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.2.0`:
| Code | Fires when | What you can do | | Code | Fires when | What you can do |
| --- | --- | --- | | --- | --- | --- |
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in a `carry` | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text | | `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in a `carry` | write the spelling the message names, or escape the prefix — `\!adf:`, block and inline alike — to keep it literal text |
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way | | `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning | | `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
| `unmappable-html` | the markdown holds a raw HTML tag, comment or processing instruction | remove it or write it in the flavour — ADF holds no raw-HTML node, and the element mapping lands at `0.2.0` | | `unmappable-html` | the input holds an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the lossless flavour |
| `unmappable-image` | an image sits inside other content, or carries a title | give the image a paragraph of its own and drop the title | | `unmappable-image` | an image sits inside other content that is not another image's description, or carries a title | give the image a paragraph of its own and drop the title |
Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`: Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`:
@@ -102,26 +194,33 @@ emit refuses:
| `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span | | `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span |
| `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines | | `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines |
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | | `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take — or markdown writes as a directive a node or mark the flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body | | `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body |
## The guarantees ## The guarantees
Serves Goals 1, 3 and 4.
- `markdownToAdf(adfToMarkdown(doc))` equals `doc` — unknown node types included, carried opaquely - `markdownToAdf(adfToMarkdown(doc))` equals `doc` — unknown node types included, carried opaquely
(AGENTS.md §3). ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML below, with three - Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from
carve-outs — literal text matching directive, pipe-table or strikethrough syntax is claimed `0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
(escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as its own name every exception.
title-less paragraph; mid-text and titled images are error results. Converting back yields the - Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
library's canonical spelling, which round-trips byte-identically — where it converts back at names, with three carve-outs — literal text matching directive, pipe-table or strikethrough
all: a parse succeeding is no promise of that, so keep the source until the way back succeeds. syntax is claimed (escapable — `spec/flavour.md`) — and one gap: a CommonMark image fits only as
its own title-less paragraph; mid-text and titled images are error results, save an image inside
another's description, which flattens into the alt text. Converting back yields the library's
canonical spelling, which round-trips byte-identically — where it converts back at all: a parse
succeeding is no promise of that, so keep the source until the way back succeeds.
``` ` `` ` ``` reads cleanly and then refuses. ``` ` `` ` ``` reads cleanly and then refuses.
- Three CommonMark spellings parse without an error and build a document the reference renders - Four CommonMark spellings parse without an error and build a document the reference
differently: `[](/url)` and `[]()` stay literal text against CommonMark's empty link, a list implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's
continuing past a marker change stays one list against CommonMark's two, and a shortcut empty link, a list continuing past a marker change stays one list against CommonMark's two, a
reference matching its definition only under Unicode case folding stays unresolved. Each is shortcut reference matching its definition only under Unicode case folding stays unresolved, and
pinned `pending` in `corpus/commonmark-spec/exceptions.json`. a link whose text holds an autolink keeps the inner link and leaves the outer brackets literal
- Raw HTML in markdown input is an error result, never a silent drop — a tag, a comment and a text, which the spec requires and the reference itself breaks, nesting one `<a>` in the other.
processing instruction alike. ADF holds no raw-HTML node; the element mapping ships at `0.2.0`. The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite
holds no example of the fourth.
- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding - Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
a carriage return, or a paragraph line beginning with a code span whose backticks read back as a a carriage return, or a paragraph line beginning with a code span whose backticks read back as a
fence. Show the refusal and keep the document read-only; saving markdown you could not produce fence. Show the refusal and keep the document read-only; saving markdown you could not produce
@@ -131,26 +230,20 @@ emit refuses:
error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in
input. input.
- Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist - Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist
is the `taskList` directive. is the `taskList` directive — `plainMarkdownToAdf` turns the marker into a `taskList`.
- A document nested deeper than 500 levels is an error result, not a stack overflow. - A document nested deeper than 500 levels is an error result, not a stack overflow, and no input
- The emitted formats are semver surface (AGENTS.md §8). makes a call loop forever.
- The emitted formats are semver surface
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#the-formats-are-api)).
- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides - **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` equals `doc`; fidelity HTML cannot express rides
`data-*` attributes. Foreign HTML maps a documented element set, an unmappable element is an `data-*` attributes. Foreign HTML maps a documented element set, which markdown's raw HTML reads
error, and well-formed HTML only — no tag-soup recovery. through as well, and a construct outside it is an error; well-formed HTML only — no tag-soup
recovery.
## Who it is for
Personas, never named consumers (AGENTS.md §7):
- **Viewer/editor app** — shows a document, lets a human edit, posts back. Losslessness above all.
- **Bot posting content** — converts generated markdown to ADF; needs the CommonMark promise.
- **Export/indexing tool** — bulk ADF→markdown/HTML; needs readable output.
- **LLM/agent pipeline** — documents to a model as markdown, edits back; needs the round-trip and
markdown legible to a reader that half-knows the flavour.
## The package ## The package
ESM only, no runtime dependencies, public npmjs. Built JavaScript with `.d.ts` beside it. Serves Goal 7. ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts`
Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs under Node, beside it. Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs
Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint. under Node, Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
Contract: `AGENTS.md` §5–6. Contract: [`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#any-es2022-engine), §Any
ES2022 engine to §Public on npm.
+1 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { createServer } from 'node:http' import { createServer } from 'node:http'
import { extname, join } from 'node:path' import { extname, join } from 'node:path'
import { readFileSync, readdirSync } from 'node:fs'
import { toEditorNormal } from '../dist/adf/editor-normal.js' import { toEditorNormal } from '../dist/adf/editor-normal.js'
const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' } const contentTypes = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript' }
+20 -17
View File
@@ -3,28 +3,31 @@ set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
source ./docker-runner.sh source ./docker-runner.sh
in_image "$node_image" npm ci leg "install ($node_image)" in_image "$node_image" npm ci
in_image "$node_image" npm run typecheck leg "typecheck ($node_image)" in_image "$node_image" npm run typecheck
leg "size ratchet ($node_image)" in_image "$node_image" npm run size-ratchet
if ! test_output=$(in_image "$node_image" npm test 2>&1); then test_log=$(mktemp)
printf '%s\n' "$test_output" node_tests() {
exit 1 in_image "$node_image" npm test 2>&1 | tee "$test_log"
fi }
printf '%s\n' "$test_output"
if printf '%s' "$test_output" | grep -q 'ℹ tests 0'; then leg "tests ($node_image)" node_tests
if grep -q 'ℹ tests 0' "$test_log"; then
echo 'the gate ran zero tests — failing instead of a vacuous green' echo 'the gate ran zero tests — failing instead of a vacuous green'
exit 1 exit 1
fi fi
rm -f "$test_log"
in_image "$deno_image" deno test --allow-env=PROPERTY_RUNS --allow-read --no-check src/ leg "tests ($deno_image)" in_image "$deno_image" deno test --allow-env=PROPERTY_RUNS --allow-read --no-check src/
in_image "$bun_image" bun test src/ leg "tests ($bun_image)" in_image "$bun_image" bun test src/
in_image "$node_image" npm run build leg "build ($node_image)" in_image "$node_image" npm run build
in_image "$node_image" sh -c 'set -e leg "pack and install the tarball ($node_image)" in_image "$node_image" sh -c 'set -e
rm -rf package-tests/node_modules rm -rf package-tests/node_modules
npm pack --pack-destination /tmp >/dev/null npm pack --pack-destination /tmp
npm install --no-audit --no-fund --no-package-lock --no-save --offline --prefix package-tests /tmp/*.tgz >/dev/null' npm install --no-audit --no-fund --no-package-lock --no-save --offline --prefix package-tests /tmp/*.tgz'
in_image "$node_image" npx tsc -p package-tests leg "typecheck the consumer ($node_image)" in_image "$node_image" npx tsc -p package-tests
in_image "$floor_image" node package-tests/node-floor.js leg "round-trip on the engines floor ($floor_image)" in_image "$floor_image" node package-tests/node-floor.js
with_firefox in_image "$node_image" node browser-tests/run.js leg "browser ($firefox_image)" with_firefox in_image "$node_image" node browser-tests/run.js
+6 -7
View File
@@ -1,10 +1,10 @@
# The corpus # The corpus
One directory per contract kind, each landing with its milestone: One directory per contract kind:
- `round-trip/` — `<name>.json` + `<name>.md`: the markdown `adfToMarkdown` must emit for that - `round-trip/` — `<name>.json` + `<name>.md`: the markdown `adfToMarkdown` must emit for that
document, byte for byte, and that `markdownToAdf` must read back to it (AGENTS.md §2). Grouped document, byte for byte, and that `markdownToAdf` must read back to it (`docs/decisions.md` §The
by what the fixture exercises. round-trip is the product). Grouped by what the fixture exercises.
- `normalization/` — `<name>.md` + `<name>.json`: markdown input, and the document - `normalization/` — `<name>.md` + `<name>.json`: markdown input, and the document
`markdownToAdf` must build from it, which must in turn emit and read back to itself. The `markdownToAdf` must build from it, which must in turn emit and read back to itself. The
markdown is not canonical. markdown is not canonical.
@@ -16,11 +16,10 @@ One directory per contract kind, each landing with its milestone:
is the suite; `refusals.json` pins each refusing example to its error `code`; `exceptions.json` is the suite; `refusals.json` pins each refusing example to its error `code`; `exceptions.json`
pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a
`reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node `reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node
model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap a later model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap).
milestone may close).
JSON is editor-normal (AGENTS.md §2), two-space indent, keys sorted. `spec.json` is the vendored, JSON is editor-normal (`docs/decisions.md` §Equality is editor-normal), two-space indent, keys
upstream machine-readable suite, byte-exact from sorted. `spec.json` is the vendored, upstream machine-readable suite, byte-exact from
[spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John [spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John
MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not
re-serialized by the corpus gate. re-serialized by the corpus gate.
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
See !adf:link[the docs]{title="Setup guide"} here.
@@ -0,0 +1 @@
unsupported-node-shape
+1
View File
@@ -0,0 +1 @@
See !adf:link[<https://example.com/docs>]{collection=contentId-98237 href="/docs"} here.
+107
View File
@@ -0,0 +1,107 @@
{
"content": [
{
"content": [
{
"text": "[",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/"
},
"type": "link"
}
],
"text": "https://example.com/",
"type": "text"
},
{
"text": "](/v)",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "[a",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "https://example.com/b"
},
"type": "link"
}
],
"text": "https://example.com/b",
"type": "text"
},
{
"text": "c](/v)",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "[",
"type": "text"
},
{
"marks": [
{
"attrs": {
"collection": "contentId-98237",
"href": "/docs"
},
"type": "link"
}
],
"text": "the docs",
"type": "text"
},
{
"text": "](/v)",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"text": "[",
"type": "text"
},
{
"marks": [
{
"attrs": {
"href": "/u"
},
"type": "link"
}
],
"text": "a",
"type": "text"
},
{
"text": "](/v)",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
+7
View File
@@ -0,0 +1,7 @@
[<https://example.com/>](/v)
[a<https://example.com/b>c](/v)
[!adf:link[the docs]{collection=contentId-98237 href="/docs"}](/v)
[[a](/u)](/v)
+16 -1
View File
@@ -1,3 +1,5 @@
: "${EPOCHREALTIME:?the gate times its legs with EPOCHREALTIME — bash 5 or newer}"
bun_image=oven/bun:1.4.0-alpine bun_image=oven/bun:1.4.0-alpine
deno_image=denoland/deno:2.9.6 deno_image=denoland/deno:2.9.6
firefox_image=selenium/standalone-firefox:153.0.4 firefox_image=selenium/standalone-firefox:153.0.4
@@ -10,9 +12,22 @@ in_image() {
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${PROPERTY_RUNS+-e PROPERTY_RUNS} ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@" docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${PROPERTY_RUNS+-e PROPERTY_RUNS} ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@"
} }
# Markers on stderr so a captured leg's value stays clean; leg_* because bash scopes local into the leg's own call.
leg() {
local leg_name=$1 leg_elapsed leg_started leg_status=0
shift
printf '\n\033[1;34m==> %s\033[0m\n' "$leg_name" >&2
# EPOCHREALTIME carries the locale's radix character, so keep the digits and read microseconds.
leg_started=${EPOCHREALTIME//[^0-9]/}
"$@" || leg_status=$?
leg_elapsed=$((${EPOCHREALTIME//[^0-9]/} - leg_started))
printf '\033[1;34m<== %s: %d.%ds\033[0m\n' "$leg_name" "$((leg_elapsed / 1000000))" "$((leg_elapsed % 1000000 / 100000))" >&2
return $leg_status
}
with_firefox() { with_firefox() {
local container in_image_network status=0 local container in_image_network status=0
container=$(docker run -d --rm "$firefox_image") container=$(docker run -d --rm "$firefox_image") || return $?
# The id is baked in: the trap fires after this function's locals are gone. # The id is baked in: the trap fires after this function's locals are gone.
trap "docker rm -f $container >/dev/null 2>&1" EXIT trap "docker rm -f $container >/dev/null 2>&1" EXIT
trap 'exit 130' INT trap 'exit 130' INT
+594
View File
@@ -0,0 +1,594 @@
# Decisions
## Plain markdown is a flavour of the grammar
2026-09-27, the maintainer. Goal 2. Valid while the plain flavour's spellings are ones the markdown
grammar can read and write.
The lossy pair is the plain flavour: the markdown grammar's reader and writer with the flavour set,
its spellings — alerts, callouts, task markers, `==` — read and written there, so a marker line and
a backslash reach them intact; what the flavour cannot spell reduces ADF→ADF ahead of the writer.
## The round-trip is the product
2026-08-23, real payloads 2026-09-15, the maintainer. Goal 1. Valid while a consumer saves back
through the lossless pair.
`markdownToAdf(adfToMarkdown(doc))` and `htmlToAdf(adfToHtml(doc))` must equal `doc` — anything
less silently destroys content an editor could not represent, in a document it did not author.
When losslessness and readability conflict, losslessness wins. Round-trip equality is a property
tested over a checked-in corpus (`corpus/README.md`), not a claim made in prose. Its real payloads
are invented content written in Atlassian's editor on the maintainer's test site, so none is
sanitized and a mention keeps the test user's real account id.
## Markdown in is a canonical fixpoint
2026-08-23, the maintainer. Goals 1 and 4. Valid while markdown input may be written by hand.
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
back yields the library's canonical spelling, and that spelling round-trips byte-identically —
where there is a way back. CommonMark spells some things the flavour has no escape for — a
paragraph opening with a code span whose backticks read back as a fence — so a parse succeeding
does not imply a spellable document; `corpus/commonmark-spec/exceptions.json` names those.
## Equality is editor-normal
2026-08-24, the maintainer. Goal 1. Valid while markdown cannot tell apart the ADF shapes this
merges.
"Equals" is structural equality over editor-normal ADF — adjacent text nodes with identical marks
and no attributes merged, JSON number semantics, an empty attrs object, marks array or content
array the absent key — the only domain markdown can restore.
`todo.md` item 40 replaces this with deep equality (2026-09-28, the maintainer).
## Unknown nodes ride the carry
2026-08-23, extended to misplaced known nodes 2026-08-26, the maintainer. Goal 1. Valid while ADF
holds nodes, or node positions, this library does not spell.
An unknown ADF node is carried opaquely — raw JSON rides a dedicated syntax in both formats and
restores to a deep-equal node. The round-trip holds for documents newer than the library. So does
a known node no section spells where it stands: a markdown serializer spells a node by type without
checking its position, and refusing loses a document ADF itself keeps in an `unsupportedBlock`.
Where a container's own spelling cannot hold the child it has — a `bulletList` holding other than
`listItem`, a `codeBlock` other than text — the error result names that instead.
## Foreign HTML sorts three ways
2026-08-23, the sort 2026-09-20, the maintainer. Goals 1 and 4. Valid while ADF holds no node
for a bare container, a comment or a script. Lands with `todo.md` item 6.
Every foreign element `htmlToAdf` and `markdownToAdf` read sorts one of three ways, never a silent
drop of content:
- A container around document content that ADF has no node for unwraps to its children, its own
attributes dropped: `<div align="center">text</div>` keeps `text`, losing the alignment.
- Content ADF cannot hold is an error result naming it. A comment is one: a person wrote those
words, and neither of Atlassian's schemas holds them — `annotation`'s `inlineComment` carries an
id, `placeholder` is the editor's own hint, `extension` names a vendor app.
- What is not document content drops whole: `<script>` and `<style>`, their text with them.
`<details><summary>Title</summary>…</details>` is an `expand` titled by its summary, a
`nestedExpand` inside another; an empty one is refused, since `expand` requires content. A `style`
attribute is not read at `0.2.0`: the `textColor` and `backgroundColor` it could reach cost more
than they buy.
`plainMarkdownToAdf` reads through `markdownToAdf`'s parser, so it takes the same set.
## Names stay text
2026-08-23, the maintainer. Goal 7. Valid while resolving a name to an id needs I/O.
A bare `@name` or `:smile:` in typed text stays a text node. Only directives produce
mention/emoji/media nodes; resolving names to ids is the consumer's job.
## Directives under `!adf:`
2026-08-23, prefixed `!adf:` 2026-09-16, the maintainer. Goals 4 and 5. Valid while prose does not
write `!adf:`.
Directives are one grammar for everything markdown lacks, namespaced under `!adf:`:
`!adf:panel info` … `!adf:/panel` blocks, `!adf:mention[@Mikael]{id=5b10a2}` inline, `\!adf:` the
one escape. Not CommonMark's generic-directives proposal: its `:::` claims a form prose writes, and
its fence-length discipline ties a container's opener to its own body, where closing from the
opener nests by itself and leaf versus container falls out of the node's content model.
## CommonMark is a subset
2026-08-23, the maintainer. Goal 4. Valid while prose rarely writes the shapes the carve-outs claim.
Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a
directive, a pipe table or a `~~` pair is claimed — plus one image gap.
## Tables
2026-08-23, the maintainer. Goal 5. Valid while a pipe table holds only one header row and inline
cells.
One header row plus plain inline cells → pipe table; anything richer → directive form.
## Links
2026-09-13, nesting 2026-09-17, the maintainer. Goals 1 and 5. Valid while CommonMark's link
syntax is what readers edit.
`[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark spells the
mark; `!adf:link[text]{attrs}` where it does not — an attribute CommonMark cannot hold, an `href` or
`title` no canonical escape spells, a paragraph opening whose CommonMark spelling would read as a
link reference definition — and a directive link CommonMark could spell is refused. No link wraps a
link — the bracket form goes literal, the directive form refused — which is CommonMark's prose
where its reference implementation nests one `<a>` in another.
## Ids stay site-local
2026-08-23, the maintainer. Goal 1. Valid while ADF ids are minted per site.
Identity-bearing nodes carry their ids in attributes; a document is only portable within its site —
accepted.
## Plain task ids come from position
2026-09-26, spelling 2026-09-29, the maintainer. Goals 6 and 7. Valid while a site rejects a task
node with no `localId`.
`plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId`
in the editor's UUID v4 shape, hashed from the whole markdown and the node's order among those it
mints, skipping any id the document holds: the same markdown reads to the same ids every run,
different markdown to different ids. The same markdown pasted twice into one document repeats its
ids: determinism wins over that case. A node the carry restores stays deep-equal (§Unknown nodes ride the carry): its
ids are only skipped.
## A callout title keeps its link targets
2026-09-29, the maintainer. Goals 5 and 6. Valid while an expand's `title` is a string.
`plainMarkdownToAdf` writes a link in a folded callout's title as its text and its target in
parentheses: `> [!faq]- See [x](http://y)` reads to the title `See x (http://y)`. A link whose text
is its target, with or without `mailto:`, keeps its text alone: `<http://y>` titles `http://y`,
`<a@b.c>` `a@b.c` — three persona readers agreeing, 2026-09-30.
## The plain flavour's spellings
2026-09-14, panels 2026-09-25 and 2026-09-29, the maintainer. Goals 5 and 6. Valid while GitHub's
renderer is the one the audience's markdown is read in.
README §Plain markdown's rows come from a survey of GitHub, GitLab, Gitea, Obsidian, Pandoc,
MkDocs, Docusaurus, Typora, Joplin, Logseq, Bear, Notion, Azure DevOps and Discord, GitHub's
renderer confirming each shape. Reader panels settled `error` as an error panel, the `==` bounds
(3 of 3) and a Han, Hangul, kana, Thai, Lao, Khmer or Myanmar character on either side bounding a
delimiter, so `は==日本語==で` (3 of 3), `==한국어==에서만` and `iPhone==専用==` (6 of 7) highlight,
the external image's two forms (6 of 7), a rule opening a list item dropping and the omission
notes (3 of 3), and a list's numbering overflowing into bullets (3 of 3, 5 of 7).
An omission note reads as the converter's, never as the author's.
Reading takes other tools' spellings, since it reads their output and writes none of them.
Rejected: `~sub~` and `^sup^` (`~2~` is a strike on GitHub, so `subsup` drops), underline and colour
spellings, raw HTML (`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family,
footnotes, definition lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states
past `[x]`/`[ ]`, and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes.
## The HTML dialect
2026-08-23, the maintainer. Goals 5 and 7. Valid while HTML output is read by consumers styling it
themselves.
The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*`
for what HTML cannot express, text always escaped. No stylesheet ships.
## No runtime dependencies
2026-08-23, the maintainer. Goal 7. Valid while ~20 lines of own code, or a vendored table, do each
job a dependency would.
`dependencies` is empty. A runtime dependency enters only through an entry here stating why ~20
lines of own code cannot do the job, who maintains it, and what auditing it costs. So the CommonMark
and HTML parsers are written in this repo.
## Standards ship as data
2026-08-30, the CommonMark suite 2026-09-05 and ADF's schemas 2026-09-13, the maintainer. Goals 1,
4 and 7. Valid while each table is fixed data a dependency would only wrap.
A table a standard fixes is data rather than a dependency: HTML5's 2125 semicolon-terminated
character references ship packed in their own module, so entity decoding is complete without one.
The CommonMark spec suite is the same shape of data and ships vendored at `corpus/commonmark-spec/`
rather than as the `commonmark-spec` dev dependency — that package is CommonJS-only, and Renovate
auto-bumping a spec version would silently point the vendored exception list's example numbers at a
renumbered suite. A spec bump is a deliberate re-pin, exceptions re-derived by hand beside it.
Atlassian's ADF JSON Schemas ship vendored the same way, at `spec/adf-schema/`, rather than as the
`@atlaskit/adf-schema` dev dependency — CommonJS-only, some fifty packages with React among them,
and a release most days for Renovate to automerge — re-pinned by hand when a payload or a report
shows the need.
## fast-check
2026-09-14, the maintainer. Goal 1. Valid while a failing generated document needs shrinking by
hand otherwise.
`fast-check` earns its place as a devDependency shrinking a failing generated document to the
nodes that break it.
## Any ES2022 engine
2026-09-01, the maintainer. Goal 7. Valid while ES2022 is the floor browsers and servers share.
The library runs on any ES2022 engine, not only Node — a browser as readily as a server. The
shipped source is ECMAScript and nothing else: no host import, no host global, no DOM.
`tsconfig.build.json` is that gate, typechecking and emitting the shipped files alone, so
`node:fs`, `process` and an ES2024 method are compile errors here rather than a consumer's crash
there. The standard is the line, never an engine list: one implementing it in part — Hermes is the
live doubt, on the Unicode property escapes emphasis matching leans on and on lookbehind — is out
of scope rather than a bug. Node's test runner, the corpus reads and the build are the repo's own,
never the library's, and `engines.node` states the floor the shipped JavaScript needs — `>=18` —
never the higher one those repo-only tools want.
## ESM only
2026-08-23, the maintainer. Goal 7. Valid while the audience's toolchains all import ES modules.
No CommonJS build, no dual-package hazard.
## One built entrypoint
2026-08-23, the maintainer. Goal 7. Valid while Node refuses to type-strip under `node_modules`.
Built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint — Node refuses to
type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so it cannot serve
an npm consumer.
## Public on npm
2026-08-23, the name 2026-09-01, the maintainer. Goals 2 and 7. Valid while the package's source
stays public beside it.
Published to public npm as `@larvit/adf-codec`. Public source: the Gitea repo goes public,
LICENSE in place, before the first publish. A codec, since it converts both directions, and named
for the hub rather than the formats around it.
## The formats are API
2026-08-23, strict input 2026-09-01, content models 2026-09-16, the maintainer. Goal 1.
Valid while consumers store what the library emits.
The emitted markdown and HTML are contracts. After 1.0: previously-emitted output parsing
differently, or not at all, is MAJOR; new syntax while old output still round-trips is MINOR.
Pre-1.0, normal 0.x rules. A spelled node's content model is part of that contract — leaf or
container is the model, not the syntax — so giving a spelled node's model content it had not, or
taking it away, is MAJOR whatever ADF's own schema does. Input reads the canonical directive
spelling alone — spacing, key order, each value's spelling — since loosening it later is MINOR.
The error surface is a contract too; `README.md` §The errors states it to the consumer, and the
types in `src/result.ts` hold its shape.
## The code list
2026-08-25, the maintainer; dated below where a rule came later. Goal 1. Valid while a consumer
switches on `code` with no `default`.
- Adding, removing or renaming a code is breaking, so a new cause takes an existing code whose
name reads true of it in both directions; where none does and a plain name exists, a new code —
in any 0.x minor, and after 1.0 only in a MAJOR (2026-09-18).
- A refusal whose cause is this library's own invariant rather than the input takes the existing
code nearest what the consumer sees — a document that does not convert is
`unsupported-node-shape` — since a code no input reaches is one no consumer can switch on
(2026-09-20).
- A refusal no spelling recovers from is a gap in the flavour rather than a code: give the flavour
the spelling and the code goes (`unspellable-link`, 2026-09-13). A cause the carry answers gets
no code: a mark no spelling writes rides the carry with its node.
## Which code a cause takes
2026-08-28, the maintainer; dated below where a rule came later. Goal 1. Valid while a consumer
handles one cause alike whichever node, attribute or direction raised it.
- A code names the cause; where one cause recurs across node types, across one mark's attributes
or across directions, one code covers them all and `path` and `message` say which —
`unsupported-nesting-depth` is the 500-level guard whichever direction hits it,
`unspellable-character` the text node and the code block alike. Where two codes stay apart, the
line between them is what they name: `unspellable-character` is a character CommonMark rewrites
wherever text holds it, `unspellable-whitespace` the newline no inline directive's content slot
spans, in either direction.
- A claim code names the spelling claimed, never the node that spelling would have built: a
malformed `!adf:table` is a `malformed-directive`, and an alignment colon a
`malformed-pipe-table` — the flavour's own delimiter row is `-` runs, so the grammar refuses the
colon rather than ADF's missing column model doing it. What the grammar itself refuses stays a
claim code, key order among it, and a leaf given a body is refused at its opener, as a container
missing its closer is (2026-09-16).
- A directive whose name reads back to no node is `unknown-directive-name` rather than a claim
code — the spelling is well formed, and telling that apart from a typo is what a consumer
switches on when a later MINOR gives the name meaning. A reserved name is a known name, so never
that code, and the two the flavour reserves part on form: a form the grammar does not have is a
claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place
is `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one
type (2026-09-01).
- A well-formed directive the node tables refuse — an attribute a node does not hold or spells
elsewhere, a value outside its kind or its canonical spelling, an argument, or a body of a shape
its content model does not take — is `unsupported-node-shape`, the emitter's code for the same
mismatch read the other way: one code across both directions for good, since the call site
knows which direction it called and parting them after `0.1.0` is MAJOR (2026-09-23).
- A non-finite number takes two codes: `unsupported-node-shape` parsing, `not-an-adf-document`
emitting — no document holds one, so no round-trip crosses them (2026-09-23).
## `message` and `path`
2026-09-03, the path 2026-09-23, the maintainer. Goals 1 and 4. Valid while a person fixing the
input reads `message`.
- A message names the violation, not the rule alone — a rule by itself states a truth the reader
must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose
it names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and
inline alike, `\|` for every pipe row.
- `not-an-adf-document` carries the document's own path throughout: seven of the guard's eight
branches read the document's own shape, and threading a path to the eighth — a malformed node
anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces. The message
names the violation instead.
## Publish on a version bump
2026-08-23, converging 2026-09-03, the maintainer. Goal 7. Valid while CI on `main` holds the npm
token.
`package.json` version on `main` is the source of truth. CI on `main`: tests green and the version
not yet on npm → publish and tag `vX.Y.Z`. No bump, no deploy. `publish.sh` is that job.
The publish and the tag each check their own end state — the version on npm, the tag on the
remote — so a run that dies between them converges on the next push
to `main` rather than leaving npm ahead of the tags. An unanswered registry reads the same as an
unpublished version, which npm's own duplicate rejection is what catches. The job rebuilds rather
than taking the gate's `dist`: the lockfile is committed, the image is patch-pinned and `tsc` is
deterministic, so the two builds agree, and promoting an artifact would make the release path
depend on a store that the gate would then have to keep.
## Docs describe the release being built
2026-09-16, the maintainer. Goal 7. Valid while a bump on `main` publishes.
Docs on `main` describe the release being built rather than the version npm holds, so they match it
the moment the bump publishes; add no interim note marking the gap.
## No schema validation
2026-08-23, the mark refusal 2026-08-25, the maintainer. Goal 1. Valid while the site a document
is saved to validates it.
No ADF schema validation or exported validator. A refusal that keeps the round-trip is not schema
validation, so the one a spelled node carrying the same mark type twice earns stays, and input
nesting a spelling inside its own kind (`*(*a*)*`) names that mark once.
## The gate runs on Deno and Bun
2026-09-01, Deno's reason 2026-09-28, the maintainer. Goals 3 and 7. Valid while the library claims
any ES2022 engine.
The gate runs the suite under Deno and Bun as well as Node. Bun runs JavaScriptCore, the one engine
of the three that is not V8, where the Unicode property escapes emphasis matching leans on can
disagree. Deno shares Node's V8 and stays to prove the library runs there too, catching what the
two runtimes leave undocumented. Both refuse a run matching no test, so Node's is the only
vacuous-green guard, and `AGENTS.md` §3's `node:` shims rule is the price of proving those engines
over the corpus rather than over a smoke import.
## The gate installs the tarball
2026-09-03, the maintainer. Goal 7. Valid while consumers install the packed package.
The gate packs the build and installs the tarball under `package-tests/`, so `files`, `exports`
and `types` are proved on the artifact that ships rather than on the source tree a self-reference
would resolve against. `consumer.ts` typechecks the emitted `.d.ts` from outside
`tsconfig.build.json` — declaration emit leaves the `.ts` specifiers
`rewriteRelativeImportExtensions` rewrites in the JavaScript, and this is what says a consumer's
resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` stays unproven.
`node-floor.js` round-trips the installed package under a Node pinned to `engines.node`'s floor.
## Firefox reads the build
2026-09-04, the maintainer. Goal 7. Valid while the library claims a browser and no other leg runs
SpiderMonkey.
A headless Firefox loads `dist/index.js` over HTTP and converts the round-trip, normalization and
error fixtures and the real payloads — the `commonmark-spec` sort is the Node suite's to check —
which is the browser half of §Any ES2022 engine and the only SpiderMonkey there is — the gate's
other engines are two V8s and a JavaScriptCore that is not Safari's. A WebDriver session is what
carries a verdict back out, the driver and the page's server sharing one network namespace so each
is the other's `127.0.0.1`; `--headless --screenshot` has no such channel, and loading
`dist/index.js` in a globals-stripped realm buys one by not running a browser. The leg re-checks
the conversions and nothing else — each fixture's emitted markdown, its parsed document, its error
code — leaving the corpus's pairing, uniqueness, source positions and byte-level equality to the
Node suite that owns them. `selenium/standalone-firefox` runs it over the smaller
`instrumentisto/geckodriver`: the leg is worth a current SpiderMonkey, and that image fell four
Firefox majors behind.
## The coverage floors
2026-08-24, the maintainer. Goal 1. Valid while `noUncheckedIndexedAccess` and ADF's optional keys
force guards with a half no valid document reaches.
The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and
functions, and a branch floor that only ever moves upward. It sits below 100 because the guards
`noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index
compared against `undefined` — have a half no valid document reaches.
## The size ratchet
2026-09-20, the maintainer. KISS, a technical principle. Valid while no measure picks out what
readers find hard better than a function's length.
`.oxlintrc.json`'s single rule, over the files `tsconfig.build.json` builds, is a per-function line
ceiling, set at that set's worst and moving only downward. It covers the built files alone, since
one ceiling over the tests too would have to be their worst, loosening the guard over the shipped
code. It guards against drift and never drives a refactor, so no cyclomatic rule and no second lint
rule join it: neither measure picked out what nine readers found hard (the comprehension panel,
2026-09-20). `oxlint` measures it since TypeScript 7 is a native compiler publishing no in-process
parser, only the `unstable/` AST surface an out-of-process handshake reaches. Three switches guard
a silent green: `IIFEs: true`, since oxlint exempts an IIFE otherwise; an explicit `-c`, so a
config gone missing fails the leg instead of falling back to oxlint's own defaults; and
`--deny-warnings`, since a rule from a category this config never names arrives as a warning it
exits 0 on.
## Properties on a fixed seed
2026-09-14, the maintainer. Goal 1. Valid while a red gate must reproduce.
Beside the corpus, properties run over documents generated from the node tables and over generated
markdown, on a fixed seed in the gate; a counterexample found becomes a round-trip fixture.
## The CommonMark suite checks three ways
2026-08-27, the maintainer. Goals 1 and 3. Valid while the suite's answers are HTML ADF cannot be
compared against.
Each example is a named error or markdown that parses and emits to itself byte for byte; its
reference HTML's text, tags stripped and entities decoded, equals the parsed document's; and its
elements count the marks and nodes they map to. The fixpoint alone passes a parser returning the
empty document, the text alone one dropping every emphasis. An exception is the maintainer's to
add, and valid CommonMark parsing to a document `adfToMarkdown` refuses where a spelling could exist
is a bug to fix, never an exception.
## The flavour spec is read as a source
2026-09-01, the maintainer. Goal 1. Valid while `spec/flavour.md` restates the node tables in
prose.
`spec/flavour.md` is read as a source, so the node tables cannot drift from the prose they copy:
its node and mark bullets must equal the tables in `adf/`. It guards the attributes alone: nodes
that differ in content model share a bullet, and the argument attribute is spelled outside the
bullet's attribute list, so both answer to the round-trip corpus and to nothing else where a node
has no fixture.
## The node tables answer to Atlassian's schema
2026-09-13, the maintainer. Goal 1. Valid while a site's editor writes what Atlassian's schema
holds.
For every node and mark the tables spell, the attribute names and kinds equal what `full.json` and
`stage-0.json` (§Standards ship as data) hold between them. Value sets stay documentation, since
any value round-trips. What the schema holds and the tables do not spell is pinned by name — an
attribute as a gap, a type as carried — so a re-pin adding either goes red until someone spells it
or pins it.
## Nothing recurses unbounded
2026-08-25, the directive form's count 2026-09-18, the maintainer. Goal 1. Valid while an engine's
stack overflows near 2000 frames.
The guards walk iteratively, and blocks, marks and JSON values — an attribute's and a carried
node's alike — are all held to 500 levels (`largestNesting`), so a deep document is a `Result`
rather than the stack overflow that waits near 2000. An attribute is counted from its value; a
spelling that nests it deeper — the block directive's `marks`, the carry — refuses in its own
format, as its parser does. A list giving way to the directive form refuses at zero headroom rather
than walking again; counting every list twice halved the list limit, counting the directive form
once doubled the parser's frames per level.
## Nothing spreads an unbounded array
2026-09-18, the maintainer. Goal 1. Valid while engines cap a call's arguments.
Nothing spreads an unbounded array into a call — a node's siblings, a code block's held lines, a
mark run's segments: the argument list caps near 125k and throws a `RangeError` where a `Result` is
owed. A walk pushes one at a time. A literal spread (`[...value]`) is not the same thing and is
fine.
## A retry loop checks its own termination
2026-09-20, the maintainer. Goal 1. Valid while a fallback can fail to spell what it is handed.
A loop retrying an input until a fallback spells it refuses the pass taking no fallback, so its
termination is the loop's own check.
## Readers scan by index
2026-08-30, the kept scan 2026-09-18, the maintainer. Goal 8. Valid while the pipeline persona
feeds documents nobody typed.
A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the line
before it reads, `indexOf` — never a fresh slice per character, and a per-character walk hoists the
scan that does not vary with the character: a megabyte through a quadratic walk is a minute rather
than a millisecond. A scan may keep what it read for a later walk of the same text, and the
fallback where it kept nothing must be the same reader over the same text at the same index, so the
two cannot disagree — which is what makes the kept value a memo rather than a second spelling.
## The spelling memo
2026-09-19, the maintainer. Goal 8. Valid while the `commonMarkSpelling` ask spells a node once
per level above it otherwise.
The parse and the plain reduction keep each node's readable spelling in a memo, so the
`commonMarkSpelling` ask stops spelling a node once per level above it. `text` and `spelling` carry
no depth and `headroom` is affine in it, so a read at or above the depth that filled the entry
rebases; a read below re-spells, because a hit skips the depth guards the walk it replaces runs and
an ordered list past the marker cap gives way, spending two emitter levels where the parser spent
one. Only what succeeded is kept, so no path minted at another position is ever read.
## Cost fixes are measured, never timed
2026-09-18, the maintainer and the stability-reviewer. Goal 8. Valid while Goal 8 promises growth
rather than a figure.
A cost fix that changes no behaviour lands on the suite staying green with no fixture output
changed, and a before-and-after figure in its PR; the gate times nothing. Measured and kept:
`adfDocumentFault`'s shape and depth walks stay two — the parting gives depth its own code — at
52 ms for a 9 MB document the emit takes 314 ms over; and `continuesContainer`'s re-scan per item
level stays, linear in the lines and bounded in depth by the 500-level guard.
## Only the hard break holds a raw newline
2026-08-26, the maintainer. Goal 1. Valid while the whitespace carry finds a line edge by its raw
newline.
Only the hard break's inline segment holds a raw newline — every other spelling escapes one or
refuses it — which is how the whitespace carry finds a line edge.
## Emphasis follows CommonMark's matching
2026-08-27, the maintainer. Goals 1 and 3. Valid while CommonMark's emphasis rules are the
reader's.
Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text
escapes wherever CommonMark could open or close with it, leaving the emitter's own delimiters the
only ones in play, and a pair that matching hands to another delimiter rides the carry instead.
## Readable spellings take the `try` prefix
2026-09-21, the maintainer. Goals 1 and 5. Valid while a readable spelling's refusal would cost a
document the general form spells.
A readable spelling tried ahead of a general one takes the `try` prefix and fails only where the
general form fails on the same node: refusing there refuses a document the general form spells, so a
refusal the general form does not share belongs in the general form or nowhere. A readable spelling
that must spell its subtree before it can give way — the list, whose thematic-break first line and
blank lines exist only spelled — hands that one walk to the general form instead: giving way after
the walk walks again at every level, doubling per level.
## The attribute vocabulary is ADF's
2026-08-27, the maintainer. Goal 2. Valid while every format spells the same ADF attributes.
`adf/` walks the attribute vocabulary and narrows each value to its kind, and a format spells the
narrowed value. A spelling that re-checks the type is the check's second copy. Reading a spelling
back is the format's own: the reader sits beside the spelling it inverts, so
decode-respell-compare cannot drift, and each format writes its own — canonical JSON for a number
is the markdown flavour's choice, not ADF's.
## The source parts by ADF and format
2026-08-27, placement 2026-09-18, `adf/`'s bar 2026-09-21, the maintainer. Goal 2. Valid while
each format has a reader and a writer through ADF.
`src/adf/` holds ADF's own knowledge, imports no format, and is where a construct both formats read
lives: the question is answered in ADF's vocabulary — a node type, an attribute kind, a content
model — and no delimiter, element name or escape reaches it. A helper that cannot answer that way
is two constructs, the ADF question there and the spelling in each format, the seam
`markAttributes` and `markSpellings` already draw; one that cannot be split is a gap to ask.
`markdown/` and `html/` are peers: neither imports the other, and no third directory sits between
them. A primitive knowing neither ADF nor a format stays at `src/` root. A construct rises to
`adf/` on its second consumer, not in anticipation of one. A directory follows a split
`spec/flavour.md` draws, and a placement nothing here settles goes beside its only reader, or in
what both read where there are two.
Each format directory parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest of it
holding what both directions read. A construct's reader lives there beside the regex the emitter
escapes against, so the two cannot drift; a reader with no emit counterpart goes in `parse/`,
unless it is part of a construct that side already holds — a grammar stays in one file rather than
splitting across the seam. A rule both directions must answer alike — whether a list marker
interrupts a paragraph — is one function there too, never a copy per direction, however
conservative the copy would be. Where the rule is the emitter's own choice, input consults it
rather than restating it, and that is the only import `parse/` takes from `emit/` —
`commonMarkSpelling` and `openingLinkTakesDirective` — so no fixture the emitter writes can be
refused, and a spelling the emitter refuses gives its own error rather than a second name for it.
+397
View File
@@ -11,12 +11,360 @@
"devDependencies": { "devDependencies": {
"@types/node": "24.13.3", "@types/node": "24.13.3",
"fast-check": "4.10.0", "fast-check": "4.10.0",
"oxlint": "1.83.0",
"typescript": "7.0.2" "typescript": "7.0.2"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.83.0.tgz",
"integrity": "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-android-arm64": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.83.0.tgz",
"integrity": "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-darwin-arm64": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.83.0.tgz",
"integrity": "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-darwin-x64": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.83.0.tgz",
"integrity": "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-freebsd-x64": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.83.0.tgz",
"integrity": "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.83.0.tgz",
"integrity": "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.83.0.tgz",
"integrity": "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm64-gnu": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.83.0.tgz",
"integrity": "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-arm64-musl": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.83.0.tgz",
"integrity": "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.83.0.tgz",
"integrity": "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.83.0.tgz",
"integrity": "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-riscv64-musl": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.83.0.tgz",
"integrity": "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-s390x-gnu": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.83.0.tgz",
"integrity": "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-x64-gnu": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.83.0.tgz",
"integrity": "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-linux-x64-musl": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.83.0.tgz",
"integrity": "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-openharmony-arm64": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.83.0.tgz",
"integrity": "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-win32-arm64-msvc": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.83.0.tgz",
"integrity": "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-win32-ia32-msvc": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.83.0.tgz",
"integrity": "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxlint/binding-win32-x64-msvc": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.83.0.tgz",
"integrity": "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.3", "version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -390,6 +738,55 @@
"node": ">=12.17.0" "node": ">=12.17.0"
} }
}, },
"node_modules/oxlint": {
"version": "1.83.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.83.0.tgz",
"integrity": "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==",
"dev": true,
"license": "MIT",
"bin": {
"oxlint": "bin/oxlint"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/sponsors/oxc-project"
},
"optionalDependencies": {
"@oxlint/binding-android-arm-eabi": "1.83.0",
"@oxlint/binding-android-arm64": "1.83.0",
"@oxlint/binding-darwin-arm64": "1.83.0",
"@oxlint/binding-darwin-x64": "1.83.0",
"@oxlint/binding-freebsd-x64": "1.83.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.83.0",
"@oxlint/binding-linux-arm-musleabihf": "1.83.0",
"@oxlint/binding-linux-arm64-gnu": "1.83.0",
"@oxlint/binding-linux-arm64-musl": "1.83.0",
"@oxlint/binding-linux-ppc64-gnu": "1.83.0",
"@oxlint/binding-linux-riscv64-gnu": "1.83.0",
"@oxlint/binding-linux-riscv64-musl": "1.83.0",
"@oxlint/binding-linux-s390x-gnu": "1.83.0",
"@oxlint/binding-linux-x64-gnu": "1.83.0",
"@oxlint/binding-linux-x64-musl": "1.83.0",
"@oxlint/binding-openharmony-arm64": "1.83.0",
"@oxlint/binding-win32-arm64-msvc": "1.83.0",
"@oxlint/binding-win32-ia32-msvc": "1.83.0",
"@oxlint/binding-win32-x64-msvc": "1.83.0"
},
"peerDependencies": {
"oxlint-tsgolint": ">=7.0.2001",
"vite-plus": "*"
},
"peerDependenciesMeta": {
"oxlint-tsgolint": {
"optional": true
},
"vite-plus": {
"optional": true
}
}
},
"node_modules/pure-rand": { "node_modules/pure-rand": {
"version": "8.4.2", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
+4 -2
View File
@@ -1,11 +1,13 @@
import { adfToMarkdown, isAdfDocument, markdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec' import { adfToMarkdown, adfToPlainMarkdown, isAdfDocument, markdownToAdf, plainMarkdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec'
const document: AdfDocument = { content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 } const document: AdfDocument = { content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }
const emitted: Result<string> = adfToMarkdown(document) const emitted: Result<string> = adfToMarkdown(document)
const parsed: Result<AdfDocument, ParseError> = markdownToAdf('x\n') const parsed: Result<AdfDocument, ParseError> = markdownToAdf('x\n')
const plainEmitted: Result<string> = adfToPlainMarkdown(document)
const plainParsed: Result<AdfDocument, ParseError> = plainMarkdownToAdf('x\n')
const guarded: boolean = isAdfDocument(document) const guarded: boolean = isAdfDocument(document)
const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code
const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line
export const surface = { code, guarded, line } export const surface = { code, guarded, line, plainEmitted, plainParsed }
+3 -1
View File
@@ -23,12 +23,14 @@
}, },
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json", "build": "tsc -p tsconfig.build.json",
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-exclude=src/property-harness.ts --test-coverage-branches=98 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", "size-ratchet": "oxlint --deny-warnings -c .oxlintrc.json src",
"test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-exclude=src/conformance/property-harness.ts --test-coverage-branches=98 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json" "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.build.json"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "24.13.3", "@types/node": "24.13.3",
"fast-check": "4.10.0", "fast-check": "4.10.0",
"oxlint": "1.83.0",
"typescript": "7.0.2" "typescript": "7.0.2"
} }
} }
+22 -15
View File
@@ -3,35 +3,42 @@ set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
source ./docker-runner.sh source ./docker-runner.sh
read_field() {
in_image "$node_image" npm pkg get "$1" | tr -d '"\r'
}
published_version() { published_version() {
in_image "$node_image" npm view "$1@$2" version 2>/dev/null || true in_image "$node_image" npm view "$1@$2" version 2>/dev/null || true
} }
private=$(read_field private) push_tag() {
git tag "v$version" && git push origin "v$version"
}
read_field() {
in_image "$node_image" npm pkg get "$1" | tr -d '"\r'
}
read_package_fields() {
private=$(read_field private) &&
name=$(read_field name) &&
version=$(read_field version)
}
leg "read package.json ($node_image)" read_package_fields
if [ "$private" = 'true' ]; then if [ "$private" = 'true' ]; then
echo 'package.json is private — the maintainer removes that in the bump that first publishes' echo 'package.json is private — the maintainer removes that in the bump that first publishes'
exit 0 exit 0
fi fi
name=$(read_field name) published=$(leg "ask npmjs for $name@$version ($node_image)" published_version "$name" "$version")
version=$(read_field version) tagged=$(leg "ask origin for v$version" git ls-remote --tags origin "v$version")
published=$(published_version "$name" "$version")
tagged=$(git ls-remote --tags origin "v$version")
# Both steps observe their own end state, so a partial run converges on the next push to main.
if [ -z "$published" ]; then if [ -z "$published" ]; then
: "${NPM_TOKEN:?the publish needs NPM_TOKEN}" : "${NPM_TOKEN:?the publish needs NPM_TOKEN}"
in_image "$node_image" npm ci leg "install ($node_image)" in_image "$node_image" npm ci
in_image "$node_image" npm run build leg "build ($node_image)" in_image "$node_image" npm run build
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e NPM_TOKEN -v "$PWD:/app" -w /app --entrypoint sh "$node_image" -c \ leg "publish $name@$version ($node_image)" \
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e NPM_TOKEN -v "$PWD:/app" -w /app --entrypoint sh "$node_image" -c \
'printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" > "$HOME/.npmrc" && npm publish --access public' 'printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" > "$HOME/.npmrc" && npm publish --access public'
fi fi
if [ -z "$tagged" ]; then if [ -z "$tagged" ]; then
git tag "v$version" leg "tag v$version" push_tag
git push origin "v$version"
fi fi
+59 -55
View File
@@ -1,12 +1,12 @@
# The markdown flavour # The markdown flavour
The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` parses. Plain
CommonMark is a subset apart from raw HTML (below), with three carve-outs: literal text that CommonMark is a subset apart from raw HTML (below), with three carve-outs: literal text that matches
matches directive syntax below or reads as a pipe table is claimed by the flavour, and a matched directive syntax below or reads as a pipe table is claimed by the flavour, and a matched `~~` pair
`~~` pair spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal) — and one gap: a spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal) — and one gap: a CommonMark
CommonMark image fits only as its own image fits only as its own title-less paragraph — mid-text and titled images are named errors. The
title-less paragraph — mid-text and titled images are named errors. The emitted form is contract emitted form is contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this
(AGENTS.md §8). Per-node syntaxes build on this grammar in the sections below. grammar in the sections below.
## Canonical form ## Canonical form
@@ -67,22 +67,21 @@ normalizes to it through the round-trip.
matching below, which is what lets the emitter decide its own pairings. matching below, which is what lets the emitter decide its own pairings.
- Blocks separated by one blank line at document level, inside a blockquote and between CommonMark - Blocks separated by one blank line at document level, inside a blockquote and between CommonMark
blocks; inside a directive container a pair holding a directive block takes none. No trailing blocks; inside a directive container a pair holding a directive block takes none. No trailing
whitespace outside a code whitespace outside a code block's content, single trailing newline; a document with no blocks is the empty string.
block's
content, single trailing newline; a document with no blocks is the empty string.
## Directives ## Directives
One grammar for everything CommonMark lacks, namespaced: every directive opens with the literal One grammar for everything CommonMark lacks, namespaced: every directive opens with the literal
`!adf:`. A directive name is `[a-z][A-Za-z0-9]*` — the ADF node and mark names the sections below `!adf:`. A directive name is `[a-z][A-Za-z0-9]*` — the ADF node and mark names the sections below
spell as directives. Recognition is syntactic and name-set-independent: anything matching the forms spell as directives. Recognition is syntactic and name-set-independent: anything matching the forms
below parses as a directive regardless of whether the name is known, and an unknown name is an below parses as a directive regardless of whether the name is known, and an unknown name is an error
error result naming it at the opener, whatever follows it — so output an old emitter escaped stays result naming it at the opener, whatever follows it — so output an old emitter escaped stays
escaped, and erroring input gaining meaning later is MINOR, never a reparse (§8). Each name belongs escaped, and erroring input gaining meaning later is MINOR, never a reparse (`docs/decisions.md`
to one position, and a name the other one spells — a mark or an inline node written as a block §The formats are API). Each name belongs to one position, and a name the other one spells — a mark
directive, a block node written inline — is a different error, naming the spelling it takes. Two or an inline node written as a block directive, a block node written inline — is a different error,
reserved names read back to no node: `carry` for the opaque carry, as both directive name and fence naming the spelling it takes. Two reserved names read back to no node: `carry` for the opaque carry,
info string, and `listBreak` for the leaf that parts two adjacent lists (Canonical form). as both directive name and fence info string, and `listBreak` for the leaf that parts two adjacent
lists (Canonical form).
**Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name` **Claiming**: an unescaped `!adf:` claims wherever it stands. What follows picks the form: `/name`
closes a container, and a name picks by what follows it in turn — a space or the line's end a block closes a container, and a name picks by what follows it in turn — a space or the line's end a block
@@ -123,7 +122,7 @@ Which of the two a node takes is its content model, never the spelling: a model
written as an opener–closer pair and one taking none as a leaf, so a leaf given a body and a written as an opener–closer pair and one taking none as a leaf, so a leaf given a body and a
container missing its closer are each a named error. A node holding no content whose model takes container missing its closer are each a named error. A node holding no content whose model takes
some is an empty pair. A spelled node's content model is contract in consequence — changing one is some is an empty pair. A spelled node's content model is contract in consequence — changing one is
MAJOR (AGENTS.md §8). MAJOR (`docs/decisions.md` §The formats are API).
Canonical spacing is the only spacing input reads: one space parts the name, `arg` and `{attrs}`, Canonical spacing is the only spacing input reads: one space parts the name, `arg` and `{attrs}`,
and one parts each attribute pair, with no padding inside the braces. Trailing whitespace on a and one parts each attribute pair, with no padding inside the braces. Trailing whitespace on a
@@ -154,15 +153,15 @@ outside code spans and code blocks, `\!adf:` in input yields the literal text.
naming no open container or a node other than the innermost open one, a leaf given a body, an naming no open container or a node other than the innermost open one, a leaf given a body, an
`!adf:` completing no directive, an inline `[content]` or `{attrs}` left unclosed at end of line, `!adf:` completing no directive, an inline `[content]` or `{attrs}` left unclosed at end of line,
unparseable or duplicate-keyed attrs, invalid JSON in an opaque carry. Never a silent literal-text unparseable or duplicate-keyed attrs, invalid JSON in an opaque carry. Never a silent literal-text
fallback — a typo that reparses as prose is the silent loss §2 refuses. fallback — a typo that reparses as prose is the silent loss the round-trip refuses.
## The opaque carry (AGENTS.md §3) ## The opaque carry
A node no section spells where it stands — an unknown type, or a known one whose spelling belongs A node no section spells where it stands (`docs/decisions.md` §Unknown nodes ride the carry) — an
to the other position — rides as its raw JSON and restores to a deep-equal node. A carry may hold unknown type, or a known one whose spelling belongs to the other position — rides as its raw JSON
a node the emitter spells natively: it restores unreinterpreted, and the next emit spells it and restores to a deep-equal node. A carry may hold a node the emitter spells natively: it restores
canonically (AGENTS.md §2). Block and inline positions canonicalize differently, each fitting unreinterpreted, and the next emit spells it canonically (`docs/decisions.md` §The round-trip is the
where it sits: product). Block and inline positions canonicalize differently, each fitting where it sits:
- **Block position**: a fenced code block with info string `carry`, body = the node's JSON — - **Block position**: a fenced code block with info string `carry`, body = the node's JSON —
two-space indent, object keys sorted. two-space indent, object keys sorted.
@@ -177,25 +176,27 @@ In block-directive position `!adf:carry` is a named error — the carry's block
## Raw HTML in input ## Raw HTML in input
CommonMark input may contain raw HTML. `markdownToAdf` routes each construct through the foreign CommonMark input may contain raw HTML. `markdownToAdf` routes each construct through the foreign
HTML element mapping (AGENTS.md §3; specified with the HTML dialect, todo.md milestone 6) — ADF HTML element mapping (`docs/decisions.md` §Foreign HTML sorts three ways; specified with the HTML
has no raw-HTML node, so a construct without a mapping, comments and processing instructions dialect, `todo.md` item 6) — ADF has no raw-HTML node, so a construct without a mapping, comments
included, is an error result naming it. The flavour never emits raw HTML. and processing instructions included, is an error result naming it. The flavour never emits raw
HTML.
## Block nodes ## Block nodes
The directive name is always the ADF node type. A container's body is the node's `content`; a The directive name is always the ADF node type. A container's body is the node's `content`; a leaf
leaf has none. Every directive parses in any position — `markdownToAdf` builds exactly what is has none. Every directive parses in any position — `markdownToAdf` builds exactly what is written;
written; validity against ADF's content models stays the author's business (AGENTS.md §14). It validity against ADF's content models stays the author's business (`docs/decisions.md` §No schema
parses only in the form the emitter picks, though: a directive spelling a node the emitter would validation). It parses only in the form the emitter picks, though: a directive spelling a node the
have written as CommonMark is a named error. emitter would have written as CommonMark is a named error.
Each section lists attributes as `name (type)`. A parenthesized value set documents what real Each section lists attributes as `name (type)`. A parenthesized value set documents what real
payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by
type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare
(`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys (`width="33.33"`) — and `json` values as the inline carry's serialization (compact, keys sorted),
sorted), quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty; quoted. `markdownToAdf` emits `attrs`, `content` and `marks` keys only when non-empty; editor-normal
editor-normal ADF reads an empty attrs object, marks array or content array as the absent key ADF reads an empty attrs object, marks array or content array as the absent key (`docs/decisions.md`
(AGENTS.md §2) — the grammar's empty-`{attrs}` omission already collapses the two spellings. §Equality is editor-normal) — the grammar's empty-`{attrs}` omission already collapses the two
spellings.
Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a Marks on a block node ride the reserved attribute key `marks` — the node's marks array as a
`json` value: `!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`. `json` value: `!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`.
@@ -300,10 +301,10 @@ other text, or one carrying a title, is a named error: `mediaInline` carries a m
### Tables ### Tables
One header row plus plain inline cells is a pipe table; anything richer is the directive form One header row plus plain inline cells is a pipe table; anything richer is the directive form
(AGENTS.md §4). Precisely: a table emits as a pipe table exactly when the `table`, every row (`docs/decisions.md` §Tables). Precisely: a table emits as a pipe table exactly when the `table`,
and every cell carry no attrs and no marks, the first row is all `tableHeader` and the rest all every row and every cell carry no attrs and no marks, the first row is all `tableHeader` and the
`tableCell`, every row has the header's cell count, and every cell holds exactly one attr-less, rest all `tableCell`, every row has the header's cell count, and every cell holds exactly one
mark-less paragraph — an empty cell holds one empty paragraph — with no `|` anywhere the attr-less, mark-less paragraph — an empty cell holds one empty paragraph — with no `|` anywhere the
inline layer spells as syntax: a code span, an autolink, a link destination or title. A `|` there inline layer spells as syntax: a code span, an autolink, a link destination or title. A `|` there
takes the directive form instead. A pipe table parses back to exactly that shape. takes the directive form instead. A pipe table parses back to exactly that shape.
@@ -449,14 +450,14 @@ Shipped !adf:emoji[🎉]{shortName=":tada:"} on !adf:date{timestamp=175608000000
``` ```
**Whitespace CommonMark cannot hold.** A newline inside a text node, and a space or tab where **Whitespace CommonMark cannot hold.** A newline inside a text node, and a space or tab where
CommonMark strips or refuses one — a block's inline content edges, either side of a line break, CommonMark strips or refuses one — a block's inline content edges, either side of a line break, an
an em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled `!adf:text{text="…"}`,
`!adf:text{text="…"}`, the reserved key carrying the node's text, escaped by the attribute grammar the reserved key carrying the node's text, escaped by the attribute grammar and never literal: pipe
and never literal: pipe cells trim and pad. The emitter wraps the whitespace run alone and leaves cells trim and pad. The emitter wraps the whitespace run alone and leaves the rest plain text;
the rest plain text; `markdownToAdf` merges adjacent text nodes carrying identical marks and no `markdownToAdf` merges adjacent text nodes carrying identical marks and no attributes
attributes (AGENTS.md §2). Input reads that spelling alone: the value is one run of spaces and (`docs/decisions.md` §Equality is editor-normal). Input reads that spelling alone: the value is one
tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries plainly — run of spaces and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark
is a named error. carries plainly — is a named error.
``` ```
!adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines. !adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines.
@@ -473,7 +474,10 @@ the inline directive `!adf:link[text]{attrs}` only where CommonMark does not: an
`href` and `title`, an `href` or `title` no canonical escape spells (a control character, a `href` and `title`, an `href` or `title` no canonical escape spells (a control character, a
backslash, an entity reference, an angle bracket beside a space or opening a bare destination, a backslash, an entity reference, an angle bracket beside a space or opening a bare destination, a
newline in the title), or a link opening a paragraph whose markdown spelling would read as a link newline in the title), or a link opening a paragraph whose markdown spelling would read as a link
reference definition. A directive link CommonMark could spell is a named error. reference definition. Every such spelling carries an `href`: a directive link CommonMark could
spell is a named error, and so is one spelling none. No link wraps a link at any nesting, which is
CommonMark's own rule: a `[text]` already holding one leaves the outer brackets literal text, and
the directive form, open to no literal reading, is a named error.
- `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3). - `border` — Attributes: `color` (string, `#rrggbb` or `#rrggbbaa`), `size` (number, 1–3).
- `code`, `em`, `strike`, `strong` — Attributes: none. - `code`, `em`, `strike`, `strong` — Attributes: none.
@@ -485,11 +489,11 @@ reference definition. A directive link CommonMark could spell is a named error.
A spelling adds its mark to every inline node it wraps, and nesting is the marks array in order, A spelling adds its mark to every inline node it wraps, and nesting is the marks array in order,
outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the
reverse. reverse. `adfToMarkdown` nests in the order the array holds rather than sorting it —
`adfToMarkdown` nests in the order the array holds rather than sorting it — §2's equality `docs/decisions.md` §Equality is editor-normal restores the array, not a set — and opens each
restores the array, not a set — and opens each spelling once over the longest run of adjacent spelling once over the longest run of adjacent inline nodes carrying an identical mark, attributes
inline nodes carrying an identical mark, attributes included, at that depth. A run breaks at every included, at that depth. A run breaks at every node the emitter carries, so no emitted carry sits
node the emitter carries, so no emitted carry sits inside a mark spelling. inside a mark spelling.
An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs spelling does not list, a value that is not the spelling's type, an attribute the spelling needs
@@ -499,7 +503,7 @@ close where the run sits (`un**-real**istic`), or one CommonMark's matching pair
intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the
merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a
mark spelling is a named error in input: the carry restores its node exactly, marks included mark spelling is a named error in input: the carry restores its node exactly, marks included
(AGENTS.md §3). (`docs/decisions.md` §Unknown nodes ride the carry).
``` ```
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed]. !adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
-23
View File
@@ -1,23 +0,0 @@
import fc from 'fast-check'
import assert from 'node:assert/strict'
import test from 'node:test'
import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts'
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
import { toEditorNormal } from './adf/editor-normal.ts'
const gateRuns = 1600
test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
const emitted = adfToMarkdown(document)
if (!emitted.ok) return
const read = markdownToAdf(emitted.value)
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
assert.deepEqual(toEditorNormal(read.value), document, `reading ${JSON.stringify(emitted.value)}`)
}),
propertyRuns(gateRuns),
)
})
@@ -1,6 +1,6 @@
import type { AttributeVocabulary } from './attribute-vocabulary.ts' import type { AttributeVocabulary } from './attribute-vocabulary.ts'
export type BlockDirective = { export type BlockNodeModel = {
attributes: AttributeVocabulary attributes: AttributeVocabulary
contentModel: 'block' | 'code' | 'inline' | 'none' contentModel: 'block' | 'code' | 'inline' | 'none'
} }
@@ -41,7 +41,7 @@ const mediaAttributes: AttributeVocabulary = {
const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' } const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' }
export const blockDirectives = { export const blockNodes = {
blockTaskItem: { attributes: localIdAttributes, contentModel: 'block' }, blockTaskItem: { attributes: localIdAttributes, contentModel: 'block' },
blockquote: { attributes: localIdAttributes, contentModel: 'block' }, blockquote: { attributes: localIdAttributes, contentModel: 'block' },
bodiedExtension: { attributes: extensionAttributes, contentModel: 'block' }, bodiedExtension: { attributes: extensionAttributes, contentModel: 'block' },
@@ -80,14 +80,14 @@ export const blockDirectives = {
tableRow: { attributes: localIdAttributes, contentModel: 'block' }, tableRow: { attributes: localIdAttributes, contentModel: 'block' },
taskItem: { attributes: localIdAttributes, contentModel: 'inline' }, taskItem: { attributes: localIdAttributes, contentModel: 'inline' },
taskList: { attributes: localIdAttributes, contentModel: 'block' }, taskList: { attributes: localIdAttributes, contentModel: 'block' },
} satisfies Readonly<Record<string, BlockDirective>> } satisfies Readonly<Record<string, BlockNodeModel>>
export type BlockType = keyof typeof blockDirectives export type BlockType = keyof typeof blockNodes
export function blockDirective(type: string): BlockDirective | undefined { export function blockNodeModel(type: string): BlockNodeModel | undefined {
return isBlockType(type) ? blockDirectives[type] : undefined return isBlockType(type) ? blockNodes[type] : undefined
} }
function isBlockType(type: string): type is BlockType { function isBlockType(type: string): type is BlockType {
return Object.hasOwn(blockDirectives, type) return Object.hasOwn(blockNodes, type)
} }
+4 -5
View File
@@ -61,16 +61,15 @@ test('rejects a node whose shape ProseMirror JSON cannot hold', () => {
}) })
test('names the attribute nesting past the levels the parser reads one at, and still calls the value a document', () => { test('names the attribute nesting past the levels the parser reads one at, and still calls the value a document', () => {
const deeper = (key: string, type: string, levels: number = largestNesting): string => const deeper = (key: string, type: string): string => `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
`the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted') assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted')
assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph')) assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph'))
assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth') assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth')
assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), true) assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), true)
const marked = (levels: number): unknown => ({ content: [{ marks: [{ attrs: { a: nested(levels) }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }) const marked = (levels: number): unknown => ({ content: [{ marks: [{ attrs: { a: nested(levels) }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 })
assert.equal(fault(marked(largestNesting - 3)), 'accepted') assert.equal(fault(marked(largestNesting)), 'accepted')
assert.equal(fault(marked(largestNesting - 2)), deeper('a', 'link', largestNesting - 3)) assert.equal(fault(marked(largestNesting + 1)), deeper('a', 'link'))
assert.equal(isAdfDocument(marked(largestNesting - 2)), true) assert.equal(isAdfDocument(marked(largestNesting + 1)), true)
}) })
test('accepts the JSON values an attribute may hold', () => { test('accepts the JSON values an attribute may hold', () => {
+5 -8
View File
@@ -23,9 +23,6 @@ export type AdfDocument = {
version: number version: number
} }
// A block directive spells the whole mark set as one JSON attribute, so a mark's value sits three levels inside it.
const markAttributeNesting = largestNesting - 3
const documentKeys = ['content', 'type', 'version'] const documentKeys = ['content', 'type', 'version']
const markKeys = ['attrs', 'type'] const markKeys = ['attrs', 'type']
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type'] const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
@@ -47,8 +44,8 @@ export function adfDocumentFault(value: unknown): ConvertFault | undefined {
return nestingFault(content) return nestingFault(content)
} }
export function attributeNestingMessage(key: string, type: string, levels: number = largestNesting): string { export function attributeNestingMessage(key: string, type: string): string {
return `the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries` return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
} }
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean { export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
@@ -116,15 +113,15 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
function marksFault(marks: readonly AdfMark[]): ConvertFault | undefined { function marksFault(marks: readonly AdfMark[]): ConvertFault | undefined {
for (const mark of marks) { for (const mark of marks) {
const fault = attributesFault(nodeAttrs(mark), mark.type, markAttributeNesting) const fault = attributesFault(nodeAttrs(mark), mark.type)
if (fault !== undefined) return fault if (fault !== undefined) return fault
} }
return undefined return undefined
} }
function attributesFault(attrs: AdfAttributes, type: string, levels: number = largestNesting): ConvertFault | undefined { function attributesFault(attrs: AdfAttributes, type: string): ConvertFault | undefined {
for (const [key, value] of Object.entries(attrs)) { for (const [key, value] of Object.entries(attrs)) {
if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) } if (overNested(value)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type) }
} }
return undefined return undefined
} }
+1 -1
View File
@@ -77,7 +77,7 @@ function mergesText(node: AdfNode): boolean {
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0 return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
} }
function sameMarks(previous: AdfNode, node: AdfNode): boolean { export function sameMarks(previous: AdfNode, node: AdfNode): boolean {
return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node)) return marksKey(nodeMarks(previous)) === marksKey(nodeMarks(node))
} }
@@ -1,11 +1,11 @@
import type { AttributeVocabulary } from './attribute-vocabulary.ts' import type { AttributeVocabulary } from './attribute-vocabulary.ts'
export type InlineDirective = { export type InlineNodeModel = {
attributes: AttributeVocabulary attributes: AttributeVocabulary
textAttribute?: string textAttribute?: string
} }
export const inlineDirectives: Readonly<Record<string, InlineDirective>> = { export const inlineNodes: Readonly<Record<string, InlineNodeModel>> = {
date: { attributes: { localId: 'string', timestamp: 'string' } }, date: { attributes: { localId: 'string', timestamp: 'string' } },
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, textAttribute: 'text' }, emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, textAttribute: 'text' },
hardBreak: { attributes: { localId: 'string', text: 'string' } }, hardBreak: { attributes: { localId: 'string', text: 'string' } },
@@ -27,6 +27,6 @@ export const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, textAttribute: 'text' }, status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, textAttribute: 'text' },
} }
export function inlineDirective(type: string): InlineDirective | undefined { export function inlineNodeModel(type: string): InlineNodeModel | undefined {
return Object.hasOwn(inlineDirectives, type) ? inlineDirectives[type] : undefined return Object.hasOwn(inlineNodes, type) ? inlineNodes[type] : undefined
} }
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict'
import fc from 'fast-check'
import test from 'node:test'
import type { AdfNode } from '../adf/document.ts'
import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts'
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { adfToPlainMarkdown, reduceToPlain } from '../markdown/emit/plain-reduction.ts'
import { directivePrefix } from '../markdown/directive-syntax.ts'
import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { toEditorNormal } from '../adf/editor-normal.ts'
const gateRuns = 1600
const renamedPrefix = '!adg:'
// Renaming the prefix changes what markdown reads only where a directive was read.
function readsNoDirective(markdown: string): boolean {
const read = markdownToAdf(markdown)
const renamed = markdownToAdf(markdown.replaceAll(directivePrefix, renamedPrefix))
return read.ok && renamed.ok && JSON.stringify(read.value).replaceAll(directivePrefix, renamedPrefix) === JSON.stringify(renamed.value)
}
// Each block's text, an expand's title and an image's alt and url, in document order: what the plain pair keeps.
function shownText(nodes: readonly AdfNode[]): string[] {
const shown: string[] = []
for (const node of nodes) {
const attrs = node.attrs ?? {}
if ((node.type === 'expand' || node.type === 'nestedExpand') && typeof attrs['title'] === 'string') shown.push(attrs['title'])
if (node.type === 'media') shown.push(`${JSON.stringify(attrs['alt'] ?? '')} ${JSON.stringify(attrs['url'])}`)
const content = node.content ?? []
if (content.some((child) => child.type === 'text' || child.type === 'hardBreak')) shown.push(content.map((child) => child.text ?? '\n').join(''))
else for (const text of shownText(content)) shown.push(text)
}
return shown.filter((text) => text !== '')
}
test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
const emitted = adfToMarkdown(document)
if (!emitted.ok) return
const read = markdownToAdf(emitted.value)
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
assert.deepEqual(toEditorNormal(read.value), document, `reading ${JSON.stringify(emitted.value)}`)
}),
propertyRuns(gateRuns),
)
})
test('a generated document writes plain markdown refusing only what the guard refuses, and that markdown reads back to its text and to itself', { timeout: propertyTimeout }, () => {
fc.assert(
fc.property(adfDocument, (document) => {
const written = adfToPlainMarkdown(document)
assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`)
assert.ok(readsNoDirective(written.value), `a directive in ${JSON.stringify(written.value)}`)
const read = plainMarkdownToAdf(written.value)
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(written.value)}`)
const reduced = reduceToPlain(document)
assert.deepEqual(shownText(read.value.content ?? []), reduced.ok ? shownText(reduced.value.content ?? []) : reduced, `reading ${JSON.stringify(written.value)}`)
assert.deepEqual(adfToPlainMarkdown(read.value), written, `reading ${JSON.stringify(written.value)}`)
}),
propertyRuns(gateRuns),
)
})
@@ -1,15 +1,15 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts' import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import { blockDirectives } from './adf/block-directives.ts' import { blockArgument } from '../markdown/block-directive.ts'
import { inlineDirectives } from './adf/inline-directives.ts' import { blockNodes } from '../adf/block-nodes.ts'
import { markAttributes } from './adf/mark-attributes.ts' import { inlineNodes } from '../adf/inline-nodes.ts'
import { blockArgument } from './markdown/block-directive-arguments.ts' import { markAttributes } from '../adf/mark-attributes.ts'
type Held = Map<string, Set<AttributeKind>> type Held = Map<string, Set<AttributeKind>>
type Properties = Map<string, SchemaObject[]> type Properties = Map<string, SchemaObject[]>
@@ -21,7 +21,7 @@ const definitionReference = '#/definitions/'
const gaps: string[] = [] const gaps: string[] = []
const grammarOwn = ['doc', 'text'] const grammarOwn = ['doc', 'text']
const readKeywords = ['$ref', 'additionalProperties', 'allOf', 'anyOf', 'enum', 'items', 'maxItems', 'maximum', 'minItems', 'minLength', 'minimum', 'pattern', 'properties', 'required', 'type'] const readKeywords = ['$ref', 'additionalProperties', 'allOf', 'anyOf', 'enum', 'items', 'maxItems', 'maximum', 'minItems', 'minLength', 'minimum', 'pattern', 'properties', 'required', 'type']
const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'spec', 'adf-schema') const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'spec', 'adf-schema')
const schemaFiles = ['full.json', 'stage-0.json'] const schemaFiles = ['full.json', 'stage-0.json']
test('the ADF JSON Schemas are @atlaskit/adf-schema 57.4.9, vendored byte-exact', () => { test('the ADF JSON Schemas are @atlaskit/adf-schema 57.4.9, vendored byte-exact', () => {
@@ -78,8 +78,8 @@ test("the ADF JSON Schemas hold no type the tables leave unspelled, the pinned c
function spelled(): Spelled[] { function spelled(): Spelled[] {
return [ return [
...Object.entries(blockDirectives).map(([type, entry]) => spelledType(type, entry.attributes, blockArgument(type))), ...Object.entries(blockNodes).map(([type, model]) => spelledType(type, model.attributes, blockArgument(type))),
...Object.entries(inlineDirectives).map(([type, entry]) => spelledType(type, entry.attributes)), ...Object.entries(inlineNodes).map(([type, model]) => spelledType(type, model.attributes)),
...Object.entries(markAttributes).map(([type, attributes]) => spelledType(type, attributes)), ...Object.entries(markAttributes).map(([type, attributes]) => spelledType(type, attributes)),
] ]
} }
@@ -1,15 +1,15 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import type { AdfDocument, AdfNode } from './adf/document.ts' import type { AdfDocument, AdfNode } from '../adf/document.ts'
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus', 'commonmark-spec') const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus', 'commonmark-spec')
const checks = ['count', 'fixpoint', 'text'] as const const checks = ['count', 'fixpoint', 'text'] as const
type Check = (typeof checks)[number] type Check = (typeof checks)[number]
@@ -91,7 +91,7 @@ test('the refusal list is unique per example and names real examples', () => {
for (const example of exampleToRefusal.keys()) assert.ok(spec.some((entry) => entry.example === example), `refusal ${example} names no example in the suite`) for (const example of exampleToRefusal.keys()) assert.ok(spec.some((entry) => entry.example === example), `refusal ${example} names no example in the suite`)
}) })
// A mark is counted once per text node it touches (AGENTS.md §14). // A mark is counted once per text node it touches (docs/decisions.md §No schema validation).
const countKeys = ['a', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'li', 'ol', 'pre', 'strong', 'ul'] const countKeys = ['a', 'blockquote', 'br', 'code', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img', 'li', 'ol', 'pre', 'strong', 'ul']
const nodeElement: Record<string, string> = { const nodeElement: Record<string, string> = {
blockquote: 'blockquote', blockquote: 'blockquote',
@@ -1,17 +1,17 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { basename, dirname, join, sep } from 'node:path' import { basename, dirname, join, sep } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { readFileSync, readdirSync } from 'node:fs'
import test from 'node:test'
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { isAdfDocument } from './adf/document.ts' import { isAdfDocument } from '../adf/document.ts'
import { isJsonValue } from './json-value.ts' import { isJsonValue } from '../json-value.ts'
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
import { toEditorNormal } from './adf/editor-normal.ts' import { toEditorNormal } from '../adf/editor-normal.ts'
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus') const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus')
const errorsRoot = join(corpusRoot, 'errors') const errorsRoot = join(corpusRoot, 'errors')
const normalizationRoot = join(corpusRoot, 'normalization') const normalizationRoot = join(corpusRoot, 'normalization')
const realPayloadsRoot = join(corpusRoot, 'real-payloads') const realPayloadsRoot = join(corpusRoot, 'real-payloads')
@@ -1,18 +1,18 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts' import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import { blockDirectives } from './adf/block-directives.ts' import { blockNodes } from '../adf/block-nodes.ts'
import { inlineDirectives } from './adf/inline-directives.ts' import { inlineNodes } from '../adf/inline-nodes.ts'
import { markAttributes } from './adf/mark-attributes.ts' import { markAttributes } from '../adf/mark-attributes.ts'
import { textDirectiveName } from './markdown/text-directive.ts' import { textDirectiveName } from '../markdown/text-directive.ts'
type Declared = { attributes: AttributeVocabulary } type Declared = { attributes: AttributeVocabulary }
const specPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'spec', 'flavour.md') const specPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'spec', 'flavour.md')
const introducer = 'Attributes: ' const introducer = 'Attributes: '
const codeFence = /^`{3,}/ const codeFence = /^`{3,}/
const directiveName = /`([a-z][A-Za-z0-9]*)`/g const directiveName = /`([a-z][A-Za-z0-9]*)`/g
@@ -90,16 +90,16 @@ function vocabularies(table: Readonly<Record<string, Declared>>): Record<string,
} }
test('the block node table holds the attributes spec/flavour.md gives each node', () => { test('the block node table holds the attributes spec/flavour.md gives each node', () => {
assert.deepEqual(declarations('Block nodes'), vocabularies(blockDirectives)) assert.deepEqual(declarations('Block nodes'), vocabularies(blockNodes))
}) })
test('the inline node table holds the attributes spec/flavour.md gives each node', () => { test('the inline node table holds the attributes spec/flavour.md gives each node', () => {
assert.deepEqual(declarations('Inline nodes'), vocabularies(inlineDirectives)) assert.deepEqual(declarations('Inline nodes'), vocabularies(inlineNodes))
}) })
// A name in two tables would make the position a directive is read in ambiguous. // A name in two tables would make the position a directive is read in ambiguous.
test('no name is spelled in more than one position', () => { test('no name is spelled in more than one position', () => {
const names = [...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes), textDirectiveName] const names = [...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes), textDirectiveName]
assert.equal(new Set(names).size, names.length) assert.equal(new Set(names).size, names.length)
}) })
@@ -1,17 +1,17 @@
import fc from 'fast-check'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import fc from 'fast-check'
import test from 'node:test' import test from 'node:test'
import type { AdfDocument } from './adf/document.ts' import type { AdfDocument } from '../adf/document.ts'
import type { Arbitrary, DepthIdentifier } from 'fast-check' import type { Arbitrary, DepthIdentifier } from 'fast-check'
import type { AttributeVocabulary } from './adf/attribute-vocabulary.ts' import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { JsonValue } from './json-value.ts' import type { JsonValue } from '../json-value.ts'
import type { Result } from './result.ts' import type { Result } from '../result.ts'
import { adfDocument, attributes, jsonKey, jsonValue, markdownPieces, propertyRuns, propertyTimeout, textOf } from './property-harness.ts' import { adfDocument, attributes, jsonKey, jsonValue, markdownPieces, propertyRuns, propertyTimeout, textOf } from './property-harness.ts'
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
import { blockArgument } from './markdown/block-directive-arguments.ts' import { blockArgument, listBreakName, marksAttribute } from '../markdown/block-directive.ts'
import { blockDirectives } from './adf/block-directives.ts' import { blockNodes } from '../adf/block-nodes.ts'
import { carryName } from './markdown/opaque-carry.ts' import { carryName } from '../markdown/opaque-carry.ts'
import { import {
directivePrefix, directivePrefix,
spellAttributes, spellAttributes,
@@ -22,19 +22,17 @@ import {
spellJsonAttribute, spellJsonAttribute,
spellStringAttribute, spellStringAttribute,
spellVocabulary, spellVocabulary,
} from './markdown/directive-syntax.ts' } from '../markdown/directive-syntax.ts'
import { fencedCodeBlock } from './markdown/backtick-runs.ts' import { fencedCodeBlock } from '../markdown/commonmark/backtick-runs.ts'
import { inlineDirectives } from './adf/inline-directives.ts' import { inlineNodes } from '../adf/inline-nodes.ts'
import { listBreakName } from './markdown/list-break.ts' import { markAttributes } from '../adf/mark-attributes.ts'
import { markAttributes } from './adf/mark-attributes.ts' import { markSpelling } from '../markdown/mark-spellings.ts'
import { markSpelling } from './markdown/mark-spellings.ts' import { markdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' import { nodeContent, nodeMarks } from '../adf/document.ts'
import { marksAttribute } from './markdown/block-directive-marks.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
import { nodeContent, nodeMarks } from './adf/document.ts' import { textDirectiveName } from '../markdown/text-directive.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { toEditorNormal } from '../adf/editor-normal.ts'
import { textDirectiveName } from './markdown/text-directive.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
import { toEditorNormal } from './adf/editor-normal.ts'
import { vocabularyPairs } from './adf/attribute-vocabulary.ts'
type Choice = { arbitrary: Arbitrary<string>; hostile?: true; weight: number } type Choice = { arbitrary: Arbitrary<string>; hostile?: true; weight: number }
@@ -50,11 +48,11 @@ const fixpointFloor = 600
const gateRuns = 1000 const gateRuns = 1000
const markdownMarkTypes = new Set(Object.keys(markAttributes).filter((type) => markSpelling(type)?.kind !== 'directive')) const markdownMarkTypes = new Set(Object.keys(markAttributes).filter((type) => markSpelling(type)?.kind !== 'directive'))
const vocabularies = [...Object.values(blockDirectives).map((directive) => directive.attributes), ...Object.values(inlineDirectives).map((directive) => directive.attributes), ...Object.values(markAttributes)] const vocabularies = [...Object.values(blockNodes).map((model) => model.attributes), ...Object.values(inlineNodes).map((model) => model.attributes), ...Object.values(markAttributes)]
const attributeKeys = [ const attributeKeys = [
...new Set([...vocabularies.flatMap((vocabulary) => Object.keys(vocabulary)), ...Object.keys(blockDirectives).flatMap((type) => blockArgument(type) ?? []), marksAttribute, 'json', textDirectiveName]), ...new Set([...vocabularies.flatMap((vocabulary) => Object.keys(vocabulary)), ...Object.keys(blockNodes).flatMap((type) => blockArgument(type) ?? []), marksAttribute, 'json', textDirectiveName]),
] ]
const directiveNames = [...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes), carryName, listBreakName, textDirectiveName] const directiveNames = [...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes), carryName, listBreakName, textDirectiveName]
// Hostile generation reaches refusals; clean generation holds none a single piece would trip, so a whole document reaches the emitter. // Hostile generation reaches refusals; clean generation holds none a single piece would trip, so a whole document reaches the emitter.
function choose(hostile: boolean, choices: readonly Choice[], depth?: { depthIdentifier: DepthIdentifier; maxDepth: number }): Arbitrary<string> { function choose(hostile: boolean, choices: readonly Choice[], depth?: { depthIdentifier: DepthIdentifier; maxDepth: number }): Arbitrary<string> {
@@ -233,9 +231,9 @@ function inlineMarkdown(hostile: boolean): InlineMarkdown {
}, },
{ {
arbitrary: fc.oneof( arbitrary: fc.oneof(
...Object.entries(inlineDirectives).map(([name, directive]) => ...Object.entries(inlineNodes).map(([name, model]) =>
fc fc
.tuple(directive.textAttribute === undefined ? fc.constant(null) : fc.option(hostile ? word : prose), tableAttributes(directive.attributes, directive.textAttribute)) .tuple(model.textAttribute === undefined ? fc.constant(null) : fc.option(hostile ? word : prose), tableAttributes(model.attributes, model.textAttribute))
.map(([slot, attrs]) => (slot === null ? spellInlineLeafDirective(name, attrs) : `${spellInlineDirectiveOpener(name)}${slot}]${attrs}`)), .map(([slot, attrs]) => (slot === null ? spellInlineLeafDirective(name, attrs) : `${spellInlineDirectiveOpener(name)}${slot}]${attrs}`)),
), ),
...Object.entries(markAttributes) ...Object.entries(markAttributes)
@@ -328,15 +326,15 @@ function blockMarkdown(hostile: boolean, { inlines, oneLine }: InlineMarkdown, {
const blockDepth = fc.createDepthIdentifier() const blockDepth = fc.createDepthIdentifier()
const { blocks } = fc.letrec<{ block: string; blocks: string }>((tie) => { const { blocks } = fc.letrec<{ block: string; blocks: string }>((tie) => {
const bodyByModel = { block: fc.oneof(tie('blocks'), fc.constant('')), code: fencedCode, inline: fc.oneof(oneLine, fc.constant('')) } const bodyByModel = { block: fc.oneof(tie('blocks'), fc.constant('')), code: fencedCode, inline: fc.oneof(oneLine, fc.constant('')) }
const tableDirectives = Object.entries(blockDirectives).map(([name, directive]) => { const tableDirectives = Object.entries(blockNodes).map(([name, model]) => {
const argument = const argument =
blockArgument(name) === undefined blockArgument(name) === undefined
? fc.constant(undefined) ? fc.constant(undefined)
: fc.oneof({ arbitrary: fc.constantFrom('DONE', 'TODO', 'custom', 'info', 'warning'), weight: 3 }, { arbitrary: bareToken, weight: 1 }) : fc.oneof({ arbitrary: fc.constantFrom('DONE', 'TODO', 'custom', 'info', 'warning'), weight: 3 }, { arbitrary: bareToken, weight: 1 })
const attrs = hostile ? fc.oneof({ arbitrary: tableAttributes(directive.attributes), weight: 4 }, { arbitrary: hostileAttributes, weight: 1 }) : tableAttributes(directive.attributes) const attrs = hostile ? fc.oneof({ arbitrary: tableAttributes(model.attributes), weight: 4 }, { arbitrary: hostileAttributes, weight: 1 }) : tableAttributes(model.attributes)
if (directive.contentModel === 'none') return fc.tuple(argument, attrs).map(([held, spelled]) => spellDirectiveOpener(name, held, spelled)) if (model.contentModel === 'none') return fc.tuple(argument, attrs).map(([held, spelled]) => spellDirectiveOpener(name, held, spelled))
return fc return fc
.tuple(argument, attrs, bodyByModel[directive.contentModel], hostile ? closerDrift : fc.constant(null)) .tuple(argument, attrs, bodyByModel[model.contentModel], hostile ? closerDrift : fc.constant(null))
.map(([held, spelled, body, closer]) => container(spellDirectiveOpener(name, held, spelled), body, closer ?? spellDirectiveCloser(name))) .map(([held, spelled, body, closer]) => container(spellDirectiveOpener(name, held, spelled), body, closer ?? spellDirectiveCloser(name)))
}) })
return { return {
@@ -1,17 +1,17 @@
import fc from 'fast-check'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { env } from 'node:process' import { env } from 'node:process'
import fc from 'fast-check'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../adf/document.ts'
import type { Arbitrary } from 'fast-check' import type { Arbitrary } from 'fast-check'
import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts' import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { JsonValue } from './json-value.ts' import type { JsonValue } from '../json-value.ts'
import { blockArgument } from './markdown/block-directive-arguments.ts' import { blockArgument } from '../markdown/block-directive.ts'
import { blockDirectives } from './adf/block-directives.ts' import { blockNodes } from '../adf/block-nodes.ts'
import { directivePrefix } from './markdown/directive-syntax.ts' import { directivePrefix } from '../markdown/directive-syntax.ts'
import { inlineDirectives } from './adf/inline-directives.ts' import { inlineNodes } from '../adf/inline-nodes.ts'
import { markAttributes } from './adf/mark-attributes.ts' import { markAttributes } from '../adf/mark-attributes.ts'
import { toEditorNormal } from './adf/editor-normal.ts' import { toEditorNormal } from '../adf/editor-normal.ts'
type Positions = { block: AdfNode; inline: AdfNode } type Positions = { block: AdfNode; inline: AdfNode }
@@ -23,9 +23,9 @@ export const propertyTimeout = 600000
const depthIdentifier = fc.createDepthIdentifier() const depthIdentifier = fc.createDepthIdentifier()
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' } const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
const flatCommonMarkShapeWeight = 4 const flatCommonMarkShapeWeight = 4
export const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`) export const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉日ー한𠀀', '==', '[!NOTE]', '[x]', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
const nestingCommonMarkShapeWeight = 21 const nestingCommonMarkShapeWeight = 21
const spelledTypes = new Set(['text', ...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes)]) const spelledTypes = new Set(['text', ...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes)])
export function textOf(minLength: number): Arbitrary<string> { export function textOf(minLength: number): Arbitrary<string> {
return fc.oneof( return fc.oneof(
@@ -80,6 +80,7 @@ function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }):
const mark: Arbitrary<AdfMark> = fc.oneof( const mark: Arbitrary<AdfMark> = fc.oneof(
{ arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 }, { arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 },
{ arbitrary: attributes({ color: 'string' }).map((attrs) => ({ attrs, type: 'backgroundColor' })), weight: 2 },
{ arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 }, { arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 },
) )
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type }) const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
@@ -94,8 +95,8 @@ const autolinkTextNode = fc
.record({ href: fc.tuple(fc.constantFrom('ab:', 'http://'), textOf(0)).map(([scheme, rest]) => `${scheme}${rest}`), marks }) .record({ href: fc.tuple(fc.constantFrom('ab:', 'http://'), textOf(0)).map(([scheme, rest]) => `${scheme}${rest}`), marks })
.map(({ href, marks: held }): AdfNode => ({ marks: [...held.filter((outer) => outer.type !== 'link'), { attrs: { href }, type: 'link' }], text: href, type: 'text' })) .map(({ href, marks: held }): AdfNode => ({ marks: [...held.filter((outer) => outer.type !== 'link'), { attrs: { href }, type: 'link' }], text: href, type: 'text' }))
const inlineNodes = Object.entries(inlineDirectives).map(([type, directive]) => const inlineArbitraries = Object.entries(inlineNodes).map(([type, model]) =>
fc.record({ attrs: attributes(directive.attributes), marks }).map((held): AdfNode => ({ ...held, type })), fc.record({ attrs: attributes(model.attributes), marks }).map((held): AdfNode => ({ ...held, type })),
) )
function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): { arbitrary: Arbitrary<AdfNode>; weight: number }[] { function weighted(arbitraries: readonly Arbitrary<AdfNode>[], weight: number): { arbitrary: Arbitrary<AdfNode>; weight: number }[] {
@@ -112,17 +113,17 @@ const positions = fc.letrec<Positions>((tie) => {
none: fc.constant<AdfNode[]>([]), none: fc.constant<AdfNode[]>([]),
} }
const blockMarks = fc.oneof({ arbitrary: fc.constant<AdfMark[]>([]), weight: 4 }, { arbitrary: marks, weight: 1 }) const blockMarks = fc.oneof({ arbitrary: fc.constant<AdfMark[]>([]), weight: 4 }, { arbitrary: marks, weight: 1 })
const blockNodes = Object.entries(blockDirectives).map(([type, directive]) => { const blockArbitraries = Object.entries(blockNodes).map(([type, model]) => {
const argument = blockArgument(type) const argument = blockArgument(type)
const vocabulary: AttributeVocabulary = argument === undefined ? directive.attributes : { ...directive.attributes, [argument]: 'string' } const vocabulary: AttributeVocabulary = argument === undefined ? model.attributes : { ...model.attributes, [argument]: 'string' }
const node = fc.record({ attrs: attributes(vocabulary), content: contentByModel[directive.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type })) const node = fc.record({ attrs: attributes(vocabulary), content: contentByModel[model.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type }))
return { leaf: directive.contentModel === 'code' || directive.contentModel === 'none', node } return { leaf: model.contentModel === 'code' || model.contentModel === 'none', node }
}) })
const unknownNode = fc const unknownNode = fc
.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType }) .record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType })
.map((held): AdfNode => held) .map((held): AdfNode => held)
const leafBlocks = blockNodes.filter((entry) => entry.leaf).map((entry) => entry.node) const leafBlocks = blockArbitraries.filter((entry) => entry.leaf).map((entry) => entry.node)
const containerBlocks = blockNodes.filter((entry) => !entry.leaf).map((entry) => entry.node) const containerBlocks = blockArbitraries.filter((entry) => !entry.leaf).map((entry) => entry.node)
const misplacedWeight = 7 const misplacedWeight = 7
const paragraph = fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: fc.array(backtickRunNode, { maxLength: 4, minLength: 2 }), weight: 1 }).map((content): AdfNode => ({ content, type: 'paragraph' })) const paragraph = fc.oneof({ arbitrary: inlineContent, weight: 3 }, { arbitrary: fc.array(backtickRunNode, { maxLength: 4, minLength: 2 }), weight: 1 }).map((content): AdfNode => ({ content, type: 'paragraph' }))
const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type })) const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type }))
@@ -135,8 +136,12 @@ const positions = fc.letrec<Positions>((tie) => {
paragraph, paragraph,
fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable), fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable),
] ]
const task = (type: string, content: Arbitrary<AdfNode[]>) =>
fc.record({ content, state: fc.constantFrom('DONE', 'TODO') }).map(({ content: held, state }): AdfNode => ({ attrs: { state }, content: held, type }))
const taskItem = fc.oneof({ arbitrary: task('taskItem', inlineContent), weight: 3 }, { arbitrary: task('blockTaskItem', blockContent), weight: 1 })
const nestingCommonMarkShapes = [ const nestingCommonMarkShapes = [
blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })), blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })),
fc.array(fc.oneof({ arbitrary: taskItem, weight: 3 }, { arbitrary: tie('block'), weight: 1 }), { depthIdentifier, maxLength: 3, minLength: 1 }).map((content): AdfNode => ({ content, type: 'taskList' })),
listItems.map((content): AdfNode => ({ content, type: 'bulletList' })), listItems.map((content): AdfNode => ({ content, type: 'bulletList' })),
fc fc
.record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) }) .record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) })
@@ -148,7 +153,7 @@ const positions = fc.letrec<Positions>((tie) => {
{ depthIdentifier, depthSize: 'small', maxDepth: 4 }, { depthIdentifier, depthSize: 'small', maxDepth: 4 },
{ arbitrary: fc.oneof(...flatBlocks), weight: flatBlocks.reduce((sum, entry) => sum + entry.weight, 0) }, { arbitrary: fc.oneof(...flatBlocks), weight: flatBlocks.reduce((sum, entry) => sum + entry.weight, 0) },
{ arbitrary: fc.oneof(...containerBlocks), weight: containerBlocks.length * 2 }, { arbitrary: fc.oneof(...containerBlocks), weight: containerBlocks.length * 2 },
{ arbitrary: fc.oneof(textNode, ...inlineNodes, unknownNode), weight: misplacedWeight }, { arbitrary: fc.oneof(textNode, ...inlineArbitraries, unknownNode), weight: misplacedWeight },
{ arbitrary: fc.oneof(...nestingCommonMarkShapes), weight: nestingCommonMarkShapes.length * nestingCommonMarkShapeWeight }, { arbitrary: fc.oneof(...nestingCommonMarkShapes), weight: nestingCommonMarkShapes.length * nestingCommonMarkShapeWeight },
), ),
inline: fc.oneof( inline: fc.oneof(
@@ -156,8 +161,8 @@ const positions = fc.letrec<Positions>((tie) => {
{ arbitrary: textNode, weight: 12 }, { arbitrary: textNode, weight: 12 },
{ arbitrary: autolinkTextNode, weight: 2 }, { arbitrary: autolinkTextNode, weight: 2 },
{ arbitrary: backtickRunNode, weight: 3 }, { arbitrary: backtickRunNode, weight: 3 },
{ arbitrary: fc.oneof(...inlineNodes), weight: 7 }, { arbitrary: fc.oneof(...inlineArbitraries), weight: 7 },
{ arbitrary: fc.oneof(...blockNodes.map((entry) => entry.node), unknownNode), weight: 2 }, { arbitrary: fc.oneof(...blockArbitraries.map((entry) => entry.node), unknownNode), weight: 2 },
), ),
} }
}) })
+3 -1
View File
@@ -1,6 +1,8 @@
// Export only the conversions, their types, isAdfDocument and what a guarantee or a persona needs.
export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts' export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts'
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts' export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
export type { JsonValue } from './json-value.ts' export type { JsonValue } from './json-value.ts'
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { adfToPlainMarkdown } from './markdown/emit/plain-reduction.ts'
export { isAdfDocument } from './adf/document.ts' export { isAdfDocument } from './adf/document.ts'
export { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' export { markdownToAdf, plainMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
-13
View File
@@ -1,13 +0,0 @@
import type { BlockType } from '../adf/block-directives.ts'
const argumentByType = new Map(
Object.entries({
blockTaskItem: 'state',
panel: 'panelType',
taskItem: 'state',
} satisfies Partial<Record<BlockType, string>>),
)
export function blockArgument(type: string): string | undefined {
return argumentByType.get(type)
}
-9
View File
@@ -1,9 +0,0 @@
import { blockDirective } from '../adf/block-directives.ts'
import { listBreakName } from './list-break.ts'
export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined {
if (name === listBreakName) return 'leaf'
const directive = blockDirective(name)
if (directive === undefined) return undefined
return directive.contentModel === 'none' ? 'leaf' : 'container'
}
@@ -1,10 +1,36 @@
import type { AdfMark } from '../adf/document.ts' import type { AdfMark } from '../adf/document.ts'
import type { BlockType } from '../adf/block-nodes.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { blockNodeModel } from '../adf/block-nodes.ts'
import { isAdfMark, nodeAttrs } from '../adf/document.ts' import { isAdfMark, nodeAttrs } from '../adf/document.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
import { spellDirectiveOpener } from './directive-syntax.ts'
const argumentByType = new Map(
Object.entries({
blockTaskItem: 'state',
panel: 'panelType',
taskItem: 'state',
} satisfies Partial<Record<BlockType, string>>),
)
export const listBreakName = 'listBreak'
export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
export const marksAttribute = 'marks' export const marksAttribute = 'marks'
export function blockArgument(type: string): string | undefined {
return argumentByType.get(type)
}
export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined {
if (name === listBreakName) return 'leaf'
const model = blockNodeModel(name)
if (model === undefined) return undefined
return model.contentModel === 'none' ? 'leaf' : 'container'
}
export function markValues(marks: readonly AdfMark[]): JsonValue { export function markValues(marks: readonly AdfMark[]): JsonValue {
return marks.map((mark) => { return marks.map((mark) => {
const attrs = nodeAttrs(mark) const attrs = nodeAttrs(mark)
+2 -2
View File
@@ -1,7 +1,7 @@
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { carryName } from './opaque-carry.ts' import { carryName } from './opaque-carry.ts'
import { holdsControlCharacter } from './commonmark-grammar.ts' import { holdsControlCharacter } from './commonmark/grammar.ts'
import { holdsEntityReference } from './entity-references.ts' import { holdsEntityReference } from './commonmark/entity-references.ts'
export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' } export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' }
@@ -1,4 +1,4 @@
import { isUnicodeWhitespace } from './commonmark-grammar.ts' import { isUnicodeWhitespace } from './grammar.ts'
type DelimiterRun = { canClose: boolean; canOpen: boolean; character: string; length: number } type DelimiterRun = { canClose: boolean; canOpen: boolean; character: string; length: number }
@@ -27,6 +27,7 @@ export function isWordCharacter(character: string): boolean {
return character !== '' && !isWhitespace(character) && !isPunctuation(character) return character !== '' && !isWhitespace(character) && !isPunctuation(character)
} }
// Transcribes CommonMark's reference process_emphasis line for line; the closer walk and opener search stay whole, since named steps drift from it.
export function matchEmphasis<Run extends DelimiterRun>(runs: readonly Run[]): EmphasisPairing<Run>[] { export function matchEmphasis<Run extends DelimiterRun>(runs: readonly Run[]): EmphasisPairing<Run>[] {
const pairings: EmphasisPairing<Run>[] = [] const pairings: EmphasisPairing<Run>[] = []
const bottoms = new Map<string, Candidate<Run> | undefined>() const bottoms = new Map<string, Candidate<Run> | undefined>()
@@ -1,8 +1,8 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { readEntityReference } from './entity-references.ts' import { readEntityReference } from './entity-references.ts'
@@ -1,4 +1,4 @@
import { backslashEscape, decodeTextEscapes, holdsControlCharacter } from './commonmark-grammar.ts' import { backslashEscape, decodeTextEscapes, holdsControlCharacter } from './grammar.ts'
import { holdsEntityReference } from './entity-references.ts' import { holdsEntityReference } from './entity-references.ts'
export type LinkDefinition = { destination: string; title?: string } export type LinkDefinition = { destination: string; title?: string }
+2 -2
View File
@@ -1,8 +1,8 @@
import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts' import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts'
import type { ConvertFault } from '../result.ts' import type { ConvertFault } from '../result.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { backslashEscape } from './commonmark-grammar.ts' import { backslashEscape } from './commonmark/grammar.ts'
import { backtickRun, closingBacktickRun } from './backtick-runs.ts' import { backtickRun, closingBacktickRun } from './commonmark/backtick-runs.ts'
import { isJsonValue, overNested } from '../json-value.ts' import { isJsonValue, overNested } from '../json-value.ts'
import { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
+11 -4
View File
@@ -353,8 +353,8 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth') assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth')
let attrs: AdfMark['attrs'] = { depth: 'x' } let attrs: AdfMark['attrs'] = { depth: 'x' }
for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs } for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs }
const deeper = (key: string, type: string, levels: number = largestNesting): string => const deeper = (key: string, type: string): string =>
`unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries` `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
const nested = (levels: number): JsonValue => { const nested = (levels: number): JsonValue => {
let value: JsonValue = 1 let value: JsonValue = 1
for (let level = 0; level < levels; level += 1) value = [value] for (let level = 0; level < levels; level += 1) value = [value]
@@ -375,11 +375,18 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
assert.deepEqual(toEditorNormal(read.value), document(node)) assert.deepEqual(toEditorNormal(read.value), document(node))
} }
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em', largestNesting - 3)) assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em'))
assert.equal(
markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { deep: nested(largestNesting) }, type: 'em' }], text: 'x', type: 'text' })))),
`unsupported-nesting-depth: a carried node's JSON nests deeper than the ${largestNesting} levels its position leaves`,
)
assert.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper('data', 'inlineCard')) assert.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper('data', 'inlineCard'))
assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), []) assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), [])
roundTrips(paragraph(card(largestNesting))) roundTrips(paragraph(card(largestNesting)))
assert.equal(markdown(adfToMarkdown(document(marked(largestNesting - 2)))), deeper('deep', 'em', largestNesting - 3)) assert.equal(markdown(adfToMarkdown(document(marked(largestNesting - 2)))), deeper('marks', 'panel'))
assert.deepEqual(path(adfToMarkdown(document(marked(largestNesting - 2)))), ['content', 0])
const markedCode: AdfNode = { content: [{ text: 'x', type: 'text' }], marks: [{ attrs: { deep: nested(largestNesting - 2) }, type: 'em' }], type: 'codeBlock' }
assert.equal(markdown(adfToMarkdown(document(markedCode))), deeper('marks', 'codeBlock'))
roundTrips(marked(largestNesting - 3)) roundTrips(marked(largestNesting - 3))
}) })
+152 -59
View File
@@ -1,16 +1,17 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockDirective } from '../../adf/block-directives.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import type { Flavour } from '../plain-conventions.ts'
import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { adfDocumentFault, carriesOnly, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { blockDirective, blockDirectives } from '../../adf/block-directives.ts' import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain-conventions.ts'
import { blockDirectiveForm } from '../block-directive-forms.ts' import { blockDirectiveForm, listBreakSpelling } from '../block-directive.ts'
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
import { carriedBlock } from '../opaque-carry.ts' import { carriedBlock } from '../opaque-carry.ts'
import { emitInlineLine } from './inline-line.ts' import { emitInlineLine } from './inline-line.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { fencedCodeBlock } from '../backtick-runs.ts' import { fencedCodeBlock } from '../commonmark/backtick-runs.ts'
import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts' import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts'
import { languageSlot } from '../code-language.ts' import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { listBreakSpelling } from '../list-break.ts'
import { spellBlockDirectiveOpener } from './block-directive-spelling.ts' import { spellBlockDirectiveOpener } from './block-directive-spelling.ts'
import { spellDirectiveCloser, spellDirectiveOpener } from '../directive-syntax.ts' import { spellDirectiveCloser, spellDirectiveOpener } from '../directive-syntax.ts'
import { tryImage } from './image.ts' import { tryImage } from './image.ts'
@@ -19,31 +20,39 @@ import { tryPipeTable } from './pipe-table.ts'
type BlockContainer = 'directive' | 'document' | 'list-item' type BlockContainer = 'directive' | 'document' | 'list-item'
type BlockSpelling = 'commonmark' | 'directive' | 'list' type BlockSpelling = 'commonmark' | 'directive' | 'list'
type EmittedBlock = { headroom: number; spelling: BlockSpelling; text: string } type EmittedBlock = { headroom: number; spelling: BlockSpelling; text: string }
type PlacedBlock = EmittedBlock & { node: AdfNode } type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
type PlacedBlock = Omit<EmittedBlock, 'headroom'> & { node: AdfNode }
// Keyed by reference: only a caller building one object per position (the parse, the plain reduction) passes one; a consumer's document may share a node.
export type SpellingMemo = Map<AdfNode, KeptSpelling>
type Walk = { blocks: readonly PlacedBlock[]; headroom: number } type Walk = { blocks: readonly PlacedBlock[]; headroom: number }
type WalkedItem = { node: AdfNode; walk: Walk } type WalkedItem = { node: AdfNode; walk: Walk }
export type Writing = { flavour: Flavour; memo: SpellingMemo | undefined }
const largestListMarker = 999999999 export const largestListMarker = 999999999
// Bare because emitList admits no item carrying attributes, marks or text. // Bare because tryList admits no item carrying attributes, marks or text.
const listItemOpener = spellDirectiveOpener('listItem', undefined, '') const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
export function adfToMarkdown(document: AdfDocument): Result<string> { export function adfToMarkdown(document: AdfDocument): Result<string> {
return writeMarkdown(document, 'lossless')
}
export function writeMarkdown(document: AdfDocument, flavour: Flavour): Result<string> {
const fault = adfDocumentFault(document) const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, []) if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const walk = walkBlocks(nodeContent(document), [], 0) const walk = walkBlocks(nodeContent(document), [], 0, { flavour, memo: undefined })
if (!walk.ok) return walk if (!walk.ok) return walk
const text = joinBlocks(walk.value.blocks, 'document') const text = joinBlocks(walk.value.blocks, 'document')
return success(text === '' ? '' : `${text}\n`) return success(text === '' ? '' : `${text}\n`)
} }
// headroom: the least slack any depth guard below the walk has. // headroom: the least slack any depth guard below the walk has.
function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number): Result<Walk> { function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, writing: Writing): Result<Walk> {
let headroom = largestNesting - depth let headroom = largestNesting - depth
if (headroom < 0) return tooDeep(path) if (headroom < 0) return tooDeep(path)
const blocks: PlacedBlock[] = [] const blocks: PlacedBlock[] = []
for (const [index, node] of nodes.entries()) { for (const [index, node] of nodes.entries()) {
const block = emitBlock(node, [...path, 'content', index], depth) const block = emitBlock(node, [...path, 'content', index], depth, writing)
if (!block.ok) return block if (!block.ok) return block
headroom = Math.min(headroom, block.value.headroom) headroom = Math.min(headroom, block.value.headroom)
blocks.push({ ...block.value, node }) blocks.push({ ...block.value, node })
@@ -79,38 +88,120 @@ function separationBetween(previous: PlacedBlock, next: PlacedBlock, container:
function interruptsParagraph(node: AdfNode): boolean { function interruptsParagraph(node: AdfNode): boolean {
const items = nodeContent(node) const items = nodeContent(node)
const empty = items[0] === undefined || nodeContent(items[0]).length === 0 const empty = node.type !== 'taskList' && (items[0] === undefined || nodeContent(items[0]).length === 0)
if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty) if (node.type !== 'orderedList') return markerInterruptsParagraph(undefined, empty)
return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty) return markerInterruptsParagraph(listStart(node, items.length) ?? 0, empty)
} }
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> { function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const directive = blockDirective(node.type) const model = blockNodeModel(node.type)
if (directive === undefined) return commonMarkLine(carriedBlock(node, path, depth)) if (model === undefined) return commonMarkLine(carriedBlock(node, path, depth))
const readable = readableBlock(node, path, depth) const readable = readableBlock(node, path, depth, writing)
if (readable !== undefined) return readable if (readable !== undefined) return readable
return emitDirectiveBlock(node, directive, path, depth, () => walkBlocks(nodeContent(node), path, depth + 1)) return emitDirectiveBlock(node, model, path, depth, () => walkBlocks(nodeContent(node), path, depth + 1, writing))
} }
export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number): Result<null> | undefined { export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<null> | undefined {
const readable = readableBlock(node, path, depth) const readable = readableBlock(node, path, depth, writing)
if (readable === undefined) return undefined if (readable === undefined) return undefined
if (!readable.ok) return readable if (!readable.ok) return readable
return readable.value.spelling === 'directive' ? undefined : success(null) return readable.value.spelling === 'directive' ? undefined : success(null)
} }
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined { function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
if (node.type === 'blockquote') return emitBlockquote(node, path, depth) const { memo } = writing
if (node.type === 'bulletList' || node.type === 'orderedList') return emitList(node, path, depth) const kept = memo?.get(node)
if (node.type === 'codeBlock') return emitCodeBlock(node, path) if (kept !== undefined) {
if (node.type === 'heading') return emitHeading(node, path) if (kept.block === undefined) return undefined
// A read below the fill would skip the depth guards the walk it replaces runs (docs/decisions.md §The spelling memo).
if (depth <= kept.depth) return success({ ...kept.block, headroom: kept.block.headroom + kept.depth - depth })
}
const spelled = spellReadableBlock(node, path, depth, writing)
if (spelled === undefined) memo?.set(node, { block: undefined, depth })
else if (spelled.ok) memo?.set(node, { block: spelled.value, depth })
return spelled
}
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
const plain = writing.flavour === 'plain' ? spellPlainBlock(node, path, depth, writing) : undefined
if (plain !== undefined) return plain
if (node.type === 'blockquote') return tryBlockquote(node, path, depth, writing)
if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, writing)
if (node.type === 'codeBlock') return tryCodeBlock(node, path)
if (node.type === 'heading') return tryHeading(node, path, writing.flavour)
if (node.type === 'mediaSingle') return readableText(tryImage(node, path)) if (node.type === 'mediaSingle') return readableText(tryImage(node, path))
if (node.type === 'paragraph') return emitParagraph(node, path) if (node.type === 'paragraph') return tryParagraph(node, path, writing.flavour)
if (node.type === 'rule') return emitRule(node) if (node.type === 'rule') return readableText(tryRule(node))
if (node.type === 'table') return readableText(tryPipeTable(node, path)) if (node.type === 'table') return readableText(tryPipeTable(node, path, writing.flavour))
return undefined return undefined
} }
// The plain flavour's nodes, in the shapes the plain reduction leaves them.
function spellPlainBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
if (node.type === 'panel') return quotedUnder(alertMarker(nodeAttrs(node)['panelType']), node, path, depth, writing)
if (node.type === 'taskList') return tryTaskList(node, path, depth, writing)
if (node.type !== 'expand' && node.type !== 'nestedExpand') return undefined
const title = nodeAttrs(node)['title']
if (typeof title !== 'string') return quotedUnder(foldedAlertMarker, node, path, depth, writing)
// The reader takes a title as lossless inline text.
const line = emitInlineLine([{ text: title, type: 'text' }], 'paragraph', path, 'lossless')
return line.ok ? quotedUnder(`${foldedAlertMarker} ${line.value}`, node, path, depth, writing) : line
}
function quotedUnder(head: string, node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
if (!inner.ok) return inner
const body = joinBlocks(inner.value.blocks, 'document')
return success(commonMarkText(quoted(body === '' ? head : `${head}\n\n${body}`), inner.value.headroom))
}
function quoted(text: string): string {
return text
.split('\n')
.map((line) => (line === '' ? '>' : `> ${line}`))
.join('\n')
}
function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const items: PlacedBlock[][] = []
let headroom = largestNesting - depth - 1
// A child other than a task nests in the task before it.
for (const [index, child] of nodeContent(node).entries()) {
const task = child.type === 'taskItem' || child.type === 'blockTaskItem'
const walk = task ? taskBlocks(child, [...path, 'content', index], depth + 1, writing) : placedBlock(child, [...path, 'content', index], depth + 1, writing)
if (!walk.ok) return walk
headroom = Math.min(headroom, walk.value.headroom)
const previous = items.at(-1)
if (task || previous === undefined) items.push([...walk.value.blocks])
else for (const block of walk.value.blocks) previous.push(block)
}
const lines = items.map((blocks) => tryListItemLines(joinBlocks(blocks, 'list-item'), '- '))
// The plain reduction leaves no task a list item cannot hold: a directive here would break the flavour.
return lines.includes(undefined) ? failure('unsupported-node-shape', 'a task holds blocks no list item spells', path) : success({ headroom, spelling: 'list', text: lines.join('\n') })
}
function placedBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<Walk> {
const block = emitBlock(node, path, depth, writing)
return block.ok ? success({ blocks: [{ ...block.value, node }], headroom: block.value.headroom }) : block
}
// The marker leads the first paragraph, or stands as one where the blocks open with another.
function taskBlocks(task: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<Walk> {
const marker = taskMarker(nodeAttrs(task)['state'])
const markerBlock: PlacedBlock = { node: { type: 'paragraph' }, spelling: 'commonmark', text: marker }
if (task.type === 'taskItem') {
const content = nodeContent(task)
const line = content.length === 0 ? success('') : emitInlineLine(content, 'paragraph', path, writing.flavour)
if (!line.ok) return line
return success({ blocks: [{ ...markerBlock, text: line.value === '' ? marker : `${marker} ${line.value}` }], headroom: Number.POSITIVE_INFINITY })
}
const walk = walkBlocks(nodeContent(task), path, depth, writing)
if (!walk.ok) return walk
const [first, ...rest] = walk.value.blocks
const blocks = first?.node.type === 'paragraph' ? [{ ...first, text: `${marker} ${first.text}` }, ...rest] : [markerBlock, ...walk.value.blocks]
return success({ blocks, headroom: walk.value.headroom })
}
function readableText(text: string | undefined): Result<EmittedBlock> | undefined { function readableText(text: string | undefined): Result<EmittedBlock> | undefined {
return text === undefined ? undefined : success(commonMarkText(text)) return text === undefined ? undefined : success(commonMarkText(text))
} }
@@ -128,19 +219,20 @@ function directivePair(node: AdfNode, opener: string, body: string, headroom: nu
return { headroom, spelling: 'directive', text: `${opener}\n${body === '' ? '' : `${body}\n`}${spellDirectiveCloser(node.type)}` } return { headroom, spelling: 'directive', text: `${opener}\n${body === '' ? '' : `${body}\n`}${spellDirectiveCloser(node.type)}` }
} }
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number, walkBody: () => Result<Walk>): Result<EmittedBlock> { function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number, walkBody: () => Result<Walk>): Result<EmittedBlock> {
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path) if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path) if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path, depth) if (model.contentModel === 'code') return emitCodeDirective(node, model, path, depth)
const opener = spellBlockDirectiveOpener(node, directive) const opener = spellBlockDirectiveOpener(node, model, path)
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth)) if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
return emitDirectiveBody(node, directive, opener, path, walkBody) if (!opener.ok) return opener
return emitDirectiveBody(node, model, opener.value, path, walkBody)
} }
function emitDirectiveBody(node: AdfNode, directive: BlockDirective, opener: string, path: ConvertErrorPath, walkBody: () => Result<Walk>): Result<EmittedBlock> { function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string, path: ConvertErrorPath, walkBody: () => Result<Walk>): Result<EmittedBlock> {
if (blockDirectiveForm(node.type) === 'leaf') return success({ headroom: Number.POSITIVE_INFINITY, spelling: 'directive', text: opener }) if (blockDirectiveForm(node.type) === 'leaf') return success({ headroom: Number.POSITIVE_INFINITY, spelling: 'directive', text: opener })
if (directive.contentModel === 'inline') { if (model.contentModel === 'inline') {
const line = emitInlineLine(nodeContent(node), 'paragraph', path) const line = emitInlineLine(nodeContent(node), 'paragraph', path, 'lossless')
if (!line.ok) return line if (!line.ok) return line
return success(directivePair(node, opener, line.value)) return success(directivePair(node, opener, line.value))
} }
@@ -149,18 +241,16 @@ function emitDirectiveBody(node: AdfNode, directive: BlockDirective, opener: str
return success(directivePair(node, opener, joinBlocks(walk.value.blocks, 'directive'), walk.value.headroom)) return success(directivePair(node, opener, joinBlocks(walk.value.blocks, 'directive'), walk.value.headroom))
} }
function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined { function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, [])) return undefined if (!carriesOnly(node, [])) return undefined
const inner = walkBlocks(nodeContent(node), path, depth + 1) const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
if (!inner.ok) return inner if (!inner.ok) return inner
const text = joinBlocks(inner.value.blocks, 'document') const text = joinBlocks(inner.value.blocks, 'document')
.split('\n') const alert = writing.flavour === 'plain' && leadingMarker(text, readAlertMarker) !== undefined
.map((line) => (line === '' ? '>' : `> ${line}`)) return success(commonMarkText(quoted(alert ? `\\${text}` : text), inner.value.headroom))
.join('\n')
return success(commonMarkText(text, inner.value.headroom))
} }
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['language'])) return undefined if (!carriesOnly(node, ['language'])) return undefined
const slot = languageSlot(nodeAttrs(node)['language']) const slot = languageSlot(nodeAttrs(node)['language'])
if (slot.kind === 'attribute') return undefined if (slot.kind === 'attribute') return undefined
@@ -169,13 +259,14 @@ function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlo
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value))) return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
} }
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> { function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const slot = languageSlot(nodeAttrs(node)['language']) const slot = languageSlot(nodeAttrs(node)['language'])
const opener = spellBlockDirectiveOpener(node, directive, slot.kind === 'attribute' ? [] : ['language']) const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth)) if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (!opener.ok) return opener
const text = codeBlockText(node, path) const text = codeBlockText(node, path)
if (!text.ok) return text if (!text.ok) return text
return success(directivePair(node, opener, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value))) return success(directivePair(node, opener.value, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
} }
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> { function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
@@ -199,19 +290,19 @@ function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
return success(text) return success(text)
} }
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
if (!carriesOnly(node, ['level'])) return undefined if (!carriesOnly(node, ['level'])) return undefined
const level = nodeAttrs(node)['level'] const level = nodeAttrs(node)['level']
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
const hashes = '#'.repeat(level) const hashes = '#'.repeat(level)
const content = nodeContent(node) const content = nodeContent(node)
if (content.length === 0) return success(commonMarkText(hashes)) if (content.length === 0) return success(commonMarkText(hashes))
const line = emitInlineLine(content, 'heading', path) const line = emitInlineLine(content, 'heading', path, flavour)
if (!line.ok) return line if (!line.ok) return line
return success(commonMarkText(`${hashes} ${line.value}`)) return success(commonMarkText(`${hashes} ${line.value}`))
} }
function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<EmittedBlock> | undefined { function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
const ordered = node.type === 'orderedList' const ordered = node.type === 'orderedList'
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
const items = nodeContent(node) const items = nodeContent(node)
@@ -221,26 +312,29 @@ function emitList(node: AdfNode, path: ConvertErrorPath, depth: number): Result<
const walked: WalkedItem[] = [] const walked: WalkedItem[] = []
let headroom = Number.POSITIVE_INFINITY let headroom = Number.POSITIVE_INFINITY
for (const [offset, item] of items.entries()) { for (const [offset, item] of items.entries()) {
const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1) const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1, writing)
if (!walk.ok) return walk if (!walk.ok) return walk
headroom = Math.min(headroom, walk.value.headroom) headroom = Math.min(headroom, walk.value.headroom)
walked.push({ node: item, walk: walk.value }) walked.push({ node: item, walk: walk.value })
} }
const lines: string[] = [] const lines: string[] = []
for (const [offset, item] of walked.entries()) { for (const [offset, item] of walked.entries()) {
const line = listItemLines(item.walk.blocks, ordered ? `${start + offset}. ` : '- ') const inner = joinBlocks(item.walk.blocks, 'list-item')
// GitHub reads a task marker opening any item's first paragraph as a checkbox, whatever its siblings hold.
const escaped = writing.flavour === 'plain' && leadingMarker(inner, readTaskMarker) !== undefined ? `\\${inner}` : inner
const line = tryListItemLines(escaped, ordered ? `${start + offset}. ` : '- ')
if (line === undefined) { if (line === undefined) {
// The directive form spends a level the walk did not count.
if (headroom < 1) return tooDeep(path) if (headroom < 1) return tooDeep(path)
return emitDirectiveBlock(node, ordered ? blockDirectives.orderedList : blockDirectives.bulletList, path, depth, () => success({ blocks: directiveItems(walked), headroom: headroom - 1 })) return emitDirectiveBlock(node, ordered ? blockNodes.orderedList : blockNodes.bulletList, path, depth, () => success({ blocks: directiveItems(walked), headroom: headroom - 1 }))
} }
lines.push(line) lines.push(line)
} }
return success({ headroom, spelling: 'list', text: lines.join('\n') }) return success({ headroom, spelling: 'list', text: lines.join('\n') })
} }
// The directive form sinks each item's blocks a level below where the walk read them.
function directiveItems(items: readonly WalkedItem[]): PlacedBlock[] { function directiveItems(items: readonly WalkedItem[]): PlacedBlock[] {
return items.map((item) => ({ ...directivePair(item.node, listItemOpener, joinBlocks(item.walk.blocks, 'directive'), item.walk.headroom - 1), node: item.node })) return items.map((item) => ({ ...directivePair(item.node, listItemOpener, joinBlocks(item.walk.blocks, 'directive')), node: item.node }))
} }
function listStart(node: AdfNode, items: number): number | undefined { function listStart(node: AdfNode, items: number): number | undefined {
@@ -250,8 +344,7 @@ function listStart(node: AdfNode, items: number): number | undefined {
return start + items - 1 > largestListMarker ? undefined : start return start + items - 1 > largestListMarker ? undefined : start
} }
function listItemLines(blocks: readonly PlacedBlock[], marker: string): string | undefined { function tryListItemLines(inner: string, marker: string): string | undefined {
const inner = joinBlocks(blocks, 'list-item')
if (inner === '') return marker.trimEnd() if (inner === '') return marker.trimEnd()
const body = inner.split('\n') const body = inner.split('\n')
if (body.some((line) => line !== '' && isBlankLine(line))) return undefined if (body.some((line) => line !== '' && isBlankLine(line))) return undefined
@@ -261,15 +354,15 @@ function listItemLines(blocks: readonly PlacedBlock[], marker: string): string |
return lines.join('\n') return lines.join('\n')
} }
function emitParagraph(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function tryParagraph(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
const content = nodeContent(node) const content = nodeContent(node)
if (content.length === 0 || !carriesOnly(node, [])) return undefined if (content.length === 0 || !carriesOnly(node, [])) return undefined
const line = emitInlineLine(content, 'paragraph', path) const line = emitInlineLine(content, 'paragraph', path, flavour)
if (!line.ok) return line if (!line.ok) return line
return success(commonMarkText(line.value)) return success(commonMarkText(line.value))
} }
function emitRule(node: AdfNode): Result<EmittedBlock> | undefined { function tryRule(node: AdfNode): string | undefined {
if (!carriesOnly(node, []) || nodeContent(node).length > 0) return undefined if (!carriesOnly(node, []) || nodeContent(node).length > 0) return undefined
return success(commonMarkText('---')) return '---'
} }
+13 -8
View File
@@ -1,22 +1,27 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { BlockDirective } from '../../adf/block-directives.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import { blockArgument } from '../block-directive-arguments.ts' import { attributeNestingMessage, nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { blockArgument, markValues, marksAttribute } from '../block-directive.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { markValues, marksAttribute } from '../block-directive-marks.ts' import { overNested } from '../../json-value.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellBlockDirectiveOpener(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, spelledByBody: readonly string[] = []): Result<string> | undefined {
const argumentAttribute = blockArgument(node.type) const argumentAttribute = blockArgument(node.type)
const slot = bareArgument(node, argumentAttribute) const slot = bareArgument(node, argumentAttribute)
if (slot === undefined) return undefined if (slot === undefined) return undefined
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody] const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, spelled) const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, spelled)
if (pairs === undefined) return undefined if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs) const spelledPairs = spellVocabulary(pairs)
const marks = nodeMarks(node) const marks = nodeMarks(node)
if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))]) if (marks.length > 0) {
return spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs)) const values = markValues(marks)
if (overNested(values)) return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, node.type), path)
spelledPairs.push([marksAttribute, spellJsonAttribute(values)])
}
return success(spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs)))
} }
// `undefined` where the argument slot holds a value no bare token spells. // `undefined` where the argument slot holds a value no bare token spells.
+1 -1
View File
@@ -1,6 +1,6 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import { carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts'
import type { ConvertErrorPath } from '../../result.ts' import type { ConvertErrorPath } from '../../result.ts'
import { carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts' import { serializeCanonicalJson } from '../../canonical-json.ts'
import { tryImageLine } from './inline-line.ts' import { tryImageLine } from './inline-line.ts'
@@ -1,10 +1,10 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { InlineDirective } from '../../adf/inline-directives.ts' import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
import { nodeAttrs } from '../../adf/document.ts' import { nodeAttrs } from '../../adf/document.ts'
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts' import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined { export function spellInlineNodeAttributes(node: AdfNode, model: InlineNodeModel): string | undefined {
const pairs = vocabularyPairs(nodeAttrs(node), directive.attributes, directive.textAttribute === undefined ? [] : [directive.textAttribute]) const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, model.textAttribute === undefined ? [] : [model.textAttribute])
return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs))
} }
+97 -64
View File
@@ -1,14 +1,17 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { InlineDirective } from '../../adf/inline-directives.ts' import type { Flavour } from '../plain-conventions.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts' import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
import type { LineContainer } from '../line-container.ts'
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts'
import { carriedInline } from '../opaque-carry.ts' import { carriedInline } from '../opaque-carry.ts'
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark-grammar.ts' import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { commonMarkLink, markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { escapeUnbalanced, spellDestination } from '../link-syntax.ts' import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineDirective } from '../../adf/inline-directives.ts' import { highlightDelimiter } from '../plain-conventions.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../backtick-runs.ts' import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { sameMark } from '../../adf/editor-normal.ts' import { sameMark } from '../../adf/editor-normal.ts'
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts' import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
@@ -23,6 +26,7 @@ type InlineContext = {
atBlockEnd: boolean atBlockEnd: boolean
bracketed: boolean bracketed: boolean
carried: ReadonlySet<number> carried: ReadonlySet<number>
flavour: Flavour
openingLinkAsDirective: boolean openingLinkAsDirective: boolean
path: ConvertErrorPath path: ConvertErrorPath
spansLines: boolean spansLines: boolean
@@ -30,25 +34,34 @@ type InlineContext = {
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode } type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
type LineAttempt = type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } | { fallback?: undefined; line: string }
| { carry: NodeRange; line?: undefined; openingLinkAsDirective?: undefined }
| { carry?: undefined; line?: undefined; openingLinkAsDirective: true }
| { carry?: undefined; line: string; openingLinkAsDirective?: undefined }
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> { type LineFallbacks = { carried: Set<number>; flavour: Flavour; openingLinkAsDirective: boolean }
const emitted = emitLine(nodes, container, path)
export type PlainLineFallback = { kind: 'claimed-line'; line: number; text: string } | { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<string> {
const emitted = emitLine(nodes, container, path, flavour)
if (!emitted.ok) return emitted if (!emitted.ok) return emitted
return success(emitted.value.line) return success(emitted.value.line)
} }
export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<boolean> { export function openingLinkTakesDirective(nodes: readonly AdfNode[], path: ConvertErrorPath): Result<boolean> {
const emitted = emitLine(nodes, 'paragraph', path) const emitted = emitLine(nodes, 'paragraph', path, 'lossless')
if (!emitted.ok) return emitted if (!emitted.ok) return emitted
return success(emitted.value.openingLinkAsDirective) return success(emitted.value.openingLinkAsDirective)
} }
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined { export function plainLineFallback(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<PlainLineFallback | undefined> {
const emitted = emitLine(nodes, 'table-cell', path) const emission = lineSegments(nodes, container, path, { carried: new Set(), flavour: 'plain', openingLinkAsDirective: false })
if (!emission.ok) return emission
if (emission.value.carry !== undefined) return failure('unsupported-node-shape', 'an inline node on a plain line has no spelling but the carry', path)
const verdict = lineVerdict(emission.value.segments, container, 'plain')
return success(verdict.kind === 'line' ? undefined : verdict)
}
export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, flavour: Flavour): string | undefined {
const emitted = emitLine(nodes, 'table-cell', path, flavour)
if (!emitted.ok) return undefined if (!emitted.ok) return undefined
if (emitted.value.segments.some((segment) => isSyntax(segment.escaping) && segment.text.includes('|'))) return undefined if (emitted.value.segments.some((segment) => isSyntax(segment.escaping) && segment.text.includes('|'))) return undefined
return emitted.value.line return emitted.value.line
@@ -59,57 +72,68 @@ export function tryImageLine(alt: string | undefined, href: string, path: Conver
const destination = spellDestination(href) const destination = spellDestination(href)
if (destination === undefined) return undefined if (destination === undefined) return undefined
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }] const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
const attempt = attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', path) const attempt = attemptLine([syntax('!['), ...description, syntax(`](${destination})`)], 'paragraph', path, 'lossless')
return attempt.ok ? attempt.value.line : undefined return attempt.ok ? attempt.value.line : undefined
} }
// Every pass carries at least one more node, or flips openingLinkAsDirective, which happens once — so the loop ends. function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<EmittedLine> {
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<EmittedLine> { const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false }
const carried = new Set<number>() // Terminates because takeFallback refuses a pass that took no new fallback.
let openingLinkAsDirective = false
for (;;) { for (;;) {
const emission = lineSegments(nodes, container, path, carried, openingLinkAsDirective) const emission = lineSegments(nodes, container, path, fallbacks)
if (!emission.ok) return emission if (!emission.ok) return emission
if (emission.value.carry !== undefined) { if (emission.value.carry !== undefined) {
carryRange(carried, emission.value.carry) const taken = takeFallback(fallbacks, emission.value.carry, path)
if (!taken.ok) return taken
continue continue
} }
const attempt = attemptLine(emission.value.segments, container, path) const attempt = attemptLine(emission.value.segments, container, path, flavour)
if (!attempt.ok) return attempt if (!attempt.ok) return attempt
if (attempt.value.line !== undefined) return success({ line: attempt.value.line, openingLinkAsDirective, segments: emission.value.segments }) if (attempt.value.line !== undefined) {
if (attempt.value.carry !== undefined) carryRange(carried, attempt.value.carry) return success({ line: attempt.value.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
else openingLinkAsDirective = true }
const taken = takeFallback(fallbacks, attempt.value.fallback, path)
if (!taken.ok) return taken
} }
} }
function carryRange(carried: Set<number>, range: NodeRange): void { function takeFallback(fallbacks: LineFallbacks, fallback: NodeRange | 'opening-link', path: ConvertErrorPath): Result<null> {
for (let index = range.first; index <= range.last; index += 1) carried.add(index) if (fallback === 'opening-link') {
if (fallbacks.openingLinkAsDirective) return failure('unsupported-node-shape', 'an opening link spelled as a directive still reads as a link definition, so the line has no spelling left', path)
fallbacks.openingLinkAsDirective = true
return success(null)
}
const before = fallbacks.carried.size
for (let index = fallback.first; index <= fallback.last; index += 1) fallbacks.carried.add(index)
if (fallbacks.carried.size === before) return failure('unsupported-node-shape', 'a carry took no inline node the line had not carried, so the line has no spelling left', path)
return success(null)
} }
function lineSegments( function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, fallbacks: LineFallbacks): Result<Emission> {
nodes: readonly AdfNode[], const context: InlineContext = { atBlockEnd: true, bracketed: false, ...fallbacks, path, spansLines: container === 'paragraph' }
container: LineContainer,
path: ConvertErrorPath,
carried: ReadonlySet<number>,
openingLinkAsDirective: boolean,
): Result<Emission> {
const context: InlineContext = { atBlockEnd: true, bracketed: false, carried, openingLinkAsDirective, path, spansLines: container === 'paragraph' }
const emission = emitRun(nodes, 0, 0, context) const emission = emitRun(nodes, 0, 0, context)
if (!emission.ok) return emission if (!emission.ok) return emission
if (emission.value.carry !== undefined) return emission if (emission.value.carry !== undefined) return emission
return success({ segments: carryStrippedWhitespace(emission.value.segments) }) return success({ segments: carryStrippedWhitespace(emission.value.segments) })
} }
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath): Result<LineAttempt> { function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<LineAttempt> {
const assembled = assembleInlineLine(segments, container) const verdict = lineVerdict(segments, container, flavour)
if (assembled.openingLinkAsDirective) return success({ openingLinkAsDirective: true }) if (verdict.kind === 'opening-link') return success({ fallback: 'opening-link' })
if (assembled.unspellableRun !== undefined) return success({ carry: assembled.unspellableRun }) if (verdict.kind === 'unspellable-run') return success({ fallback: verdict.runs[0] })
for (const [index, single] of assembled.line.split('\n').entries()) { if (verdict.kind === 'claimed-line') return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(verdict.text)}`, path)
if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) { return success({ line: verdict.text })
return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`, path) }
}
} // The fallbacks in the order a line takes them, or the line where it takes none.
return success({ line: assembled.line }) function lineVerdict(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): PlainLineFallback | { kind: 'line'; text: string } {
const assembled = assembleInlineLine(segments, container, flavour)
if (assembled.openingLinkAsDirective) return { kind: 'opening-link' }
const [run, ...others] = assembled.unspellableRuns
if (run !== undefined) return { kind: 'unspellable-run', runs: [run, ...others] }
const lines = assembled.line.split('\n')
const claimed = container === 'paragraph' ? lines.findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later')) : -1
return claimed === -1 ? { kind: 'line', text: assembled.line } : { kind: 'claimed-line', line: claimed, text: lines[claimed] ?? '' }
} }
// spec/flavour.md, Inline nodes. // spec/flavour.md, Inline nodes.
@@ -194,7 +218,7 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath {
function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean { function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
if (carried.has(index)) return true if (carried.has(index)) return true
return node.type !== 'text' && inlineDirective(node.type) === undefined return node.type !== 'text' && inlineNodeModel(node.type) === undefined
} }
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> { function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> {
@@ -206,27 +230,27 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
} }
const types = nodeMarks(node).map((mark) => mark.type) const types = nodeMarks(node).map((mark) => mark.type)
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path) if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
const directive = inlineDirective(node.type) const model = inlineNodeModel(node.type)
if (directive === undefined) return emitText(node, context, index, path) if (model === undefined) return emitText(node, context, index, path)
if (node.type === 'hardBreak') return emitHardBreak(node, directive, context, index, path) if (node.type === 'hardBreak') return emitHardBreak(node, model, context, index, path)
return emitInlineDirective(node, directive, index, path) return emitInlineDirective(node, model, index, path)
} }
function emitHardBreak(node: AdfNode, directive: InlineDirective, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> { function emitHardBreak(node: AdfNode, model: InlineNodeModel, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
const empty = refuseContentAndText(node, path) const empty = refuseContentAndText(node, path)
if (!empty.ok) return empty if (!empty.ok) return empty
const attributes = spellInlineNodeAttributes(node, directive) const attributes = spellInlineNodeAttributes(node, model)
if (attributes === undefined) return success({ carry: { first: index, last: index } }) if (attributes === undefined) return success({ carry: { first: index, last: index } })
if (attributes === '' && context.spansLines && !context.atBlockEnd) return success({ segments: [syntax('\\\n')] }) if (attributes === '' && context.spansLines && !context.atBlockEnd) return success({ segments: [syntax('\\\n')] })
return success({ segments: [syntax(spellInlineLeafDirective('hardBreak', attributes))] }) return success({ segments: [syntax(spellInlineLeafDirective('hardBreak', attributes))] })
} }
function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: number, path: ConvertErrorPath): Result<Emission> { function emitInlineDirective(node: AdfNode, model: InlineNodeModel, index: number, path: ConvertErrorPath): Result<Emission> {
const empty = refuseContentAndText(node, path) const empty = refuseContentAndText(node, path)
if (!empty.ok) return empty if (!empty.ok) return empty
const attributes = spellInlineNodeAttributes(node, directive) const attributes = spellInlineNodeAttributes(node, model)
if (attributes === undefined) return success({ carry: { first: index, last: index } }) if (attributes === undefined) return success({ carry: { first: index, last: index } })
const slot = directive.textAttribute === undefined ? undefined : nodeAttrs(node)[directive.textAttribute] const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
if (slot === undefined) return success({ segments: [syntax(spellInlineLeafDirective(node.type, attributes))] }) if (slot === undefined) return success({ segments: [syntax(spellInlineLeafDirective(node.type, attributes))] })
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } }) if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
const spans = slotLineEndingFault(node.type, slot) const spans = slotLineEndingFault(node.type, slot)
@@ -250,13 +274,14 @@ function emitText(node: AdfNode, context: InlineContext, index: number, path: Co
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> { function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
const path = nodePath(context, index) const path = nodePath(context, index)
const range: NodeRange = { first: index, last: index + nodes.length - 1 } const range: NodeRange = { first: index, last: index + nodes.length - 1 }
if (mark.type === 'backgroundColor' && context.flavour === 'plain') return emitHighlight(nodes, depth, range, context)
const spelling = markSpelling(mark.type) const spelling = markSpelling(mark.type)
if (spelling === undefined) return success({ carry: range }) if (spelling === undefined) return success({ carry: range })
const attributes = spellMarkAttributes(mark, spelling.attributes) const attributes = spellMarkAttributes(mark, spelling.attributes)
if (attributes === undefined) return success({ carry: range }) if (attributes === undefined) return success({ carry: range })
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path) if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context) if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
const link = spelling.kind === 'link' ? emitLink(nodes, mark, depth, range, context) : undefined const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined
if (link !== undefined) return link if (link !== undefined) return link
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false }) const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
if (!inner.ok) return inner if (!inner.ok) return inner
@@ -271,13 +296,22 @@ function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number
const carried = carryStrippedWhitespace(inner.value.segments) const carried = carryStrippedWhitespace(inner.value.segments)
return success({ return success({
segments: [ segments: [
{ emphasis: 'open', escaping: 'none', nodes: range, text: spelling }, { emphasis: 'open', escaping: 'none', nodes: { ...range, depth }, text: spelling },
...carried, ...carried,
{ emphasis: 'close', escaping: 'none', nodes: range, text: spelling }, { emphasis: 'close', escaping: 'none', nodes: { ...range, depth }, text: spelling },
], ],
}) })
} }
function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRange, context: InlineContext): Result<Emission> {
const inner = emitRun(nodes, depth + 1, range.first, context)
if (!inner.ok || inner.value.carry !== undefined) return inner
const run = { ...range, depth }
return success({
segments: [{ escaping: 'none', highlight: 'open', nodes: run, text: highlightDelimiter }, ...inner.value.segments, { escaping: 'none', highlight: 'close', nodes: run, text: highlightDelimiter }],
})
}
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> { function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
let text = '' let text = ''
for (const node of nodes) { for (const node of nodes) {
@@ -298,10 +332,9 @@ function needsPadding(text: string): boolean {
return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text) return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text)
} }
// `undefined` where the link takes the directive form the caller spells. function tryLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result<Emission> | undefined {
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range: NodeRange, context: InlineContext): Result<Emission> | undefined { const href = linkHref(nodeAttrs(mark))
const href = nodeAttrs(mark)['href'] if (href === undefined) return success({ carry: range })
if (typeof href !== 'string') return success({ carry: range })
const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective const opening = depth === 0 && range.first === 0 && context.openingLinkAsDirective
const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes, depth + 1, context.bracketed) const commonMark = opening ? undefined : commonMarkLink(nodeAttrs(mark), href, nodes, depth + 1, context.bracketed)
if (commonMark === undefined) return undefined if (commonMark === undefined) return undefined
+81 -48
View File
@@ -1,28 +1,32 @@
import { backtickRun, closingBacktickRun } from '../backtick-runs.ts' import type { Flavour } from '../plain-conventions.ts'
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../emphasis-matching.ts' import type { LineContainer } from '../line-container.ts'
import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark-grammar.ts' import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark/grammar.ts'
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
import { claimsDirectivePrefix } from '../directive-syntax.ts' import { claimsDirectivePrefix } from '../directive-syntax.ts'
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
import { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts'
import { isBareDelimiterRow } from '../pipe-table-syntax.ts' import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
import { opensLinkDefinition } from '../link-reference-definitions.ts' import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
import { readEntityReference } from '../entity-references.ts' import { readEntityReference } from '../commonmark/entity-references.ts'
export type EmphasisRole = 'close' | 'open' export type DelimiterRole = 'close' | 'open'
export type InlineEscaping = 'backslash' | 'bracketed' | 'bracketed-link-target' | 'none' export type InlineEscaping = 'backslash' | 'bracketed' | 'bracketed-link-target' | 'none'
export type NodeRange = { first: number; last: number } export type NodeRange = { first: number; last: number }
export type InlineSegment = export type MarkRun = NodeRange & { depth: number }
| { emphasis: EmphasisRole; escaping: 'none'; nodes: NodeRange; text: string }
| { emphasis?: undefined; escaping: 'none'; nodes: NodeRange; text: string }
| { emphasis?: undefined; escaping: InlineEscaping; nodes?: undefined; text: string }
export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRun: NodeRange | undefined } export type InlineSegment =
| { emphasis: DelimiterRole; escaping: 'none'; highlight?: undefined; nodes: MarkRun; text: string }
| { emphasis?: undefined; escaping: 'none'; highlight: DelimiterRole; nodes: MarkRun; text: string }
| { emphasis?: undefined; escaping: 'none'; highlight?: undefined; nodes: NodeRange; text: string }
| { emphasis?: undefined; escaping: InlineEscaping; highlight?: undefined; nodes?: undefined; text: string }
export type AssembledLine = { line: string; openingLinkAsDirective?: true; unspellableRuns: MarkRun[] }
type ScanLine = { position: LinePosition; start: number; text: string } type ScanLine = { position: LinePosition; start: number; text: string }
export type LineContainer = 'heading' | 'paragraph' | 'table-cell'
type EmittedDelimiter = { closes: boolean; offset: number; pair: number; width: number } type EmittedDelimiter = { closes: boolean; offset: number; pair: number; width: number }
type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; delimiters: EmittedDelimiter[]; length: number; start: number } type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; delimiters: EmittedDelimiter[]; length: number; start: number }
@@ -31,8 +35,8 @@ const delimiters = ['*', '_', '`', '~']
const followsLinkText = /[([]/ const followsLinkText = /[([]/
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine { export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): AssembledLine {
return escape(resolveEmphasis(segments), container) return escape(resolveEmphasis(segments), container, flavour === 'plain')
} }
function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] { function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
@@ -64,11 +68,11 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] {
return resolved return resolved
} }
function escape(segments: readonly InlineSegment[], container: LineContainer): AssembledLine { function escape(segments: readonly InlineSegment[], container: LineContainer, highlights: boolean): AssembledLine {
const scan = segments.map((segment) => segment.text).join('') const scan = segments.map((segment) => segment.text).join('')
const escapings: InlineEscaping[] = [] const escapings: InlineEscaping[] = []
for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) escapings.push(segment.escaping) for (const segment of segments) for (let index = 0; index < segment.text.length; index += 1) escapings.push(segment.escaping)
const escaped = escapedIndexes(scan, escapings, container) const escaped = escapeClosedRuns(scan, escapings, escapeClaims(scan, escapings, container, highlights))
const placements: number[] = [] const placements: number[] = []
let output = '' let output = ''
for (let index = 0; index < scan.length; index += 1) { for (let index = 0; index < scan.length; index += 1) {
@@ -77,37 +81,48 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A
output += scan.charAt(index) output += scan.charAt(index)
} }
if (container === 'paragraph' && opensLinkDefinition(output)) { if (container === 'paragraph' && opensLinkDefinition(output)) {
if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRun: undefined } if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRuns: [] }
return { line: `\\${output}`, unspellableRun: unspellableRun(segments, output, placements) } return { line: `\\${output}`, unspellableRuns: unspellableRuns(segments, output, placements) }
} }
return { line: output, unspellableRun: unspellableRun(segments, output, placements) } return { line: output, unspellableRuns: unspellableRuns(segments, output, placements) }
} }
function escapedIndexes(scan: string, escapings: readonly InlineEscaping[], container: LineContainer): Set<number> { function escapeClaims(scan: string, escapings: readonly InlineEscaping[], container: LineContainer, highlights: boolean): ReadonlySet<number> {
const escaped = new Set<number>() const escaped = new Set<number>()
const linkClose = lastLinkClose(scan, escapings) const linkClose = lastLinkClose(scan, escapings)
let line = scanLine(scan, 0) let line = scanLine(scan, 0)
let afterEscape = false
// Whether the `=` before opens a `==` the reader takes whole, so this one starts nothing.
let pairsEquals = false
for (let index = 0; index < scan.length; index += 1) { for (let index = 0; index < scan.length; index += 1) {
if (index > line.start + line.text.length) line = scanLine(scan, line.start + line.text.length + 1) if (index > line.start + line.text.length) line = scanLine(scan, line.start + line.text.length + 1)
const escaping = escapings[index] const escaping = escapings[index]
const escapable = escaping === 'backslash' || escaping === 'bracketed' const escapable = escaping === 'backslash' || escaping === 'bracketed'
if ( const opensEquals: boolean = highlights && !pairsEquals && scan.startsWith(highlightDelimiter, index)
const claimed: boolean =
(escapable && (escapable &&
(claimsLineStart(line, index, container) || ((opensEquals && claimsHighlight(scan, index)) ||
claimsLineStart(line, index, container) ||
mergesWithSyntax(scan, escapings, index) || mergesWithSyntax(scan, escapings, index) ||
opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))) || opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, afterEscape))) ||
(escaping === 'bracketed-link-target' && (escaping === 'bracketed-link-target' &&
((scan.charAt(index) === '`' && opensCodeSpan(scan, index, escaped)) || claimsDirectivePrefix(scan, index))) ((scan.charAt(index) === '`' && opensCodeSpan(scan, index, afterEscape)) || claimsDirectivePrefix(scan, index)))
) { if (claimed) escaped.add(index)
escaped.add(index) afterEscape = claimed
} pairsEquals = opensEquals && !claimed
} }
escapeClosedRuns(scan, escapings, escaped)
return escaped return escaped
} }
// Like an emphasis run, a `==` in text escapes where the reader can open or close with it.
function claimsHighlight(scan: string, index: number): boolean {
const flanking = highlightFlanking(scan, index)
return flanking.opens || flanking.closes
}
// CommonMark reads no escape inside a code span, so a backtick string an escape forms or splits off still closes one an earlier bare run opens. // CommonMark reads no escape inside a code span, so a backtick string an escape forms or splits off still closes one an earlier bare run opens.
function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], escaped: Set<number>): void { function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], claimed: ReadonlySet<number>): ReadonlySet<number> {
const escaped = new Set(claimed)
const formed = new Set<number>() const formed = new Set<number>()
let end = scan.length - 1 let end = scan.length - 1
while (end >= 0) { while (end >= 0) {
@@ -119,23 +134,41 @@ function escapeClosedRuns(scan: string, escapings: readonly InlineEscaping[], es
while (scan.charAt(start - 1) === '`') start -= 1 while (scan.charAt(start - 1) === '`') start -= 1
let segmentEnd = end let segmentEnd = end
for (let index = end; index > start; index -= 1) { for (let index = end; index > start; index -= 1) {
if (!escaped.has(index)) continue if (!claimed.has(index)) continue
formed.add(segmentEnd - index + 1) formed.add(segmentEnd - index + 1)
segmentEnd = index - 1 segmentEnd = index - 1
} }
if (segmentEnd !== end || escaped.has(start)) formed.add(segmentEnd - start + 1) if (segmentEnd !== end || claimed.has(start)) formed.add(segmentEnd - start + 1)
else if (escapings[start] !== 'none' && formed.has(end - start + 1)) { else if (escapings[start] !== 'none' && formed.has(end - start + 1)) {
for (let index = start; index <= end; index += 1) escaped.add(index) for (let index = start; index <= end; index += 1) escaped.add(index)
formed.add(1) formed.add(1)
} }
end = start - 1 end = start - 1
} }
return escaped
} }
function unspellableRun(segments: readonly InlineSegment[], output: string, placements: readonly number[]): NodeRange | undefined { // One emphasis run, the innermost, or every highlight run the line cannot spell.
function unspellableRuns(segments: readonly InlineSegment[], output: string, placements: readonly number[]): MarkRun[] {
const { nodes, runs } = emittedRuns(segments, placements, output) const { nodes, runs } = emittedRuns(segments, placements, output)
const pair = misflanked(runs) ?? unpaired(runs) const pair = misflanked(runs) ?? unpaired(runs)
return pair === undefined ? undefined : nodes[pair] const run = pair === undefined ? undefined : nodes[pair]
return run === undefined ? unreadHighlights(segments, output, placements) : [run]
}
// No `==` in text can open or close, and highlights never nest, so a pair reads back where each delimiter flanks.
function unreadHighlights(segments: readonly InlineSegment[], output: string, placements: readonly number[]): MarkRun[] {
const unread: MarkRun[] = []
let cursor = 0
for (const segment of segments) {
const start = placements[cursor] ?? 0
cursor += segment.text.length
if (segment.highlight === undefined) continue
const flanking = highlightFlanking(output, start)
const flanks = segment.highlight === 'open' ? flanking.opens : flanking.closes
if (!flanks && unread.at(-1) !== segment.nodes) unread.push(segment.nodes)
}
return unread
} }
function misflanked(runs: readonly EmittedRun[]): number | undefined { function misflanked(runs: readonly EmittedRun[]): number | undefined {
@@ -168,9 +201,9 @@ function delimiterAt(run: EmittedRun, closes: boolean, offset: number, width: nu
return run.delimiters.find((delimiter) => delimiter.closes === closes && delimiter.offset === offset && delimiter.width === width) return run.delimiters.find((delimiter) => delimiter.closes === closes && delimiter.offset === offset && delimiter.width === width)
} }
function emittedRuns(segments: readonly InlineSegment[], placements: readonly number[], output: string): { nodes: NodeRange[]; runs: EmittedRun[] } { function emittedRuns(segments: readonly InlineSegment[], placements: readonly number[], output: string): { nodes: MarkRun[]; runs: EmittedRun[] } {
const runs: EmittedRun[] = [] const runs: EmittedRun[] = []
const nodes: NodeRange[] = [] const nodes: MarkRun[] = []
const open: number[] = [] const open: number[] = []
let cursor = 0 let cursor = 0
for (const segment of segments) { for (const segment of segments) {
@@ -232,10 +265,10 @@ function opensConstruct(
index: number, index: number,
inBrackets: boolean, inBrackets: boolean,
container: LineContainer, container: LineContainer,
escaped: ReadonlySet<number>, afterEscape: boolean,
): boolean { ): boolean {
if (container === 'heading' && closesHeading(scan, index)) return true if (container === 'heading' && closesHeading(scan, index)) return true
return claimsCharacter(scan, linkClose, index, inBrackets, container, escaped) return claimsCharacter(scan, linkClose, index, inBrackets, container, afterEscape)
} }
// A hard break is the one spelling that puts a delimiter row under a row of its own, so only a later line claims. // A hard break is the one spelling that puts a delimiter row under a row of its own, so only a later line claims.
@@ -261,7 +294,7 @@ function claimsCharacter(
index: number, index: number,
inBrackets: boolean, inBrackets: boolean,
container: LineContainer, container: LineContainer,
escaped: ReadonlySet<number>, afterEscape: boolean,
): boolean { ): boolean {
const character = scan.charAt(index) const character = scan.charAt(index)
if (inBrackets && (character === '[' || character === ']')) return true if (inBrackets && (character === '[' || character === ']')) return true
@@ -271,8 +304,8 @@ function claimsCharacter(
if (character === '<') return opensBracketedAutolink(scan, index) || opensEmailAutolink(scan, index) || inlineHtmlConstruct(scan, index) !== undefined if (character === '<') return opensBracketedAutolink(scan, index) || opensEmailAutolink(scan, index) || inlineHtmlConstruct(scan, index) !== undefined
if (character === '!') return claimsDirectivePrefix(scan, index) if (character === '!') return claimsDirectivePrefix(scan, index)
if (character === '[') return index < linkClose if (character === '[') return index < linkClose
if (character === '`') return opensCodeSpan(scan, index, escaped) if (character === '`') return opensCodeSpan(scan, index, afterEscape)
if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, escaped) if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, afterEscape)
return false return false
} }
@@ -285,16 +318,16 @@ function lastLinkClose(scan: string, escapings: readonly (InlineEscaping | undef
return -1 return -1
} }
function opensCodeSpan(scan: string, index: number, escaped: ReadonlySet<number>): boolean { function opensCodeSpan(scan: string, index: number, afterEscape: boolean): boolean {
// A run escapes whole: a rest left bare would be a raw run of another length for a closer. // A run escapes whole: a rest left bare would be a raw run of another length for a closer.
if (scan.charAt(index - 1) === '`' && escaped.has(index - 1)) return true if (afterEscape && scan.charAt(index - 1) === '`') return true
if (!startsRun(scan, index, escaped)) return false if (!startsRun(scan, index, afterEscape)) return false
const opener = backtickRun(scan, index) const opener = backtickRun(scan, index)
return closingBacktickRun(scan, index + opener, opener) !== undefined return closingBacktickRun(scan, index + opener, opener) !== undefined
} }
function claimsEmphasis(scan: string, index: number, escaped: ReadonlySet<number>): boolean { function claimsEmphasis(scan: string, index: number, afterEscape: boolean): boolean {
if (!startsRun(scan, index, escaped)) return false if (!startsRun(scan, index, afterEscape)) return false
const character = scan.charAt(index) const character = scan.charAt(index)
const length = runLength(scan, index) const length = runLength(scan, index)
if (character === '~' && length !== 2) return false if (character === '~' && length !== 2) return false
@@ -302,8 +335,8 @@ function claimsEmphasis(scan: string, index: number, escaped: ReadonlySet<number
return flags.canClose || flags.canOpen return flags.canClose || flags.canOpen
} }
function startsRun(scan: string, index: number, escaped: ReadonlySet<number>): boolean { function startsRun(scan: string, index: number, afterEscape: boolean): boolean {
if (index === 0 || escaped.has(index - 1)) return true if (index === 0 || afterEscape) return true
return scan.charAt(index - 1) !== scan.charAt(index) return scan.charAt(index - 1) !== scan.charAt(index)
} }
+4 -3
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 { Flavour } from '../plain-conventions.ts'
import { carriesOnly, nodeContent } from '../../adf/document.ts' import { carriesOnly, nodeContent } from '../../adf/document.ts'
import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts' import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
import { tryPipeCell } from './inline-line.ts' import { tryPipeCell } from './inline-line.ts'
import type { ConvertErrorPath } from '../../result.ts'
export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | undefined { export function tryPipeTable(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): string | undefined {
const rows = pipeRows(node) const rows = pipeRows(node)
if (rows === undefined) return undefined if (rows === undefined) return undefined
const lines: string[] = [] const lines: string[] = []
@@ -12,7 +13,7 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
const cells: string[] = [] const cells: string[] = []
for (const [cellIndex, paragraph] of row.entries()) { for (const [cellIndex, paragraph] of row.entries()) {
const content = nodeContent(paragraph) const content = nodeContent(paragraph)
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0]) const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], flavour)
if (line === undefined) return undefined if (line === undefined) return undefined
cells.push(line) cells.push(line)
} }
+295
View File
@@ -0,0 +1,295 @@
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
import type { LineContainer } from '../line-container.ts'
import type { MarkRun } from './line-escaping.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { largestNesting } from '../../nesting.ts'
import { mergeAdjacentText, sameMark } from '../../adf/editor-normal.ts'
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { plainLineFallback, type PlainLineFallback } from './inline-line.ts'
import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
const highlight = 'backgroundColor'
const highlightMark: AdfMark = { type: highlight }
const edgeStrippingMarks: readonly string[] = [highlight, 'em', 'strike', 'strong']
const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link']
export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
const leaves = inlineLeaves(nodes, container, path, depth)
if (!leaves.ok) return leaves
return spellableLine(trimmedEdges(highlighted(trimmedEdges(leaves.value))), container, path)
}
export function isBlockNodeType(type: string): boolean {
return blockNodeModel(type) !== undefined || type === 'blockCard' || type === 'embedCard'
}
export function inlineLeaves(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
const leaves: AdfNode[] = []
let joinsNext = false
for (const [index, node] of nodes.entries()) {
const held = nodeLeaves(node, container, [...path, 'content', index], depth)
if (!held.ok) return held
if (held.value.length === 0) continue
const block = isBlockNodeType(node.type) && node.type !== 'media'
if (leaves.length > 0 && (joinsNext || block)) leaves.push(textLeaf(' ', []))
joinsNext = block
for (const leaf of held.value) leaves.push(leaf)
}
return success(leaves)
}
function nodeLeaves(node: AdfNode, container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
const marks = nodeMarks(node)
const attrs = nodeAttrs(node)
if (node.type === 'text') return success(textLeaves(node.text, marks, container))
if (node.type === 'hardBreak') return success([lineBreak(container)])
if (node.type === 'date') return success(textLeaves(isoDate(attrs['timestamp']), marks, container))
if (node.type === 'emoji') return success(textLeaves(nonEmpty(attrs['text']) ?? attrs['shortName'], marks, container))
if (node.type === 'placeholder') return success([])
if (node.type === 'mention') return success(textLeaves(nonEmpty(attrs['text']) ?? idMention(attrs['id']), marks, container))
if (node.type === 'status') return success(textLeaves(attrs['text'], marks, container))
if (['extension', 'inlineExtension'].includes(node.type)) return success(textLeaves(nonEmpty(attrs['text']), marks, container, noteName(attrs['extensionKey']) ?? 'extension'))
if (node.type === 'syncBlock') return success(noteLeaves('synced block'))
if (['media', 'mediaInline'].includes(node.type)) return success(mediaLeaves(attrs, marks, container))
if (['blockCard', 'embedCard', 'inlineCard'].includes(node.type)) return success(cardLeaves(attrs, marks, container))
const own = textLeaves(node.text ?? (['expand', 'nestedExpand'].includes(node.type) ? attrs['title'] : undefined), marks, container)
const held = inlineLeaves(nodeContent(node), container, path, depth + 1)
if (!held.ok) return held
return success(own.length > 0 && held.value.length > 0 ? [...own, textLeaf(' ', []), ...held.value] : [...own, ...held.value])
}
function cardLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
const data = attrs['data']
const held = typeof data === 'object' && data !== null && !Array.isArray(data) ? data : {}
const url = nonEmpty(attrs['url'])
const heldUrl = nonEmpty(held['url'])
const name = nonEmpty(held['name'])
const href = url ?? heldUrl
if (href === undefined) return textLeaves(name, marks, container, 'link card')
return linkedLeaves(url ?? name ?? href, href, marks, container)
}
function linkedLeaves(text: string, href: string, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
return textLeaves(text, [...marks.filter((mark) => mark.type !== 'link'), { attrs: { href }, type: 'link' }], container)
}
function idMention(id: unknown): string | undefined {
return typeof id === 'string' && id !== '' ? `@${id}` : undefined
}
// An image standing inline is a link to it: CommonMark's inline image reads back as no node.
function mediaLeaves(attrs: Readonly<AdfAttributes>, marks: readonly AdfMark[], container: LineContainer): AdfNode[] {
const alt = nonEmpty(attrs['alt'])
const url = nonEmpty(attrs['url'])
if (attrs['type'] !== 'external' || url === undefined) return textLeaves(alt, marks, container, 'image')
return linkedLeaves(alt ?? url, url, marks, container)
}
function noteLeaves(name: string): AdfNode[] {
return [textLeaf(`(${name} not included)`, [{ type: 'em' }])]
}
function noteName(value: unknown): string | undefined {
return nonEmpty(value) === undefined ? undefined : oneLine(String(value)).trim()
}
export function oneLine(text: string): string {
return text.replace(/[\r\u0000]/g, '').replace(/\n/g, ' ')
}
function nonEmpty(value: unknown): string | undefined {
return typeof value === 'string' && oneLine(value).trim() !== '' ? value : undefined
}
function isoDate(timestamp: unknown): string | undefined {
const milliseconds = typeof timestamp === 'string' && /^-?\d+$/.test(timestamp) ? Number(timestamp) : Number.NaN
const date = new Date(milliseconds)
if (Number.isNaN(date.getTime())) return undefined
const iso = date.toISOString()
return iso.slice(0, iso.indexOf('T'))
}
function lineBreak(container: LineContainer): AdfNode {
return container === 'paragraph' ? { type: 'hardBreak' } : textLeaf(' ', [])
}
function textLeaves(value: unknown, marks: readonly AdfMark[], container: LineContainer, note?: string): AdfNode[] {
if (typeof value !== 'string') return note === undefined ? [] : noteLeaves(note)
const text = value.replace(/[\r\u0000]/g, '')
const kept = plainMarks(marks, container, text)
const leaves: AdfNode[] = []
for (const [index, line] of text.split('\n').entries()) {
if (index > 0) leaves.push(lineBreak(container))
if (line !== '') leaves.push(textLeaf(line, kept))
}
return leaves
}
function textLeaf(text: string, marks: readonly AdfMark[]): AdfNode {
return marks.length === 0 ? { text, type: 'text' } : { marks: [...marks], text, type: 'text' }
}
// A highlight goes first, where `highlighted` looks for it, and code last, the only place its spelling holds.
function plainMarks(marks: readonly AdfMark[], container: LineContainer, text: string): AdfMark[] {
const kept: AdfMark[] = []
for (const mark of marks) {
if (!keptMarks.includes(mark.type) || kept.some((held) => held.type === mark.type)) continue
if (mark.type === 'code' && container === 'table-cell' && text.includes('|')) continue
const plain = mark.type === 'link' ? plainLink(mark, container) : { type: mark.type }
if (plain !== undefined) kept.push(plain)
}
const rank = (mark: AdfMark): number => (mark.type === highlight ? 0 : mark.type === 'code' ? 2 : 1)
// The reader highlights no code, as Atlassian's schema allows none.
const code = kept.some((mark) => mark.type === 'code')
return kept.filter((mark) => !code || mark.type !== highlight).sort((first, second) => rank(first) - rank(second))
}
function plainLink(mark: AdfMark, container: LineContainer): AdfMark | undefined {
const attrs = nodeAttrs(mark)
const held = attrs['href']
const title = typeof attrs['title'] === 'string' ? attrs['title'].replace(/\r/g, '').replace(/\n/g, ' ') : undefined
if (typeof held !== 'string') return undefined
const href = writableHref(container === 'table-cell' ? held.replaceAll('|', '%7C') : held)
if (title === undefined || spellLinkTarget(href, title) === undefined || (container === 'table-cell' && title.includes('|'))) return { attrs: { href }, type: 'link' }
return { attrs: { href, title }, type: 'link' }
}
// spec/flavour.md, Links: the characters no destination spelling holds, then an ampersand an entity reference would read.
export function writableHref(href: string): string {
let written = href
for (const unwritable of [/[\u0000-\u001f\u007f\\<>]/g, /&/g]) {
if (spellDestination(written) !== undefined) return written
written = written.replace(unwritable, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`)
}
return written
}
// The marks a whole highlight run shares go outside the highlight, so its delimiters open and close inside them.
function highlighted(leaves: readonly AdfNode[]): AdfNode[] {
const spelled: AdfNode[] = []
let run: AdfNode[] = []
let shared: AdfMark[] = []
for (const leaf of [...leaves, { type: 'hardBreak' }]) {
const marks = nodeMarks(leaf)
if (marks[0]?.type === highlight) {
const held = marks.slice(1)
shared = run.length === 0 ? held : shared.filter((mark) => held.some((other) => sameMark(other, mark)))
run.push(leaf)
continue
}
for (const held of run) spelled.push(withMarks(held, [...shared, highlightMark, ...nodeMarks(held).slice(1).filter((mark) => !shared.some((other) => sameMark(other, mark)))]))
run = []
spelled.push(leaf)
}
return spelled.slice(0, -1)
}
function withMarks(leaf: AdfNode, marks: readonly AdfMark[]): AdfNode {
const { marks: _, ...unmarked } = leaf
return marks.length === 0 ? unmarked : { ...unmarked, marks: [...marks] }
}
function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] {
for (let current = leaves; ; ) {
const merged = withoutEdgeBreaks(mergeAdjacentText(current))
let changed = false
const trimmed: AdfNode[] = []
for (const [index, leaf] of merged.entries()) {
const edges = leafEdges(leaf, merged[index - 1], merged[index + 1])
if (edges === undefined) {
trimmed.push(leaf)
continue
}
changed = true
for (const edge of edges) trimmed.push(edge)
}
if (!changed) return merged
current = trimmed
}
}
function withoutEdgeBreaks(leaves: readonly AdfNode[]): AdfNode[] {
let first = 0
let last = leaves.length - 1
while (leaves[first]?.type === 'hardBreak') first += 1
while (last >= first && leaves[last]?.type === 'hardBreak') last -= 1
return leaves.slice(first, last + 1)
}
// spec/flavour.md, Inline nodes: edge whitespace leaves every stripping mark opening or closing beside it, and goes at a line edge.
function leafEdges(leaf: AdfNode, previous: AdfNode | undefined, next: AdfNode | undefined): AdfNode[] | undefined {
const marks = nodeMarks(leaf)
const text = leaf.text
if (text === undefined || marks.some((mark) => mark.type === 'code')) return undefined
const lead = text.slice(0, text.search(/[^ \t]|$/))
const trail = text.slice(text.search(/[ \t]*$/))
const leadDepth = edgeDepth(marks, previous, lead)
const trailDepth = edgeDepth(marks, next, trail)
if (leadDepth === marks.length && trailDepth === marks.length) return undefined
if (lead === text) return leadDepth === undefined || trailDepth === undefined ? [] : [textLeaf(text, marks.slice(0, Math.min(leadDepth, trailDepth)))]
const edges: AdfNode[] = []
if (lead !== '' && leadDepth !== undefined) edges.push(textLeaf(lead, marks.slice(0, leadDepth)))
const core = text.slice(lead.length, text.length - trail.length)
if (core !== '') edges.push(textLeaf(core, marks))
if (trail !== '' && trailDepth !== undefined) edges.push(textLeaf(trail, marks.slice(0, trailDepth)))
return edges
}
function edgeDepth(marks: readonly AdfMark[], neighbour: AdfNode | undefined, whitespace: string): number | undefined {
if (whitespace === '') return marks.length
const lineEdge = neighbour === undefined || neighbour.type === 'hardBreak'
const neighbourMarks = lineEdge ? [] : nodeMarks(neighbour)
let shared = 0
while (shared < marks.length && sameMarkAt(marks, neighbourMarks, shared)) shared += 1
const stripping = marks.findIndex((mark, index) => index >= shared && edgeStrippingMarks.includes(mark.type))
const kept = stripping === -1 ? marks.length : stripping
return kept === 0 && lineEdge ? undefined : kept
}
function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index: number): boolean {
const mark = marks[index]
const other = others[index]
return mark !== undefined && other !== undefined && sameMark(mark, other)
}
function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<AdfNode[]> {
for (let current = leaves; ; ) {
const fallback = plainLineFallback(current, container, path)
if (!fallback.ok) return fallback
if (fallback.value === undefined) return success(current)
const fixed = withoutFallback(current, fallback.value)
if (fixed === undefined) return failure('unsupported-node-shape', 'a plain line keeps a spelling that dropping a mark does not change', path)
current = trimmedEdges(fixed)
}
}
function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback): AdfNode[] | undefined {
if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, fallback.runs)
const first = fallback.kind === 'opening-link' ? 0 : lineStart(leaves, fallback.line)
const mark = nodeMarks(leaves[first] ?? {})[0]
if (mark === undefined || mark.type !== (fallback.kind === 'opening-link' ? 'link' : 'code')) return undefined
let last = first
while (sameMarkAt(nodeMarks(leaves[last + 1] ?? {}), [mark], 0)) last += 1
// A code span is what binds the `]` a link definition reads, and dropping it keeps the link target.
const spans = leaves.slice(first, last + 1).some((leaf) => nodeMarks(leaf).length > 1 && nodeMarks(leaf).at(-1)?.type === 'code')
if (mark.type === 'link' && spans) return leaves.map((leaf, index) => (index < first || index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code'))))
return withoutMarks(leaves, [{ depth: 0, first, last }])
}
function lineStart(leaves: readonly AdfNode[], line: number): number {
let index = 0
for (let breaks = 0; breaks < line && index < leaves.length; index += 1) if (leaves[index]?.type === 'hardBreak') breaks += 1
return index
}
// The runs cover disjoint leaves, so one pass drops them all.
function withoutMarks(leaves: readonly AdfNode[], runs: readonly MarkRun[]): AdfNode[] {
const depths = new Map<number, number>()
for (const run of runs) for (let index = run.first; index <= run.last; index += 1) depths.set(index, run.depth)
return leaves.map((leaf, index) => {
const depth = depths.get(index)
return depth === undefined ? leaf : withMarks(leaf, nodeMarks(leaf).filter((_, held) => held !== depth))
})
}
+339
View File
@@ -0,0 +1,339 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import { adfToPlainMarkdown, reduceToPlain } from './plain-reduction.ts'
import { largestNesting } from '../../nesting.ts'
const code: AdfMark = { type: 'code' }
const em: AdfMark = { type: 'em' }
const strong: AdfMark = { type: 'strong' }
function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
function plain(...content: AdfNode[]): string {
return plainDocument(document(...content))
}
function plainDocument(input: AdfDocument): string {
const markdown = adfToPlainMarkdown(input)
return markdown.ok ? markdown.value : `${markdown.error.code} at /${markdown.error.path.join('/')}`
}
function text(value: string, ...marks: AdfMark[]): AdfNode {
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
}
function node(type: string, attrs: AdfAttributes, ...content: AdfNode[]): AdfNode {
return { attrs, content, type }
}
function paragraph(...content: AdfNode[]): AdfNode {
return { content, type: 'paragraph' }
}
function said(value: string): AdfNode {
return paragraph(text(value))
}
function item(...content: AdfNode[]): AdfNode {
return { content, type: 'listItem' }
}
function bulletList(...content: AdfNode[]): AdfNode {
return { content, type: 'bulletList' }
}
function cell(type: string, ...content: AdfNode[]): AdfNode {
return { content, type }
}
function row(...content: AdfNode[]): AdfNode {
return { content, type: 'tableRow' }
}
function link(href: string, title?: string): AdfMark {
return { attrs: title === undefined ? { href } : { href, title }, type: 'link' }
}
test('refuses what the document guard refuses, and nothing else', () => {
assert.equal(plainDocument({ type: 'doc', version: Number.NaN }), 'not-an-adf-document at /')
assert.equal(plainDocument({ type: 'doc', version: 2 }), 'unsupported-document-version at /')
let deep: AdfNode = said('x')
for (let level = 0; level <= largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' }
assert.match(plainDocument(document(deep)), /^unsupported-nesting-depth at \/content\/0(\/content\/0)+$/)
let deepInline: AdfNode = text('x')
for (let level = 0; level <= largestNesting; level += 1) deepInline = { content: [deepInline], type: 'unknownInline' }
assert.match(plainDocument(document(paragraph(deepInline))), /^unsupported-nesting-depth at /)
})
test('refuses nesting past 500 levels wherever the reduction walks', () => {
const lowest = (bottom: AdfNode): string => {
let deep = bottom
for (let level = 0; level < largestNesting; level += 1) deep = { content: [deep], type: 'layoutColumn' }
return plainDocument(document(deep)).split(' ')[0] ?? ''
}
const wrapped: AdfNode = { content: [text('x')], type: 'unknownInline' }
const bottoms: AdfNode[] = [
bulletList(item(said('x'))),
node('taskList', {}, node('taskList', {}, node('taskItem', {}, text('x')))),
node('taskList', {}, node('taskItem', {}, wrapped)),
node('taskList', {}, node('blockTaskItem', {}, said('x'))),
node('panel', {}, said('x')),
node('expand', {}, said('x')),
node('decisionList', {}, node('decisionItem', {}, text('x'))),
node('table', {}, row(cell('tableCell', said('x')))),
node('mediaSingle', {}, node('caption', {}, text('x'))),
node('heading', { level: 1 }, wrapped),
node('codeBlock', {}, wrapped),
paragraph(wrapped),
]
for (const bottom of bottoms) assert.equal(lowest(bottom), 'unsupported-nesting-depth', bottom.type)
})
test('spells a panel as an alert in the GitHub word for its colour', () => {
const panel = (panelType: string | undefined): string =>
plain(node('panel', panelType === undefined ? {} : { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType }, said('Check it.')))
assert.equal(panel('info'), '> [!NOTE]\n>\n> Check it.\n')
assert.equal(panel('note'), '> [!IMPORTANT]\n>\n> Check it.\n')
assert.equal(panel('tip'), '> [!TIP]\n>\n> Check it.\n')
assert.equal(panel('success'), '> [!TIP]\n>\n> Check it.\n')
assert.equal(panel('warning'), '> [!WARNING]\n>\n> Check it.\n')
assert.equal(panel('error'), '> [!CAUTION]\n>\n> Check it.\n')
assert.equal(panel('custom'), '> [!NOTE]\n>\n> Check it.\n')
assert.equal(panel(undefined), '> [!NOTE]\n>\n> Check it.\n')
assert.equal(plain(node('panel', { panelType: 'warning' })), '> [!WARNING]\n')
})
test('spells an expand and a nested expand as a folded callout titled by the marker line', () => {
const nested = node('nestedExpand', { title: 'Inner' }, said('Deep.'))
assert.equal(
plain(node('expand', { localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de', title: 'Build log' }, said('Line.'), nested)),
'> [!NOTE]- Build log\n>\n> Line.\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n',
)
assert.equal(plain(node('expand', {}, said('Line.'))), '> [!NOTE]-\n>\n> Line.\n')
assert.equal(plain(node('expand', { title: ' *Two*\nlines ' })), '> [!NOTE]- \\*Two\\* lines\n')
assert.equal(plain(node('expand', { title: '\ta \t b\t ' })), '> [!NOTE]- a \t b\n')
assert.equal(plain(node('expand', { title: '**x** [y](z) ==w==' }, said('b'))), '> [!NOTE]- \\*\\*x\\*\\* \\[y](z) ==w==\n>\n> b\n')
})
test('spells a task list as a bullet list whose items lead with their state', () => {
const task = (state: string, value: string): AdfNode => node('taskItem', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', state }, text(value))
const nested = node('taskList', {}, task('TODO', 'Review'))
assert.equal(plain(node('taskList', {}, task('DONE', 'Write the spec'), nested, task('TODO', 'Ship it'))), '- [x] Write the spec\n - [ ] Review\n- [ ] Ship it\n')
assert.equal(plain(node('taskList', {}, nested, task('DONE', ''), said('Stray'))), '- - [ ] Review\n- \\[x]\n- Stray\n')
assert.equal(plain(node('taskList', {}, node('taskList', {}), task('DONE', 'a'), said('b'), node('taskList', {}, task('TODO', 'c')))), '- \\[x] a\n- b\n - [ ] c\n')
assert.equal(plain(node('taskList', {}, paragraph(), task('DONE', 'a'))), '- [x] a\n')
assert.equal(plain(bulletList(item(said('x'))), node('taskList', {}, task('DONE', 'a'), node('taskList', {}, task('TODO', 'c')))), '- x\n- \\[x] a\n - [ ] c\n')
assert.equal(plain(node('taskList', {}), task('TODO', 'Loose')), 'Loose\n')
const blockTask = node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.'))
const codeTask = node('blockTaskItem', { state: 'TODO' }, { content: [text('x')], type: 'codeBlock' })
assert.equal(plain(node('taskList', {}, blockTask, codeTask)), '- [x] First.\n\n Second.\n- [ ]\n\n ```\n x\n ```\n')
const listTask = node('blockTaskItem', { state: 'DONE' }, bulletList(item(said('a'))))
assert.equal(plain(node('taskList', {}, task('TODO', ''), listTask, node('taskList', {}, task('TODO', 'b')))), '- [ ]\n- [x]\n - a\n - \\[ ] b\n')
assert.equal(plain(node('taskList', {}, task('DONE', 'a'), node('taskList', {}, task('TODO', '')))), '- [x] a\n - [ ]\n')
})
test('spells a decision list as a plain bullet list', () => {
assert.equal(plain(node('decisionList', {}, node('decisionItem', { state: 'DECIDED' }, text('Ship')), said('Stray'))), '- Ship\n- Stray\n')
})
test('spells a highlight as a == pair around the run, whatever its colour', () => {
const highlight = (color: string): AdfMark => ({ attrs: { color }, type: 'backgroundColor' })
assert.equal(plain(paragraph(text('a '), text('hi', highlight('#fff')), text(' there', highlight('#000')), text(' b'))), 'a ==hi there== b\n')
assert.equal(plain(paragraph(text('hi ', strong, highlight('#fff')), text('b'))), '**==hi==** b\n')
assert.equal(plain(paragraph(text('a', strong, highlight('#fff')), text('b', highlight('#fff'), em))), '==**a**_b_==\n')
assert.equal(plain(paragraph(text('a', highlight('#fff'), code), text('b', highlight('#fff')))), '`a`==b==\n')
assert.equal(plain(paragraph(text('=', highlight('#fff')), text(' '), text('a==b', highlight('#fff')))), '==\\=== ==a==b==\n')
assert.equal(plain(paragraph(text('x'), text('y', highlight('#fff')), text(' z'))), 'xy z\n')
assert.equal(plain(paragraph(text('この機能は'), text('日本語', highlight('#fff')), text('でのみ'))), 'この機能は==日本語==でのみ\n')
assert.equal(plain(paragraph(text('サーバー'), text('停止', highlight('#fff')), text('中 iPhone'), text('専用', highlight('#fff')), text('アプリ 기능은 '), text('한국어', highlight('#fff')), text('에서만'))), 'サーバー==停止==中 iPhone==専用==アプリ 기능은 ==한국어==에서만\n')
assert.equal(plain(paragraph(text('日==本==語'))), '日\\==本\\==語\n')
})
test('escapes text a renderer would take as a flavour marker, and only there', () => {
assert.equal(plain(said('==x== a == b a==b ===')), '\\==x\\== a == b a==b \\=\\==\n')
assert.equal(plain({ content: [said('[!NOTE] x'), said('[!TIP]')], type: 'blockquote' }), '> \\[!NOTE] x\n>\n> [!TIP]\n')
assert.equal(plain({ content: [said('[!NOTE]x')], type: 'blockquote' }, said('[!NOTE]')), '> [!NOTE]x\n\n[!NOTE]\n')
assert.equal(plain(bulletList(item(said('[x] a')), item(said('[ ]')))), '- \\[x] a\n- \\[ ]\n')
assert.equal(plain(bulletList(item(said('[x] a')), item(said('b'))), node('orderedList', { order: 1 }, item(said('[x] c')))), '- \\[x] a\n- b\n\n1. \\[x] c\n')
const task = (state: string, value: string): AdfNode => node('taskItem', { state }, text(value))
assert.equal(plain(node('taskList', {}, task('DONE', '[x] a'), task('TODO', '==b=='))), '- [x] [x] a\n- [ ] \\==b\\==\n')
})
test('unwraps the containers plain markdown has no spelling for to their body blocks in order', () => {
const column = (value: string): AdfNode => node('layoutColumn', { width: 50 }, said(value))
assert.equal(plain(node('layoutSection', {}, column('Left.'), column('Right.'))), 'Left.\n\nRight.\n')
assert.equal(plain(node('bodiedExtension', { extensionKey: 'k' }, said('Body.'))), 'Body.\n')
assert.equal(plain(node('bodiedSyncBlock', { resourceId: 'r' }, said('Synced.'))), 'Synced.\n')
const frame = (value: string): AdfNode => node('extensionFrame', {}, said(value))
assert.equal(plain(node('multiBodiedExtension', { extensionKey: 'k' }, frame('One.'), frame('Two.'))), 'One.\n\nTwo.\n')
})
test('keeps the CommonMark blocks in their spelling and drops their attributes and marks', () => {
const localId = { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05' }
assert.equal(plain(node('paragraph', localId, text('x')), node('heading', { level: 2, localId: '01a0d99b-1f57-7fec-94ae-50c2ee25c9de' }, text('h'))), 'x\n\n## h\n')
assert.equal(plain({ attrs: localId, content: [said('q')], marks: [{ type: 'breakout' }], type: 'blockquote' }), '> q\n')
assert.equal(plain(node('codeBlock', { language: 'ts', wrap: true }, text('a\r\nb\u0000'))), '```ts\na\nb\n```\n')
assert.equal(plain(node('codeBlock', { language: 'carry' }, text('a'), { type: 'hardBreak' }, text('b', strong))), '```\na\nb\n```\n')
assert.equal(plain(node('codeBlock', {})), '```\n```\n')
assert.equal(plain(node('rule', { color: '#000' })), '---\n')
assert.equal(plain(node('orderedList', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', order: 3 }, item(said('c')))), '3. c\n')
assert.equal(plain(node('orderedList', {}, item(said('a')))), '1. a\n')
assert.equal(plain(node('orderedList', { order: -1 }, item(said('a')))), '1. a\n')
const code: AdfNode = { content: [text('x')], type: 'codeBlock' }
assert.equal(plain(node('orderedList', { order: 1e10 }, item(said('Alpha')), item(code), item())), '- 10000000000. Alpha\n- 10000000001.\n\n ```\n x\n ```\n- 10000000002.\n')
const long = node('orderedList', { order: 1e10 }, item(said('y')))
assert.equal(plain(bulletList(item(said('x'))), long, bulletList(item(said('z')))), '- x\n- 10000000000. y\n- z\n')
assert.equal(plain(node('taskList', {}, node('taskItem', { state: 'DONE' }, text('t'))), long), '- \\[x] t\n- 10000000000. y\n')
assert.equal(plain(bulletList(item(said('a'), bulletList(item(said('x'))), long))), '- a\n - x\n - 10000000000. y\n')
const givesWay = node('orderedList', { order: 1e10 }, item(node('rule', {})), item(bulletList(item(bulletList(item())))))
assert.equal(plain(givesWay, bulletList(item(said('z')))), '- 10000000000.\n- 10000000001.\n - -\n- z\n')
assert.equal(plain(node('orderedList', { order: 999999999 }, item(said('a'))), node('orderedList', { order: 5 }, item(said('b')))), '- 999999999\\. a\n- 5\\. b\n')
assert.equal(plain(node('heading', { level: 7 }, text('h'))), 'h\n')
})
test('spells an inline node as its text', () => {
assert.equal(plain(paragraph(node('mention', { id: 'a', text: '@Mikael' }), text(' and '), node('status', { color: 'red', text: 'Blocked' }))), '@Mikael and Blocked\n')
assert.equal(plain(paragraph(node('emoji', { shortName: ':tada:', text: '🎉' }), node('emoji', { shortName: ':smile:' }))), '🎉:smile:\n')
assert.equal(plain(paragraph(node('date', { timestamp: '1757721600000' }), text(' '), node('date', { timestamp: 'soon' }))), '2025-09-13\n')
assert.equal(plain(paragraph({ marks: [strong], ...node('mention', { text: '@Mikael' }) })), '**@Mikael**\n')
assert.equal(plain(paragraph(text('by '), node('mention', { id: '5b10a2' }), node('mention', {}))), 'by @5b10a2\n')
})
test('spells a card as a link to its url, or to its data url named by its data name, else a note', () => {
assert.equal(plain(paragraph(node('inlineCard', { url: 'https://example.com' }))), '<https://example.com>\n')
assert.equal(plain(paragraph({ ...node('inlineCard', { url: 'https://example.com' }), marks: [strong, link('https://other.com')] })), '**<https://example.com>**\n')
assert.equal(plain(paragraph(text('see '), node('inlineCard', { data: {} }))), 'see _(link card not included)_\n')
assert.equal(plain(paragraph(node('inlineCard', { data: { name: 'Spec', url: 'https://e.com/s' } }), text(' '), node('inlineCard', { data: { url: 'https://e.com/u' } }))), '[Spec](https://e.com/s) <https://e.com/u>\n')
assert.equal(plain(paragraph(node('inlineCard', { data: { name: 'Spec' } }), text(' '), node('inlineCard', { data: ['x'] }))), 'Spec _(link card not included)_\n')
assert.equal(plain(node('blockCard', { url: 'https://example.com/a b' })), '[https://example.com/a b](<https://example.com/a b>)\n')
assert.equal(plain(node('embedCard', { layout: 'center', url: 'https://example.com' })), '<https://example.com>\n')
assert.equal(plain(node('blockCard', { data: {} })), '_(link card not included)_\n')
})
test('keeps an external image wherever it stands and spells a stored file as its alt text, else a note', () => {
const media = (attrs: AdfAttributes): AdfNode => ({ attrs, type: 'media' })
const caption: AdfNode = node('caption', {}, text('The moon.'))
const external = media({ alt: 'Moon', height: 10, type: 'external', url: 'https://example.com/moon.png' })
assert.equal(plain(node('mediaSingle', { layout: 'wide', width: 50 }, external, caption)), '![Moon](https://example.com/moon.png)\n\nThe moon.\n')
assert.equal(plain(node('mediaSingle', {}, media({ alt: '', type: 'external', url: 'https://example.com/a.png' }))), '![](https://example.com/a.png)\n')
assert.equal(plain(node('mediaSingle', {}, media({ alt: ' Two\nlines ', type: 'external', url: 'u' }), media({ type: 'external', url: 'v' }))), '![Two lines](u)\n\n![](v)\n')
assert.equal(plain(node('mediaSingle', {}, media({ alt: 'Bad', type: 'external', url: 'a\\b <&amp;>' }))), '![Bad](<a%5Cb %3C%26amp;%3E>)\n')
assert.equal(plain(node('mediaSingle', {}, media({ alt: 'Photo', collection: 'c', id: 'i', type: 'file' }))), 'Photo\n')
assert.equal(plain(node('mediaGroup', {}, media({ alt: 'One', type: 'file' }), media({ type: 'file' }), external)), 'One\n\n_(image not included)_\n\n![Moon](https://example.com/moon.png)\n')
assert.equal(plain(external), '![Moon](https://example.com/moon.png)\n')
assert.equal(plain(paragraph(text('a '), node('mediaInline', { alt: 'clip', type: 'file' }), text(' '), node('mediaInline', { type: 'file' }))), 'a clip _(image not included)_\n')
assert.equal(plain(paragraph(text('See '), external, text(' for '), node('mediaInline', { type: 'external', url: 'https://e.com/i.png' }))), 'See [Moon](https://example.com/moon.png) for <https://e.com/i.png>\n')
assert.equal(plain(caption), 'The moon.\n')
})
test('spells an extension as its text attribute, else a note naming it, and a placeholder as nothing', () => {
assert.equal(plain(node('extension', { extensionKey: 'toc', text: 'Contents' }), node('extension', { extensionKey: 'jira-issues-table' })), 'Contents\n\n_(jira-issues-table not included)_\n')
assert.equal(plain(node('syncBlock', { resourceId: 'r' })), '_(synced block not included)_\n')
assert.equal(plain(node('extension', { extensionKey: 'jira\r\nissues\u0000' })), '_(jira issues not included)_\n')
assert.equal(plain(node('extension', { extensionKey: '\r\u0000' }), node('extension', { extensionKey: '\n' })), '_(extension not included)_\n\n_(extension not included)_\n')
const blank = paragraph(node('inlineExtension', { extensionKey: 'k', text: '\r' }), text(' '), node('mediaInline', { alt: '\u0000', type: 'file' }), text(' '), node('mention', { id: '5b10a2', text: '\r' }))
assert.equal(plain(blank), '_(k not included)_ _(image not included)_ @5b10a2\n')
assert.equal(plain(paragraph(text('a '), node('inlineExtension', { text: 'macro' }), text(' '), node('inlineExtension', {}), node('placeholder', { text: 'Type here' }))), 'a macro _(extension not included)_\n')
})
test('spells a node no row names, or one standing where no spelling holds it, as its blocks or its text', () => {
assert.equal(plain(node('futureBlock', {}, said('Inside.'))), 'Inside.\n')
assert.equal(plain(paragraph(text('a '), { content: [text('b')], text: 'c', type: 'futureInline' })), 'a c b\n')
assert.equal(plain(text('loose'), node('mention', { text: '@x' }), node('listItem', {}, said('item'))), 'loose@x\n\nitem\n')
assert.equal(plain(paragraph(text('a '), node('bulletList', {}, item(said('b')), item(said('c'))))), 'a b c\n')
assert.equal(plain(bulletList(said('stray'), item(said('b')), text('loose'))), '- stray\n- b\n- loose\n')
assert.equal(plain(bulletList()), '')
assert.equal(plain(bulletList(item({ content: [text('a\n \nb')], type: 'codeBlock' }))), '- ```\n a\n\n b\n ```\n')
assert.equal(plain(bulletList(item({ content: [text(' ')], type: 'codeBlock' }))), '- ```\n ```\n')
assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {}), said('Install')), item(said('Configure')))), '- Install\n- Configure\n')
assert.equal(plain(bulletList(item(node('rule', {}), node('rule', {})), item(said('Configure')))), '-\n- Configure\n')
assert.equal(plain(bulletList(item(bulletList(item(bulletList(item())))))), '- -\n')
assert.equal(plain(node('nestedExpand', {}, node('tableCell', {}, said('c')))), '> [!NOTE]-\n>\n> c\n')
})
test('keeps a table as a pipe table headed by its first row, one line per cell', () => {
const table = node(
'table',
{ layout: 'wide' },
row(cell('tableCell', said('Part')), cell('tableCell', said('Qty'))),
row(cell('tableHeader', said('Bolt'), bulletList(item(said('M8')))), { attrs: { background: '#fff' }, content: [paragraph(text('4'), { type: 'hardBreak' }, text('0'))], type: 'tableCell' }),
)
assert.equal(plain(table), '| Part | Qty |\n| --- | --- |\n| Bolt M8 | 4 0 |\n')
const spanned = node(
'table',
{},
row(cell('tableHeader', said('A')), cell('tableHeader', said('B')), cell('tableHeader', said('C'))),
row(node('tableCell', { colspan: 2, rowspan: 2 }, said('wide')), cell('tableCell', said('c'))),
row(cell('tableCell', said('d'))),
row(cell('tableCell')),
)
assert.equal(plain(spanned), '| A | B | C |\n| --- | --- | --- |\n| wide | | c |\n| | | d |\n| | | |\n')
const huge = node('table', {}, row(node('tableHeader', { colspan: 1e9, rowspan: 1e9 }, said('A')), cell('tableHeader', said('B'))), row(cell('tableCell', said('c'))))
assert.equal(plain(huge), '| A | | | | B |\n| --- | --- | --- | --- | --- |\n| c | | | | |\n')
assert.equal(plain(node('table', {}, row(cell('tableHeader', paragraph(text('a|b', code), text(' '), text('x', link('https://e.com/|'))))))), '| a\\|b [x](https://e.com/%7C) |\n| --- |\n')
assert.equal(plain(node('table', {}, said('stray'))), '| stray |\n| --- |\n')
const titled = paragraph(text('t', link('https://e.com', 'a|b')))
const folded = [node('expand', { title: 'Log' }, said('x')), node('nestedExpand', {}, said('y'))]
assert.equal(plain(node('table', {}, row(cell('tableHeader', titled), cell('tableHeader', ...folded)))), '| [t](https://e.com) | Log x y |\n| --- | --- |\n')
assert.equal(plain(node('table', {}, row())), '')
})
test('keeps code, em, link, strike and strong and drops every other mark, keeping its text', () => {
const marks: AdfMark[] = [{ type: 'strike' }, { attrs: { type: 'sub' }, type: 'subsup' }, { type: 'underline' }, { attrs: { color: '#f00' }, type: 'textColor' }]
assert.equal(plain(paragraph(text('H'), text('2', ...marks), text('O', em, strong), text('!', { attrs: { size: 1 }, type: 'border' }))), 'H~~2~~_**O**_!\n')
assert.equal(plain(paragraph(text('x', code, strong), text(' '), text('y', { attrs: { x: 1 }, type: 'strong' }), text('z', em, em))), '**`x`** **y**_z_\n')
assert.equal(plain(paragraph(text('site', { attrs: { collection: 'c', href: 'https://e.com', id: 'i' }, type: 'link' }))), '[site](https://e.com)\n')
})
test('percent-encodes a link href no CommonMark escape writes until one does', () => {
assert.equal(plain(paragraph(text('a', link('a\\b')), text(' '), text('b', { attrs: { id: 'i' }, type: 'link' }), text(' '), text('c', link('/&amp;')))), '[a](a%5Cb) b [c](/%26amp;)\n')
assert.equal(plain(paragraph(text('t', link('https://e.com', 'two\nlines')), text(' '), text('u', link('https://e.com', 'a\\b')))), '[t](https://e.com "two lines") [u](https://e.com)\n')
assert.equal(plain(paragraph(text(']: a', link('/u'), code))), '[\\]: a](/u)\n')
})
test('drops the mark of a run CommonMark flanking or matching cannot spell', () => {
assert.equal(plain(paragraph(text('un'), text('-real', strong), text('istic'))), 'un-realistic\n')
assert.equal(plain(paragraph(text('a', em), text('b', strong), text('c', em))), '_a_**b**_c_\n')
assert.equal(plain(paragraph(text('x'), text('*', em), text('y'))), 'x\\*y\n')
const highlight: AdfMark = { type: 'backgroundColor' }
assert.equal(plain(paragraph(text('a'), text('b', highlight), text(' c'), text('d', highlight), text(' '), text('e', highlight))), 'ab cd ==e==\n')
})
test('breaks a line at a newline and trims whitespace at every edge CommonMark strips', () => {
assert.equal(plain(paragraph(text(' \n a \n b\n'))), 'a\\\nb\n')
assert.equal(plain(paragraph({ type: 'hardBreak' }, text('a'), { attrs: { text: '\n' }, type: 'hardBreak' }, text('b'), { type: 'hardBreak' })), 'a\\\nb\n')
assert.equal(plain(paragraph(text('a'), text(' b ', strong), text('c'))), 'a **b** c\n')
assert.equal(plain(paragraph(text(' '), text('b', strong), text(' \n'), text('c', strong), text(' '))), '**b**\\\n**c**\n')
assert.equal(plain(paragraph(text('a'), text(' b ', em, strong), text(' ', em), text('c', em))), 'a _**b** c_\n')
assert.equal(plain(paragraph(text(' x ', code))), '` x `\n')
assert.equal(plain(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n')
assert.equal(plain(paragraph(text('a\r\u0000b'))), 'ab\n')
})
test('drops the code mark of a span opening a line with backticks that read as a fence', () => {
assert.equal(plain(paragraph(text('``` x', code))), '\\`\\`\\` x\n')
assert.equal(plain(paragraph(text('a\n'), text('``` x', code))), 'a\\\n\\`\\`\\` x\n')
assert.equal(plain(paragraph(text('a '), text('``` x', code))), 'a ```` ``` x ````\n')
})
test('drops an empty paragraph and merges adjacent lists of one type', () => {
const ordered = (order: number, value: string): AdfNode => node('orderedList', { order }, item(said(value)))
assert.equal(plain(said('a'), paragraph(), paragraph(text(' ')), said('b')), 'a\n\nb\n')
assert.equal(plain(bulletList(item(said('a'))), paragraph(), node('decisionList', {}, node('decisionItem', {}, text('b')))), '- a\n- b\n')
assert.equal(plain(ordered(2, 'a'), ordered(3, 'b'), bulletList(item(said('c')))), '2. a\n3. b\n\n- c\n')
assert.equal(plain(bulletList(item(said('x'))), ordered(1, 'a'), ordered(5, 'b'), ordered(6, 'c')), '- x\n- 1\\. a\n- 5\\. b\n\n6. c\n')
assert.equal(plain(ordered(1, 'a'), ordered(1e10, 'y')), '- 1\\. a\n- 10000000000. y\n')
assert.equal(plain(node('taskList', {}, ordered(1e10, 'y')), bulletList(ordered(1e10, 'z'))), '- - 10000000000. y\n- - 10000000000. z\n')
const column = (list: AdfNode): AdfNode => node('layoutColumn', {}, list)
assert.equal(plain(node('layoutSection', {}, column(bulletList(item(said('a')))), column(bulletList(item(said('b')))))), '- a\n- b\n')
})
test('keeps the nodes the plain flavour spells and degrades only what it cannot', () => {
const tasks = node('taskList', {}, node('taskItem', { localId: '01a0d99b-1f58-7b95-829b-6f9860371d54', state: 'DONE' }, text('t')))
const reduced = reduceToPlain(document(node('panel', { localId: '01a0d99b-1f56-7a50-889a-f4375f09ee05', panelType: 'info' }, said('x')), tasks))
assert.deepEqual(reduced.ok ? reduced.value : undefined, document(node('panel', { panelType: 'info' }, said('x')), { content: [node('taskItem', { state: 'DONE' }, text('t'))], type: 'taskList' }))
})
+402
View File
@@ -0,0 +1,402 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from './adf-to-markdown.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './plain-inline.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts'
import { taskMarker } from '../plain-conventions.ts'
// depth: the level the node reduced stands at, counted as the emitter counts it.
type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
type BlockReducer = (node: AdfNode, reduction: Reduction) => Result<AdfNode[]>
type PlacedCell = { colspan: number; paragraph: AdfNode; rowspan: number }
type Placed = { index: number; loose: AdfNode[] } | { index: number; loose?: undefined; node: AdfNode }
const blockReducers: Readonly<Record<string, BlockReducer>> = {
blockCard: paragraphOfNode,
blockquote: (node, reduction) => contained({ type: 'blockquote' }, node, reduction),
bulletList: reduceList,
caption: (node, reduction) => paragraphOf(nodeContent(node), reduction),
codeBlock: reduceCodeBlock,
decisionList: (node, reduction) => reduceItems(node, reduction, (child, at) => (child.type === 'decisionItem' ? paragraphOf(nodeContent(child), at) : reduceStanding(child, at))),
embedCard: paragraphOfNode,
expand: reduceExpand,
extension: paragraphOfNode,
heading: reduceHeading,
media: reduceMedia,
mediaSingle: (node, reduction) => concatenated(nodeContent(node).map((child, index) => reduceStanding(child, childReduction(reduction, index)))),
nestedExpand: reduceExpand,
orderedList: reduceList,
panel: reducePanel,
paragraph: (node, reduction) => paragraphOf(nodeContent(node), reduction),
rule: () => success([{ type: 'rule' }]),
syncBlock: paragraphOfNode,
table: reduceTable,
taskList: reduceTaskList,
}
export function adfToPlainMarkdown(document: AdfDocument): Result<string> {
const reduced = reduceToPlain(document)
return reduced.ok ? writeMarkdown(reduced.value, 'plain') : reduced
}
export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
const blocks = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
return blocks.ok ? success({ content: blocks.value, type: 'doc', version: 1 }) : blocks
}
function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
const placed: Placed[] = []
for (const [index, node] of nodes.entries()) {
const previous = placed[placed.length - 1]
if (!standsInline(node)) placed.push({ index, node })
else if (previous?.loose !== undefined) previous.loose.push(node)
else placed.push({ index, loose: [node] })
}
const blocks = concatenated(
placed.map((entry) => {
const at = { ...reduction, path: [...reduction.path, 'content', entry.index] }
return entry.loose === undefined ? reduceNode(entry.node, at) : paragraphOf(entry.loose, reduction)
}),
)
return blocks.ok ? plainSequence(blocks.value, reduction) : blocks
}
function reduceNode(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
if (reduction.depth > largestNesting) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, reduction.path)
const reducer = Object.hasOwn(blockReducers, node.type) ? blockReducers[node.type] : undefined
return (reducer ?? reduceBody)(node, reduction)
}
function reduceStanding(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const blocks = standsInline(node) ? paragraphOf([node], reduction) : reduceNode(node, reduction)
return blocks.ok ? plainSequence(blocks.value, reduction) : blocks
}
function standsInline(node: AdfNode): boolean {
if (node.type === 'text' || inlineNodeModel(node.type) !== undefined) return true
return !isBlockNodeType(node.type) && node.content === undefined
}
function reduceBody(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
if (blockNodeModel(node.type)?.contentModel === 'inline') return paragraphOf(nodeContent(node), reduction)
return reduceBlocks(nodeContent(node), { ...reduction, depth: reduction.depth + 1 })
}
function childReduction(reduction: Reduction, index: number): Reduction {
return { ...reduction, depth: reduction.depth + 1, path: [...reduction.path, 'content', index] }
}
function concatenated(results: readonly Result<AdfNode[]>[]): Result<AdfNode[]> {
const blocks: AdfNode[] = []
for (const result of results) {
if (!result.ok) return result
for (const block of result.value) blocks.push(block)
}
return success(blocks)
}
// A list still taking the directive form gives way to its items' blocks.
function plainSequence(blocks: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
let sequence = mergedLists(blocks.filter((block) => block.type !== 'paragraph' || nodeContent(block).length > 0))
for (let index = 0; index < sequence.length; index += 1) {
const listed = sequence[index]
if (listed === undefined || (listed.type !== 'bulletList' && listed.type !== 'orderedList')) continue
const block = numberedPastMarkers(listed)
const spelled = block === listed && commonMarkSpelling(block, reduction.path, reduction.depth, { flavour: 'plain', memo: reduction.memo })?.ok === true
if (spelled) continue
sequence = spliced(sequence, index, block === listed ? nodeContent(block).flatMap(nodeContent) : [block])
index = Math.max(0, index - 1) - 1
}
return success(sequence)
}
// The replacement merges with the lists beside it, so no two lists of one type stand adjacent.
function spliced(sequence: readonly AdfNode[], index: number, replacement: readonly AdfNode[]): AdfNode[] {
const from = Math.max(0, index - 1)
return [...sequence.slice(0, from), ...mergedLists([...sequence.slice(from, index), ...replacement, ...sequence.slice(index + 1, index + 2)]), ...sequence.slice(index + 2)]
}
// A numbered list whose markers run past CommonMark's keeps its numbers as text in a bullet list.
function numberedPastMarkers(list: AdfNode): AdfNode {
const order = nodeAttrs(list)['order']
if (list.type !== 'orderedList' || typeof order !== 'number' || order + nodeContent(list).length - 1 <= largestListMarker) return list
return numberedAsText(list)
}
function numberedAsText(list: AdfNode): AdfNode {
const order = Number(nodeAttrs(list)['order'])
return { content: nodeContent(list).map((item, offset) => itemOf(marked(nodeContent(item), `${order + offset}.`))), type: 'bulletList' }
}
// Adjacent lists of one marker read back as one list.
function mergedLists(blocks: readonly AdfNode[]): AdfNode[] {
const merged: AdfNode[] = []
for (const block of blocks) {
let next = block
for (let previous = merged.at(-1); previous !== undefined && listMarker(next) !== undefined && listMarker(previous) === listMarker(next); previous = merged.at(-1)) {
merged.pop()
next = joinedLists(previous, next)
}
merged.push(next)
}
return merged
}
function listMarker(block: AdfNode): string | undefined {
if (block.type === 'orderedList') return '.'
return block.type === 'bulletList' || block.type === 'taskList' ? '-' : undefined
}
// Two numbered lists whose numbering breaks between them keep their numbers as text in one bullet list, and a task list joining a bullet list its markers.
function joinedLists(first: AdfNode, second: AdfNode): AdfNode {
const breaks = first.type === 'orderedList' && nodeAttrs(second)['order'] !== Number(nodeAttrs(first)['order']) + nodeContent(first).length
const [head, tail] = breaks ? [numberedAsText(first), numberedAsText(second)] : first.type === second.type ? [first, second] : [tasksAsText(first), tasksAsText(second)]
return { ...head, content: [...nodeContent(head), ...nodeContent(tail)] }
}
// A task keeps its marker as text; a list item stands as one, and anything else nests in the item before it.
function tasksAsText(list: AdfNode): AdfNode {
if (list.type !== 'taskList') return list
const items: AdfNode[] = []
for (const child of nodeContent(list)) {
const previous = isTask(child) || child.type === 'listItem' ? undefined : items.pop()
items.push(itemOf(previous === undefined ? taskAsText(child) : mergedLists([...nodeContent(previous), child])))
}
return { content: items, type: 'bulletList' }
}
function taskAsText(child: AdfNode): readonly AdfNode[] {
const marker = taskMarker(nodeAttrs(child)['state'])
if (child.type === 'taskItem') return [paragraph(nodeContent(child).length === 0 ? [text(marker)] : [text(`${marker} `), ...nodeContent(child)])]
if (child.type === 'blockTaskItem') return marked(nodeContent(child), marker)
return child.type === 'listItem' ? nodeContent(child) : [child]
}
function paragraph(content: readonly AdfNode[]): AdfNode {
return { content: [...content], type: 'paragraph' }
}
function text(value: string): AdfNode {
return { text: value, type: 'text' }
}
function listOf(items: readonly AdfNode[], type: string): AdfNode[] {
return items.length === 0 ? [] : [{ content: [...items], type }]
}
function paragraphOf(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
const content = reduceInline(nodes, 'paragraph', reduction.path, reduction.depth)
return content.ok ? success(content.value.length === 0 ? [] : [paragraph(content.value)]) : content
}
function paragraphOfNode(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
return paragraphOf([node], reduction)
}
function contained(shell: AdfNode, node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const content = reduceBlocks(nodeContent(node), { ...reduction, depth: reduction.depth + 1 })
return content.ok ? success([{ ...shell, content: content.value }]) : content
}
function reducePanel(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const panelType = nodeAttrs(node)['panelType']
return contained(typeof panelType === 'string' ? { attrs: { panelType }, type: 'panel' } : { type: 'panel' }, node, reduction)
}
function reduceExpand(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const held = nodeAttrs(node)['title']
const title = typeof held === 'string' ? withoutTrailingBlanks(oneLine(held).replace(/^[ \t]+/, '')) : ''
return contained(title === '' ? { type: node.type } : { attrs: { title }, type: node.type }, node, reduction)
}
// A backward scan: an unanchored-end regex retries from every blank in a long run.
function withoutTrailingBlanks(text: string): string {
let end = text.length
while (end > 0 && (text.charAt(end - 1) === ' ' || text.charAt(end - 1) === '\t')) end -= 1
return text.slice(0, end)
}
function reduceHeading(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const level = nodeAttrs(node)['level']
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return paragraphOf(nodeContent(node), reduction)
const content = reduceInline(nodeContent(node), 'heading', reduction.path, reduction.depth)
return content.ok ? success([{ attrs: { level }, content: content.value, type: 'heading' }]) : content
}
function reduceCodeBlock(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const leaves = inlineLeaves(nodeContent(node), 'paragraph', reduction.path, reduction.depth)
if (!leaves.ok) return leaves
const code = leaves.value.map((leaf) => leaf.text ?? '\n').join('')
const slot = languageSlot(nodeAttrs(node)['language'])
const block: AdfNode = { content: code === '' ? [] : [text(code)], type: 'codeBlock' }
return success([slot.kind === 'fence' ? { ...block, attrs: { language: slot.info } } : block])
}
function reduceList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const listed = reduceItems(node, reduction, (child, at) => (child.type === 'listItem' ? reduceBlocks(nodeContent(child), at) : reduceStanding(child, at)))
if (!listed.ok || node.type !== 'orderedList') return listed
const order = nodeAttrs(node)['order']
const start = typeof order === 'number' && Number.isInteger(order) && order >= 0 ? order : 1
return success(listed.value.map((list) => ({ ...list, attrs: { order: start } })))
}
function reduceItems(node: AdfNode, reduction: Reduction, itemBlocks: (child: AdfNode, at: Reduction) => Result<AdfNode[]>): Result<AdfNode[]> {
const items = concatenated(nodeContent(node).map((child, index) => listItem(itemBlocks(child, childReduction(reduction, index)))))
return items.ok ? success(listOf(items.value, node.type === 'orderedList' ? 'orderedList' : 'bulletList')) : items
}
function listItem(blocks: Result<AdfNode[]>): Result<AdfNode[]> {
return blocks.ok ? success([itemOf(blocks.value)]) : blocks
}
// A list item's first line reads as no rule and holds no line of spaces alone: the rule and the spaces give way.
function itemOf(blocks: readonly AdfNode[]): AdfNode {
const rules = blocks.findIndex((block) => block.type !== 'rule')
return { content: blankedCode(blocks.slice(rules === -1 ? blocks.length : rules)), type: 'listItem' }
}
function blankedCode(blocks: readonly AdfNode[]): AdfNode[] {
return blocks.map((block) => (block.type === 'codeBlock' ? { ...block, content: blankedLines(nodeContent(block)) } : block))
}
function blankedLines(code: readonly AdfNode[]): AdfNode[] {
const blanked = code.map((leaf) => leaf.text ?? '').join('').replace(/^[ \t]+$/gm, '')
return blanked === '' ? [] : [text(blanked)]
}
// A task list opening with a task and holding tasks and task lists alone keeps its spelling, a list nesting in the task before it; any other keeps its markers as text. A child reducing to nothing counts for neither.
function reduceTaskList(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const kept: { blocks: AdfNode[]; child: AdfNode }[] = []
for (const [index, child] of nodeContent(node).entries()) {
const at = childReduction(reduction, index)
const reduced = isTask(child) ? reduceTask(child, at) : reduceStanding(child, at)
if (!reduced.ok) return reduced
if (reduced.value.length > 0) kept.push({ blocks: reduced.value, child })
}
const regular = isTask(kept[0]?.child) && kept.every(({ child }) => isTask(child) || child.type === 'taskList')
const tasks: AdfNode[] = []
let nested: AdfNode[] = []
for (const { blocks, child } of kept) {
if (!regular) {
const standsAlone = !isTask(child) && child.type !== 'taskList'
for (const block of standsAlone ? [{ content: blocks, type: 'listItem' }] : blocks) tasks.push(block)
continue
}
if (isTask(child)) {
nestIn(tasks, nested)
nested = []
}
for (const block of blocks) (isTask(child) ? tasks : nested).push(block)
}
nestIn(tasks, nested)
if (regular) return success([{ content: tasks, type: 'taskList' }])
return success(listOf(nodeContent(tasksAsText({ content: tasks, type: 'taskList' })), 'bulletList'))
}
// The writer nests a list in the task before it, so one closing a block task item's blocks merges with it.
function nestIn(tasks: AdfNode[], nested: readonly AdfNode[]): void {
const previous = tasks.at(-1)
if (previous?.type === 'blockTaskItem') tasks[tasks.length - 1] = { ...previous, content: mergedLists([...nodeContent(previous), ...nested]) }
else for (const block of mergedLists(nested)) tasks.push(block)
}
function isTask(node: AdfNode | undefined): boolean {
return node?.type === 'taskItem' || node?.type === 'blockTaskItem'
}
function reduceTask(task: AdfNode, at: Reduction): Result<AdfNode[]> {
const attrs = { state: nodeAttrs(task)['state'] === 'DONE' ? 'DONE' : 'TODO' }
if (task.type === 'taskItem') {
const content = reduceInline(nodeContent(task), 'paragraph', at.path, at.depth)
return content.ok ? success([{ attrs, content: content.value, type: 'taskItem' }]) : content
}
const blocks = reduceBlocks(nodeContent(task), at)
return blocks.ok ? success([{ attrs, content: blankedCode(blocks.value), type: 'blockTaskItem' }]) : blocks
}
// The marker leads the first paragraph, or stands as one where the blocks open with another.
function marked(blocks: readonly AdfNode[], marker: string): AdfNode[] {
const [first, ...rest] = blocks
if (first?.type === 'paragraph') return [paragraph([text(`${marker} `), ...nodeContent(first)]), ...rest]
return [paragraph([text(marker)]), ...blocks]
}
function reduceTable(node: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const rows: PlacedCell[][] = []
for (const [rowIndex, row] of nodeContent(node).entries()) {
const rowReduction = childReduction(reduction, rowIndex)
const cells: PlacedCell[] = []
for (const [cellIndex, cell] of (row.type === 'tableRow' ? nodeContent(row) : [row]).entries()) {
const paragraph = cellParagraph(cell, childReduction(rowReduction, cellIndex))
if (!paragraph.ok) return paragraph
cells.push({ colspan: span(nodeAttrs(cell)['colspan']), paragraph: paragraph.value, rowspan: span(nodeAttrs(cell)['rowspan']) })
}
rows.push(cells)
}
const grid = spannedGrid(rows)
const width = grid.reduce((widest, cells) => Math.max(widest, cells.length), 0)
const tableRows = grid.map((cells, rowIndex) => ({
content: Array.from({ length: width }, (_, column): AdfNode => ({ content: [cells[column] ?? { type: 'paragraph' }], type: rowIndex === 0 ? 'tableHeader' : 'tableCell' })),
type: 'tableRow',
}))
return success(width === 0 ? [] : [{ content: tableRows, type: 'table' }])
}
function span(value: unknown): number {
return typeof value === 'number' && Number.isInteger(value) && value > 1 ? value : 1
}
// A span keeps its cell under its header by empty cells where it covered; they number no more than the table's cells.
function spannedGrid(rows: readonly PlacedCell[][]): (AdfNode | undefined)[][] {
const grid: (AdfNode | undefined)[][] = rows.map(() => [])
const covered = rows.map(() => new Set<number>())
let padding = rows.reduce((count, cells) => count + cells.length, 0)
for (const [rowIndex, cells] of rows.entries()) {
let column = 0
for (const cell of cells) {
while (covered[rowIndex]?.has(column) === true) column += 1
setCell(grid, rowIndex, column, cell.paragraph)
for (let row = rowIndex; row < Math.min(rows.length, rowIndex + cell.rowspan) && padding > 0; row += 1) {
for (let spanned = row === rowIndex ? 1 : 0; spanned < cell.colspan && padding > 0; spanned += 1) {
covered[row]?.add(column + spanned)
setCell(grid, row, column + spanned, { type: 'paragraph' })
padding -= 1
}
}
column += 1
}
}
return grid
}
function setCell(grid: (AdfNode | undefined)[][], row: number, column: number, cell: AdfNode): void {
const cells = grid[row]
if (cells !== undefined && cells[column] === undefined) cells[column] = cell
}
function cellParagraph(cell: AdfNode, reduction: Reduction): Result<AdfNode> {
const blocks = cell.type === 'tableCell' || cell.type === 'tableHeader' ? nodeContent(cell) : [cell]
const content = reduceInline(blocks, 'table-cell', reduction.path, reduction.depth)
return content.ok ? success(content.value.length === 0 ? { type: 'paragraph' } : paragraph(content.value)) : content
}
function reduceMedia(media: AdfNode, reduction: Reduction): Result<AdfNode[]> {
const attrs = nodeAttrs(media)
const url = attrs['url']
if (attrs['type'] !== 'external' || typeof url !== 'string') return paragraphOfNode(media, reduction)
const held = attrs['alt']
const alt = typeof held === 'string' ? oneLine(held).trim() : ''
const external: AdfNode = { attrs: alt === '' ? { type: 'external', url: writableHref(url) } : { alt, type: 'external', url: writableHref(url) }, type: 'media' }
const image: AdfNode = { attrs: { layout: 'center' }, content: [external], type: 'mediaSingle' }
return success([image])
}
+1
View File
@@ -0,0 +1 @@
export type LineContainer = 'heading' | 'paragraph' | 'table-cell'
-5
View File
@@ -1,5 +0,0 @@
import { spellDirectiveOpener } from './directive-syntax.ts'
export const listBreakName = 'listBreak'
export const listBreakSpelling = spellDirectiveOpener(listBreakName, undefined, '')
+8 -3
View File
@@ -1,10 +1,10 @@
import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts' import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts'
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import type { MarkType } from '../adf/mark-attributes.ts' import type { MarkType } from '../adf/mark-attributes.ts'
import { escapeUnbalanced, spellLinkTarget } from './link-syntax.ts' import { escapeUnbalanced, spellLinkTarget } from './commonmark/link-syntax.ts'
import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive-syntax.ts' import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive-syntax.ts'
import { holdsEntityReference } from './entity-references.ts' import { holdsEntityReference } from './commonmark/entity-references.ts'
import { isAutolink } from './commonmark-grammar.ts' import { isAutolink } from './commonmark/grammar.ts'
import { isMarkType, markAttributes } from '../adf/mark-attributes.ts' import { isMarkType, markAttributes } from '../adf/mark-attributes.ts'
import { nodeAttrs, nodeMarks } from '../adf/document.ts' import { nodeAttrs, nodeMarks } from '../adf/document.ts'
import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts'
@@ -35,6 +35,11 @@ export function markSpelling(type: string): MarkSpelling | undefined {
return { attributes, kind: spelling.kind } return { attributes, kind: spelling.kind }
} }
export function linkHref(attrs: AdfAttributes): string | undefined {
const href = attrs['href']
return typeof href === 'string' ? href : undefined
}
// spec/flavour.md, Marks. `marksInside` counts the marks the link's nodes carry within it: the emitter's depth + 1, the parser's 0. // spec/flavour.md, Marks. `marksInside` counts the marks the link's nodes carry within it: the emitter's depth + 1, the parser's 0.
export function commonMarkLink(attrs: AdfAttributes, href: string, nodes: readonly AdfNode[], marksInside: number, bracketed: boolean): CommonMarkLink | undefined { export function commonMarkLink(attrs: AdfAttributes, href: string, nodes: readonly AdfNode[], marksInside: number, bracketed: boolean): CommonMarkLink | undefined {
if (Object.keys(attrs).some((key) => key !== 'href' && key !== 'title')) return undefined if (Object.keys(attrs).some((key) => key !== 'href' && key !== 'title')) return undefined
+1 -1
View File
@@ -2,9 +2,9 @@ import type { AdfNode } from '../adf/document.ts'
import type { DirectiveSpan, Read } from './directive-syntax.ts' import type { DirectiveSpan, Read } from './directive-syntax.ts'
import type { JsonSpelling } from '../canonical-json.ts' import type { JsonSpelling } from '../canonical-json.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
import { fencedCodeBlock } from './commonmark/backtick-runs.ts'
import { isAdfNode } from '../adf/document.ts' import { isAdfNode } from '../adf/document.ts'
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts' import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
import { fencedCodeBlock } from './backtick-runs.ts'
import { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
import { malformedDirective, readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' import { malformedDirective, readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
import { serializeCanonicalJson } from '../canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
+1 -1
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { Block } from './blocks.ts' import type { Block } from './blocks.ts'
import type { LinkDefinition } from '../link-syntax.ts' import type { LinkDefinition } from '../commonmark/link-syntax.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
function definitions(markdown: string): [string, LinkDefinition][] { function definitions(markdown: string): [string, LinkDefinition][] {
+55 -40
View File
@@ -1,6 +1,6 @@
import type { ConvertFault, SourcePosition } from '../../result.ts' import type { ConvertFault, SourcePosition } from '../../result.ts'
import type { DirectiveAttributes, DirectiveLine } from '../directive-syntax.ts' import type { DirectiveAttributes, DirectiveLine } from '../directive-syntax.ts'
import type { LinkDefinition } from '../link-syntax.ts' import type { LinkDefinition } from '../commonmark/link-syntax.ts'
import { import {
atxHeading, atxHeading,
claimsPipeLine, claimsPipeLine,
@@ -17,11 +17,11 @@ import {
setextHeadingLevel, setextHeadingLevel,
thematicBreakTail, thematicBreakTail,
type ThematicBreakTail, type ThematicBreakTail,
} from '../commonmark-grammar.ts' } from '../commonmark/grammar.ts'
import { blockDirectiveForm } from '../block-directive-forms.ts'
import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts'
import { barePipeCells, isDelimiterRow, isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts' import { barePipeCells, isDelimiterRow, isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
import { readLinkDefinitions } from '../link-reference-definitions.ts' import { blockDirectiveForm } from '../block-directive.ts'
import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts'
import { readLinkDefinitions } from '../commonmark/link-reference-definitions.ts'
export type Block = { position: SourcePosition } & ( export type Block = { position: SourcePosition } & (
| { argument: string | undefined; attributes: DirectiveAttributes; blocks: Block[] | undefined; kind: 'directive'; name: string } | { argument: string | undefined; attributes: DirectiveAttributes; blocks: Block[] | undefined; kind: 'directive'; name: string }
@@ -43,7 +43,7 @@ export type DirectiveBlock = Extract<Block, { kind: 'directive' }>
type ListBlock = Extract<Block, { items: Block[][] }> type ListBlock = Extract<Block, { items: Block[][] }>
type OpenDirective = { blocks: Block[]; depths: number[]; index: number; kind: 'directive'; name: string; parent: Block[]; position: SourcePosition } type OpenDirective = { blocks: Block[]; index: number; kind: 'directive'; name: string; parent: Block[]; position: SourcePosition }
type EdgeContainer = Extract<Block, { kind: 'blockquote' }> | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock } type EdgeContainer = Extract<Block, { kind: 'blockquote' }> | { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock }
@@ -64,13 +64,19 @@ type Line = { column: number; text: string }
type LeafOpener = { index: number; position: SourcePosition } type LeafOpener = { index: number; position: SourcePosition }
type ContainerStack = {
directiveDepth: (name: string) => number | undefined
drop: (depth: number) => OpenContainer[]
edges: readonly { container: EdgeContainer; depth: number }[]
open: readonly OpenContainer[]
push: (container: OpenContainer) => void
}
type Walk = ParsedBlocks & { type Walk = ParsedBlocks & {
directiveDepths: Map<string, number[]>
edges: { container: EdgeContainer; depth: number }[]
leaf: OpenLeaf | undefined leaf: OpenLeaf | undefined
leafOpeners: Map<Block[], Map<string, LeafOpener>> leafOpeners: Map<Block[], Map<string, LeafOpener>>
position: SourcePosition position: SourcePosition
stack: OpenContainer[] stack: ContainerStack
} }
const indentedCodeColumns = 4 const indentedCodeColumns = 4
@@ -81,12 +87,10 @@ export function parseBlocks(markdown: string): ParsedBlocks {
const walk: Walk = { const walk: Walk = {
blocks: [], blocks: [],
definitions: new Map(), definitions: new Map(),
directiveDepths: new Map(),
edges: [],
leaf: undefined, leaf: undefined,
leafOpeners: new Map(), leafOpeners: new Map(),
position: { line: 1, offset: 0 }, position: { line: 1, offset: 0 },
stack: [], stack: containerStack(),
} }
for (const line of sourceLines(markdown)) { for (const line of sourceLines(markdown)) {
walk.position = line.position walk.position = line.position
@@ -96,16 +100,41 @@ export function parseBlocks(markdown: string): ParsedBlocks {
return { blocks: walk.blocks, definitions: walk.definitions } return { blocks: walk.blocks, definitions: walk.definitions }
} }
function containerStack(): ContainerStack {
const directiveDepths = new Map<string, number[]>()
const depthsOf = (name: string): number[] => entryOf(directiveDepths, name, () => [])
const edges: { container: EdgeContainer; depth: number }[] = []
const open: OpenContainer[] = []
return {
directiveDepth: (name) => directiveDepths.get(name)?.at(-1),
drop: (depth) => {
const dropped = open.splice(depth)
for (const container of dropped) {
if (container.kind === 'directive') depthsOf(container.name).pop()
else edges.pop()
}
return dropped
},
edges,
open,
push: (container) => {
const depth = open.push(container) - 1
if (container.kind === 'directive') depthsOf(container.name).push(depth)
else edges.push({ container, depth })
},
}
}
function readLine(walk: Walk, line: Line): void { function readLine(walk: Walk, line: Line): void {
const matched = matchContainers(walk, line) const matched = matchContainers(walk, line)
// CommonMark: no container opens inside an open code or HTML block. // CommonMark: no container opens inside an open code or HTML block.
if (matched.depth === walk.stack.length && swallowsLines(walk.leaf)) { if (matched.depth === walk.stack.open.length && swallowsLines(walk.leaf)) {
readBlockLine(walk, matched.rest) readBlockLine(walk, matched.rest)
return return
} }
const paragraphOpen = matched.depth === walk.stack.length && walk.leaf?.kind === 'paragraph' const paragraphOpen = matched.depth === walk.stack.open.length && walk.leaf?.kind === 'paragraph'
const opened = openContainers(walk, matched.rest, paragraphOpen, matched.depth) const opened = openContainers(walk, matched.rest, paragraphOpen, matched.depth)
if (!opened.opened && matched.depth < walk.stack.length) { if (!opened.opened && matched.depth < walk.stack.open.length) {
if (continuesLazily(walk, opened.rest)) { if (continuesLazily(walk, opened.rest)) {
appendParagraph(walk, opened.rest.text) appendParagraph(walk, opened.rest.text)
return return
@@ -122,12 +151,12 @@ function swallowsLines(leaf: OpenLeaf | undefined): boolean {
// A directive container has no continuation marker, so every line continues it. // A directive container has no continuation marker, so every line continues it.
function matchContainers(walk: Walk, line: Line): { depth: number; rest: Line } { function matchContainers(walk: Walk, line: Line): { depth: number; rest: Line } {
let rest = line let rest = line
for (const { container, depth } of walk.edges) { for (const { container, depth } of walk.stack.edges) {
const next = continuesContainer(walk, container, rest) const next = continuesContainer(walk, container, rest)
if (next === undefined) return { depth, rest } if (next === undefined) return { depth, rest }
rest = next rest = next
} }
return { depth: walk.stack.length, rest } return { depth: walk.stack.open.length, rest }
} }
function continuesContainer(walk: Walk, container: EdgeContainer, line: Line): Line | undefined { function continuesContainer(walk: Walk, container: EdgeContainer, line: Line): Line | undefined {
@@ -145,7 +174,7 @@ function blockquoteRest(opener: Line): Line | undefined {
} }
function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: number): { opened: boolean; rest: Line } { function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: number): { opened: boolean; rest: Line } {
const unmatched = walk.stack[depth] const unmatched = walk.stack.open[depth]
const tail = thematicBreakTail(line.text) const tail = thematicBreakTail(line.text)
let opened = false let opened = false
let rest = line let rest = line
@@ -202,16 +231,12 @@ function openContainer(walk: Walk, start: ContainerStart): void {
if (start.kind === 'blockquote') { if (start.kind === 'blockquote') {
const blockquote: EdgeContainer = { blocks, kind: 'blockquote', position: walk.position } const blockquote: EdgeContainer = { blocks, kind: 'blockquote', position: walk.position }
currentBlocks(walk).push(blockquote) currentBlocks(walk).push(blockquote)
pushEdge(walk, blockquote) walk.stack.push(blockquote)
return return
} }
const list = openedList(walk, start) const list = openedList(walk, start)
list.items.push(blocks) list.items.push(blocks)
pushEdge(walk, { blocks, indentation: start.indentation, kind: 'item', list }) walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list })
}
function pushEdge(walk: Walk, container: EdgeContainer): void {
walk.edges.push({ container, depth: walk.stack.push(container) - 1 })
} }
// Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md). // Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md).
@@ -226,7 +251,7 @@ function openedList(walk: Walk, start: Extract<ContainerStart, { kind: 'item' }>
function closeContainers(walk: Walk, depth: number): void { function closeContainers(walk: Walk, depth: number): void {
closeLeaf(walk) closeLeaf(walk)
for (const container of dropContainers(walk, depth)) { for (const container of walk.stack.drop(depth)) {
if (container.kind !== 'directive') continue if (container.kind !== 'directive') continue
container.parent[container.index] = { container.parent[container.index] = {
fault: malformedDirective(`the ${container.name} container is unclosed: no ${spellDirectiveCloser(container.name)} follows inside the block holding it; ${directiveEscape}`), fault: malformedDirective(`the ${container.name} container is unclosed: no ${spellDirectiveCloser(container.name)} follows inside the block holding it; ${directiveEscape}`),
@@ -236,15 +261,6 @@ function closeContainers(walk: Walk, depth: number): void {
} }
} }
function dropContainers(walk: Walk, depth: number): OpenContainer[] {
const dropped = walk.stack.splice(depth)
for (const container of dropped) {
if (container.kind === 'directive') container.depths.pop()
else walk.edges.pop()
}
return dropped
}
function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void { function applyDirectiveLine(walk: Walk, directive: DirectiveLine): void {
if (directive.kind === 'closer') closeDirective(walk, directive.name) if (directive.kind === 'closer') closeDirective(walk, directive.name)
else openDirective(walk, directive) else openDirective(walk, directive)
@@ -267,8 +283,7 @@ function openDirective(walk: Walk, directive: Extract<DirectiveLine, { kind: 'op
entryOf(walk.leafOpeners, parent, () => new Map<string, LeafOpener>()).set(name, { index, position }) entryOf(walk.leafOpeners, parent, () => new Map<string, LeafOpener>()).set(name, { index, position })
return return
} }
const depths = entryOf(walk.directiveDepths, name, (): number[] => []) walk.stack.push({ blocks: block.blocks, index, kind: 'directive', name, parent, position })
depths.push(walk.stack.push({ blocks: block.blocks, depths, index, kind: 'directive', name, parent, position }) - 1)
} }
function closeDirective(walk: Walk, name: string): void { function closeDirective(walk: Walk, name: string): void {
@@ -283,13 +298,13 @@ function closeDirective(walk: Walk, name: string): void {
return return
} }
closeContainers(walk, depth + 1) closeContainers(walk, depth + 1)
dropContainers(walk, depth) walk.stack.drop(depth)
} }
// A closer crosses no list item or blockquote edge. // A closer crosses no list item or blockquote edge.
function openDirectiveDepth(walk: Walk, name: string): number | undefined { function openDirectiveDepth(walk: Walk, name: string): number | undefined {
const depth = walk.directiveDepths.get(name)?.at(-1) const depth = walk.stack.directiveDepth(name)
return depth === undefined || depth < (walk.edges.at(-1)?.depth ?? -1) ? undefined : depth return depth === undefined || depth < (walk.stack.edges.at(-1)?.depth ?? -1) ? undefined : depth
} }
function faultLeafOpener(walk: Walk, name: string, fault: ConvertFault): void { function faultLeafOpener(walk: Walk, name: string, fault: ConvertFault): void {
@@ -497,7 +512,7 @@ function takeParagraph(walk: Walk): Extract<Block, { kind: 'paragraph' }> | unde
} }
function currentBlocks(walk: Walk): Block[] { function currentBlocks(walk: Walk): Block[] {
return walk.stack.at(-1)?.blocks ?? walk.blocks return walk.stack.open.at(-1)?.blocks ?? walk.blocks
} }
function* sourceLines(markdown: string): Generator<{ position: SourcePosition; text: string }> { function* sourceLines(markdown: string): Generator<{ position: SourcePosition; text: string }> {
+15 -17
View File
@@ -1,23 +1,21 @@
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
import type { BlockDirective } from '../../adf/block-directives.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import type { ConvertFault } from '../../result.ts' import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts' import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
import type { Elsewhere } from './directive-attributes.ts' import type { Elsewhere } from './directive-attributes.ts'
import { attributeNestingMessage, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { attributeNestingMessage, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts' import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
import { blockArgument } from '../block-directive-arguments.ts' import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts'
import { blockDirective } from '../../adf/block-directives.ts' import { blockNodeModel } from '../../adf/block-nodes.ts'
import { blockDirectiveForm } from '../block-directive-forms.ts'
import { carryName } from '../opaque-carry.ts' import { carryName } from '../opaque-carry.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineDirective } from '../../adf/inline-directives.ts'
import { inlineMarkSpellingFault } from './directive-marks.ts' import { inlineMarkSpellingFault } from './directive-marks.ts'
import { marksAttribute, readMarkValues } from '../block-directive-marks.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { readVocabulary } from './directive-attributes.ts' import { readVocabulary } from './directive-attributes.ts'
import { slotLineEndingFault } from '../directive-syntax.ts' import { slotLineEndingFault } from '../directive-syntax.ts'
import { textDirectiveName } from '../text-directive.ts' import { textDirectiveName } from '../text-directive.ts'
export type BlockDirectiveNode = { contentModel: BlockDirective['contentModel']; node: AdfNode } export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; node: AdfNode }
export function readBlockDirectiveNode( export function readBlockDirectiveNode(
name: string, name: string,
@@ -28,13 +26,13 @@ export function readBlockDirectiveNode(
if (name === carryName) { if (name === carryName) {
return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryName} fence`, path) return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryName} fence`, path)
} }
const directive = blockDirective(name) const model = blockNodeModel(name)
if (directive === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path) if (model === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path)
const argumentKey = blockArgument(name) const argumentKey = blockArgument(name)
const rest = new Map(attributes) const rest = new Map(attributes)
rest.delete(marksAttribute) rest.delete(marksAttribute)
const elsewhere: Elsewhere | undefined = argumentKey === undefined ? undefined : { key: argumentKey, slot: 'argument' } const elsewhere: Elsewhere | undefined = argumentKey === undefined ? undefined : { key: argumentKey, slot: 'argument' }
const attrs = readVocabulary(name, rest, directive.attributes, elsewhere, path) const attrs = readVocabulary(name, rest, model.attributes, elsewhere, path)
if (!attrs.ok) return attrs if (!attrs.ok) return attrs
if (argument !== undefined) { if (argument !== undefined) {
if (argumentKey === undefined) return failure('unsupported-node-shape', `${name} takes no argument: this one spells one`, path) if (argumentKey === undefined) return failure('unsupported-node-shape', `${name} takes no argument: this one spells one`, path)
@@ -43,7 +41,7 @@ export function readBlockDirectiveNode(
const spelled = attributes.get(marksAttribute) const spelled = attributes.get(marksAttribute)
const marks: Result<AdfMark[] | undefined> = spelled === undefined ? success(undefined) : readMarks(name, spelled, path) const marks: Result<AdfMark[] | undefined> = spelled === undefined ? success(undefined) : readMarks(name, spelled, path)
if (!marks.ok) return marks if (!marks.ok) return marks
return success({ contentModel: directive.contentModel, node: namedNode(name, attrs.value, marks.value) }) return success({ contentModel: model.contentModel, node: namedNode(name, attrs.value, marks.value) })
} }
export function readInlineDirectiveNode( export function readInlineDirectiveNode(
@@ -52,12 +50,12 @@ export function readInlineDirectiveNode(
content: readonly AdfNode[] | undefined, content: readonly AdfNode[] | undefined,
path: ConvertErrorPath, path: ConvertErrorPath,
): Result<AdfNode> { ): Result<AdfNode> {
const directive = inlineDirective(name) const model = inlineNodeModel(name)
if (directive === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name), path) if (model === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name), path)
const slot = directive.textAttribute const slot = model.textAttribute
if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path) if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path)
const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' } const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' }
const attrs = readVocabulary(name, attributes, directive.attributes, elsewhere, path) const attrs = readVocabulary(name, attributes, model.attributes, elsewhere, path)
if (!attrs.ok) return attrs if (!attrs.ok) return attrs
if (slot !== undefined && content !== undefined) { if (slot !== undefined && content !== undefined) {
const text = slotText(content) const text = slotText(content)
@@ -71,11 +69,11 @@ export function readInlineDirectiveNode(
return success(namedNode(name, attrs.value, undefined)) return success(namedNode(name, attrs.value, undefined))
} }
// A name the other position spells names that spelling, never the code a later MINOR may fill (AGENTS.md §8). // A name the other position spells names that spelling, never the code a later MINOR may fill (docs/decisions.md §Which code a cause takes).
function inlineSpellingFault(name: string): ConvertFault | undefined { function inlineSpellingFault(name: string): ConvertFault | undefined {
const mark = inlineMarkSpellingFault(name) const mark = inlineMarkSpellingFault(name)
if (mark !== undefined) return mark if (mark !== undefined) return mark
if (inlineDirective(name) === undefined && name !== textDirectiveName) return undefined if (inlineNodeModel(name) === undefined && name !== textDirectiveName) return undefined
return { code: 'unsupported-node-shape', message: `${name} takes the inline form, ${directivePrefix}${name}{…}, never the block form` } return { code: 'unsupported-node-shape', message: `${name} takes the inline form, ${directivePrefix}${name}{…}, never the block form` }
} }
+163 -47
View File
@@ -1,21 +1,23 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts' import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts'
import type { EmphasisPairing } from '../emphasis-matching.ts' import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
import type { LineContainer } from '../emit/line-escaping.ts' import type { Flavour } from '../plain-conventions.ts'
import type { LinkDefinition } from '../link-syntax.ts' import type { LineContainer } from '../line-container.ts'
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark-grammar.ts' import type { LinkDefinition } from '../commonmark/link-syntax.ts'
import { backtickRun, closingBacktickRun } from '../backtick-runs.ts' import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark/grammar.ts'
import { commonMarkLink } from '../mark-spellings.ts' import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts' import { commonMarkLink, linkHref } from '../mark-spellings.ts'
import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { inlineDirective } from '../../adf/inline-directives.ts' import { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts'
import { mergeAdjacentText } from '../../adf/editor-normal.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { mergeAdjacentText, sameMarks } from '../../adf/editor-normal.ts'
import { noSpans, readInlineDirective } from '../directive-syntax.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts' import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { normalizeLabel, readInlineTarget, readLabel } from '../link-syntax.ts' import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts'
import { openingLinkTakesDirective } from '../emit/inline-line.ts' import { openingLinkTakesDirective } from '../emit/inline-line.ts'
import { readCarriedInline } from '../opaque-carry.ts' import { readCarriedInline } from '../opaque-carry.ts'
import { readDirectiveMark } from './directive-marks.ts' import { readDirectiveMark } from './directive-marks.ts'
import { noSpans, readInlineDirective } from '../directive-syntax.ts'
import { readInlineDirectiveNode } from './directive-nodes.ts' import { readInlineDirectiveNode } from './directive-nodes.ts'
import { readTextDirective } from '../text-directive.ts' import { readTextDirective } from '../text-directive.ts'
@@ -25,6 +27,8 @@ export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number } type Bracket = { active: boolean; image: boolean; kind: 'open'; start: number }
type HighlightDelimiter = { closes: boolean; holder: AdfNode; index: number; line: number; opens: boolean; position: number }
type Pairing = EmphasisPairing<Run> type Pairing = EmphasisPairing<Run>
type Piece = type Piece =
@@ -33,13 +37,17 @@ type Piece =
| { alt: string; kind: 'image'; node: AdfNode } | { alt: string; kind: 'image'; node: AdfNode }
| { kind: 'nodes'; nodes: AdfNode[] } | { kind: 'nodes'; nodes: AdfNode[] }
| { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number } | { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number }
| { closes: boolean; kind: 'highlight'; opens: boolean }
type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number } type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number }
// `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`. // `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`.
type Scan = { type Scan = {
container: LineContainer | undefined container: LineContainer | undefined
// Pieces below this have been walked for openers to deactivate: an image close folds the link-marked piece into alt text, leaving this the only record that the brackets around it are doomed.
deactivatedBefore: number
definitions: LinkDefinitions definitions: LinkDefinitions
highlights: boolean
openingSpellableLink: boolean openingSpellableLink: boolean
path: ConvertErrorPath path: ConvertErrorPath
pending: string pending: string
@@ -51,27 +59,21 @@ type Scan = {
type SlotContent = { carry: boolean; nodes: AdfNode[] } type SlotContent = { carry: boolean; nodes: AdfNode[] }
const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included' const carriedInMark = 'no mark spelling wraps an opaque carry: the carried node restores exactly, marks included'
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
const hreflessLink = 'the link mark spells its href: this one spells none'
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content' const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one'
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>' const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<InlineContent> { export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result<InlineContent> {
return parseInline(source, definitions, path, container, noSpans) return parseInline(source, definitions, path, container, noSpans, flavour === 'plain')
} }
function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans): Result<InlineContent> { function parseInline(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer | undefined, spans: NestedSpans, highlights: boolean): Result<InlineContent> {
const scan: Scan = { container, definitions, openingSpellableLink: false, path, pending: '', pieces: [], source, spans } const scan: Scan = { container, deactivatedBefore: 0, definitions, highlights, openingSpellableLink: false, path, pending: '', pieces: [], source, spans }
let index = 0 let index = 0
while (index < source.length) { while (index < source.length) {
switch (source.charAt(index)) { switch (source.charAt(index)) {
case '\\':
index = readBackslash(scan, index)
break
case '\n':
index = readLineEnding(scan, index)
break
case '`':
index = readBackticks(scan, index)
break
case '<': { case '<': {
const angle = readAngle(scan, index) const angle = readAngle(scan, index)
if (!angle.ok) return angle if (!angle.ok) return angle
@@ -97,20 +99,34 @@ function parseInline(source: string, definitions: LinkDefinitions, path: Convert
index = closed.value index = closed.value
break break
} }
case '*':
case '_':
case '~':
index = readDelimiterRun(scan, index)
break
default: default:
scan.pending += source.charAt(index) index = readCharacter(scan, index)
index += 1
} }
} }
flush(scan, container !== undefined) flush(scan, container !== undefined)
return assemble(scan) return assemble(scan)
} }
function readCharacter(scan: Scan, index: number): number {
switch (scan.source.charAt(index)) {
case '\\':
return readBackslash(scan, index)
case '\n':
return readLineEnding(scan, index)
case '`':
return readBackticks(scan, index)
case '*':
case '_':
case '~':
return readDelimiterRun(scan, index)
case '=':
return readEquals(scan, index)
default:
scan.pending += scan.source.charAt(index)
return index + 1
}
}
function readBackslash(scan: Scan, index: number): number { function readBackslash(scan: Scan, index: number): number {
if (scan.source.charAt(index + 1) === '\n') { if (scan.source.charAt(index + 1) === '\n') {
// CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash. // CommonMark strips the spaces the two-space break is spelled with, and keeps those before a backslash.
@@ -208,15 +224,16 @@ function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotC
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path) return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path)
} }
if (slot.carry) return failure('unsupported-node-shape', carriedInMark, scan.path) if (slot.carry) return failure('unsupported-node-shape', carriedInMark, scan.path)
const refused = mark.type === 'link' ? refuseSpellableLink(scan, mark, slot.nodes, index) : undefined const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, slot.nodes, index) : undefined
if (refused !== undefined) return refused if (refused !== undefined) return refused
return success({ kind: 'nodes', nodes: applyMark(slot.nodes, mark) }) return success({ kind: 'nodes', nodes: applyMark(slot.nodes, mark) })
} }
// spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `assemble` asks the emitter. // spec/flavour.md, Marks. A link opening a paragraph may still need the directive form for the line it opens, which `assemble` asks the emitter.
function refuseSpellableLink(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[], index: number): Result<Piece> | undefined { function refuseLinkDirective(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[], index: number): Result<Piece> | undefined {
const href = nodeAttrs(mark)['href'] const href = linkHref(nodeAttrs(mark))
if (typeof href !== 'string') return undefined if (href === undefined) return failure('unsupported-node-shape', hreflessLink, scan.path)
if (marksLink(nodes)) return failure('unsupported-node-shape', linkInLink, scan.path)
if (commonMarkLink(nodeAttrs(mark), href, nodes, 0, scan.container === undefined) === undefined) return undefined if (commonMarkLink(nodeAttrs(mark), href, nodes, 0, scan.container === undefined) === undefined) return undefined
if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path) if (index !== 0 || scan.container !== 'paragraph') return failure('unsupported-node-shape', spellableLink, scan.path)
scan.openingSpellableLink = true scan.openingSpellableLink = true
@@ -225,7 +242,7 @@ function refuseSpellableLink(scan: Scan, mark: AdfMark, nodes: readonly AdfNode[
function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> { function slotContent(scan: Scan, span: DirectiveSpan): Result<SlotContent | undefined> {
if (span.content === undefined) return success(undefined) if (span.content === undefined) return success(undefined)
const parsed = parseInline(span.content, scan.definitions, scan.path, undefined, span.spans) const parsed = parseInline(span.content, scan.definitions, scan.path, undefined, span.spans, false)
if (!parsed.ok) return parsed if (!parsed.ok) return parsed
if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path) if (parsed.value.image !== undefined) return failure('unmappable-image', imageAlone, scan.path)
return success(parsed.value) return success(parsed.value)
@@ -245,7 +262,7 @@ function assemble(scan: Scan): Result<InlineContent> {
const only = scan.pieces[0] const only = scan.pieces[0]
if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node }) if (scan.pieces.length === 1 && only?.kind === 'image') return success({ image: only.node })
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path) if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
const nodes = resolveNodes(scan.pieces, scan.path) const nodes = resolveNodes(scan.pieces, scan.path, true)
if (!nodes.ok) return nodes if (!nodes.ok) return nodes
if (scan.openingSpellableLink) { if (scan.openingSpellableLink) {
const takesDirective = openingLinkTakesDirective(nodes.value, scan.path) const takesDirective = openingLinkTakesDirective(nodes.value, scan.path)
@@ -263,6 +280,15 @@ function holdsImage(pieces: readonly Piece[]): boolean {
return pieces.some((piece) => piece.kind === 'image') return pieces.some((piece) => piece.kind === 'image')
} }
// A carry rides its own piece: the carried node's marks restore with it rather than riding a spelling, so the guard below answers for it.
function holdsLink(pieces: readonly Piece[]): boolean {
return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes))
}
function marksLink(nodes: readonly AdfNode[]): boolean {
return nodes.some((node) => nodeMarks(node).some((mark) => mark.type === 'link'))
}
function readDelimiterRun(scan: Scan, index: number): number { function readDelimiterRun(scan: Scan, index: number): number {
const character = scan.source.charAt(index) const character = scan.source.charAt(index)
const length = runLength(scan.source, index) const length = runLength(scan.source, index)
@@ -275,6 +301,16 @@ function readDelimiterRun(scan: Scan, index: number): number {
return index + length return index + length
} }
function readEquals(scan: Scan, index: number): number {
if (!scan.highlights || !scan.source.startsWith(highlightDelimiter, index)) {
scan.pending += '='
return index + 1
}
flush(scan, false)
scan.pieces.push({ ...highlightFlanking(scan.source, index), kind: 'highlight' })
return index + highlightDelimiter.length
}
function readAutolink(source: string, index: number): { length: number; node: AdfNode } | undefined { function readAutolink(source: string, index: number): { length: number; node: AdfNode } | undefined {
const bracketed = readBracketedAutolink(source, index) const bracketed = readBracketedAutolink(source, index)
if (bracketed !== undefined) return { length: bracketed, node: linkedText(source.slice(index + 1, index + bracketed - 1), '') } if (bracketed !== undefined) return { length: bracketed, node: linkedText(source.slice(index + 1, index + bracketed - 1), '') }
@@ -339,35 +375,53 @@ function resolveTarget(scan: Scan, bracket: Bracket, index: number): { definitio
return { definition, length: label?.length ?? 0 } return { definition, length: label?.length ?? 0 }
} }
// `false` where the link text is empty: the mark has no node to ride, so the brackets stay text.
function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<boolean> { function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<boolean> {
// Ahead of the guards below: brackets going literal put the image and the carry inside no mark for either to refuse.
if (holdsLink(inner)) {
deactivateOpeners(scan, at)
return success(false)
}
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path) if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path) if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path)
const resolved = resolveNodes(inner, scan.path) const resolved = resolveNodes(inner, scan.path, true)
if (!resolved.ok) return resolved if (!resolved.ok) return resolved
const nodes = resolved.value const nodes = resolved.value
// An empty link text gives the mark no node to ride, so the brackets stay text.
if (nodes.length === 0) return success(false) if (nodes.length === 0) return success(false)
const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title } const attrs = definition.title === undefined ? { href: definition.destination } : { href: definition.destination, title: definition.title }
scan.pieces.length = at truncatePieces(scan, at)
// CommonMark: no link nests inside another, though an image's description holds one. deactivateOpeners(scan, at)
for (const piece of scan.pieces) if (piece.kind === 'open' && !piece.image) piece.active = false
scan.pieces.push({ kind: 'nodes', nodes: applyMark(nodes, { attrs, type: 'link' }) }) scan.pieces.push({ kind: 'nodes', nodes: applyMark(nodes, { attrs, type: 'link' }) })
return success(true) return success(true)
} }
// CommonMark: no link nests inside another, though an image's description holds one.
function deactivateOpeners(scan: Scan, before: number): void {
for (let index = scan.deactivatedBefore; index < before; index += 1) {
const piece = scan.pieces[index]
if (piece?.kind === 'open' && !piece.image) piece.active = false
}
scan.deactivatedBefore = before
}
function truncatePieces(scan: Scan, to: number): void {
scan.pieces.length = to
scan.deactivatedBefore = Math.min(scan.deactivatedBefore, to)
}
function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<null> { function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition: LinkDefinition): Result<null> {
if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path) if (definition.title !== undefined) return failure('unmappable-image', 'no media node carries a link title', scan.path)
const resolved = imageAlt(inner, scan.path) const resolved = imageAlt(inner, scan.path)
if (!resolved.ok) return resolved if (!resolved.ok) return resolved
const alt = resolved.value const alt = resolved.value
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination } const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
scan.pieces.length = at truncatePieces(scan, at)
scan.pieces.push({ alt, kind: 'image', node: { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' } }) scan.pieces.push({ alt, kind: 'image', node: { attrs: { layout: 'center' }, content: [{ attrs, type: 'media' }], type: 'mediaSingle' } })
return success(null) return success(null)
} }
function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<string> { function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<string> {
const nodes = resolveNodes(inner, path) const nodes = resolveNodes(inner, path, false)
if (!nodes.ok) return nodes if (!nodes.ok) return nodes
return success(nodes.value.map(altText).join('')) return success(nodes.value.map(altText).join(''))
} }
@@ -375,25 +429,30 @@ function imageAlt(inner: readonly Piece[], path: ConvertErrorPath): Result<strin
// spec/flavour.md, The CommonMark image: the description's plain text, the content slot included. // spec/flavour.md, The CommonMark image: the description's plain text, the content slot included.
function altText(node: AdfNode): string { function altText(node: AdfNode): string {
if (node.type === 'hardBreak') return ' ' if (node.type === 'hardBreak') return ' '
const slot = inlineDirective(node.type)?.textAttribute const slot = inlineNodeModel(node.type)?.textAttribute
const spelled = slot === undefined ? undefined : nodeAttrs(node)[slot] const spelled = slot === undefined ? undefined : nodeAttrs(node)[slot]
return typeof spelled === 'string' ? spelled : (node.text ?? '') return typeof spelled === 'string' ? spelled : (node.text ?? '')
} }
function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath): Result<AdfNode[]> { // An image's alt text is plain, so `highlights` is off there and every `==` stays text.
function resolveNodes(pieces: readonly Piece[], path: ConvertErrorPath, highlights: boolean): Result<AdfNode[]> {
const nodes = pieces.map(pieceNodes) const nodes = pieces.map(pieceNodes)
const runs = delimiterRuns(pieces) const runs = delimiterRuns(pieces)
const pairings = matchEmphasis(runs) const pairings = matchEmphasis(runs)
writeUnpaired(nodes, runs, pairings) writeUnpaired(nodes, runs, pairings)
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, path) if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, path)
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
return success(mergeAdjacentText(nodes.flat())) return success(mergeAdjacentText(nodes.flat()))
} }
// Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first. // Only `imageAlt` reaches the image arm: everywhere else an image amid other content is refused first.
// A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it.
function pieceNodes(piece: Piece): AdfNode[] { function pieceNodes(piece: Piece): AdfNode[] {
switch (piece.kind) { switch (piece.kind) {
case 'carry': case 'carry':
return [piece.node] return [piece.node]
case 'highlight':
return [{ text: '', type: 'text' }]
case 'image': case 'image':
return piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }] return piece.alt === '' ? [] : [{ text: piece.alt, type: 'text' }]
case 'nodes': case 'nodes':
@@ -440,12 +499,69 @@ function markPairings(pieces: readonly Piece[], nodes: AdfNode[][], pairings: re
return true return true
} }
function highlightDelimiters(pieces: readonly Piece[], nodes: readonly AdfNode[][]): HighlightDelimiter[] {
const found: HighlightDelimiter[] = []
let line = 0
let position = 0
for (const [index, piece] of pieces.entries()) {
const held = nodes[index] ?? []
const [holder] = held
if (piece.kind === 'highlight' && holder !== undefined) {
found.push({ closes: piece.closes, holder, index, line, opens: piece.opens, position })
position += highlightDelimiter.length
continue
}
for (const node of held) {
if (node.type === 'text') position += node.text?.length ?? 0
else line += 1
}
}
return found
}
// Each opener takes the next closer holding at least one character after it, both in one line and under the same marks.
function pairedHighlights(pieces: readonly Piece[], nodes: readonly AdfNode[][]): { closer: number; opener: number }[] {
const found = highlightDelimiters(pieces, nodes)
const paired: { closer: number; opener: number }[] = []
let closer = 0
let resume = 0
for (const opener of found) {
if (!opener.opens || opener.position < resume) continue
const earliest = opener.position + highlightDelimiter.length + 1
let candidate = found[closer]
while (candidate !== undefined && (!candidate.closes || candidate.position < earliest)) candidate = found[(closer += 1)]
if (candidate === undefined) break
if (candidate.line !== opener.line || !sameMarks(opener.holder, candidate.holder)) continue
paired.push({ closer: candidate.index, opener: opener.index })
resume = candidate.position + highlightDelimiter.length
}
return paired
}
// Atlassian's schema refuses a highlight on code, a node holds one highlight, and a rebuilt node would lose its attributes.
function markHighlights(pieces: readonly Piece[], nodes: AdfNode[][], paired: readonly { closer: number; opener: number }[]): void {
for (const [index, piece] of pieces.entries()) {
if (piece.kind === 'highlight') nodes[index] = (nodes[index] ?? []).map((holder) => ({ ...holder, text: highlightDelimiter }))
}
for (const { closer, opener } of paired) {
nodes[opener] = []
nodes[closer] = []
for (let index = opener + 1; index < closer; index += 1) nodes[index] = (nodes[index] ?? []).map(highlighted)
}
}
function highlighted(node: AdfNode): AdfNode {
const marks = nodeMarks(node)
if (node.type !== 'text' || Object.keys(nodeAttrs(node)).length > 0 || marks.some((mark) => mark.type === 'code' || mark.type === 'backgroundColor')) return node
return { ...node, marks: [editorHighlight, ...marks] }
}
function markType(character: string, used: number): string { function markType(character: string, used: number): string {
if (character === '~') return 'strike' if (character === '~') return 'strike'
return used === 2 ? 'strong' : 'em' return used === 2 ? 'strong' : 'em'
} }
// A node cannot carry one mark type twice (AGENTS.md §14). // A node cannot carry one mark type twice (docs/decisions.md §No schema validation).
function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] { function applyMark(nodes: readonly AdfNode[], mark: AdfMark): AdfNode[] {
return nodes.map((node) => { return nodes.map((node) => {
const marks = nodeMarks(node) const marks = nodeMarks(node)
+58 -3
View File
@@ -416,6 +416,7 @@ test('names the mark spelling no opaque carry sits inside', () => {
assert.equal(content(markdownToAdf(`**${carried}**\n`)), named) assert.equal(content(markdownToAdf(`**${carried}**\n`)), named)
assert.equal(content(markdownToAdf(`~~a ${carried}~~\n`)), named) assert.equal(content(markdownToAdf(`~~a ${carried}~~\n`)), named)
assert.equal(content(markdownToAdf(`[a ${carried} b](https://example.com/x)\n`)), named) assert.equal(content(markdownToAdf(`[a ${carried} b](https://example.com/x)\n`)), named)
assert.equal(content(markdownToAdf(`[*<http://x/>${carried}*](/w)\n`)), named)
assert.equal(content(markdownToAdf(`!adf:underline[${carried}]\n`)), named) assert.equal(content(markdownToAdf(`!adf:underline[${carried}]\n`)), named)
assert.equal(content(markdownToAdf(`!adf:textColor[a ${carried}]{color="#ae2e24"}\n`)), named) assert.equal(content(markdownToAdf(`!adf:textColor[a ${carried}]{color="#ae2e24"}\n`)), named)
assert.equal(content(markdownToAdf(`![_a ${carried}_](https://example.com/i)\n`)), named) assert.equal(content(markdownToAdf(`![_a ${carried}_](https://example.com/i)\n`)), named)
@@ -671,7 +672,7 @@ test('refuses input nested deeper than the parser carries', () => {
assert.equal(code(markdownToAdf(marks(largestNesting + 1))), 'unsupported-nesting-depth') assert.equal(code(markdownToAdf(marks(largestNesting + 1))), 'unsupported-nesting-depth')
assert.deepEqual(content(markdownToAdf(marks(largestNesting))), [{ content: [marked('a', underline)], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf(marks(largestNesting))), [{ content: [marked('a', underline)], type: 'paragraph' }])
const nest = (names: readonly string[], body: string): string => [...names.map((name) => `!adf:${name}\n`), body, ...names.map((name) => `!adf:/${name}\n`).reverse()].join('') const nest = (names: readonly string[], body: string): string => [...names.map((name) => `!adf:${name}\n`), body, ...names.map((name) => `!adf:/${name}\n`).reverse()].join('')
const repeated = (name: string): string[] => Array.from({ length: largestNesting }, () => name) const repeated = (name: string, levels: number = largestNesting): string[] => Array.from({ length: levels }, () => name)
assert.ok(markdownToAdf(nest(repeated('panel'), '!adf:paragraph {localId=a-1}\nPart.\n!adf:/paragraph\n')).ok) assert.ok(markdownToAdf(nest(repeated('panel'), '!adf:paragraph {localId=a-1}\nPart.\n!adf:/paragraph\n')).ok)
assert.deepEqual(position(markdownToAdf(nest(['expand', ...repeated('panel'), 'expand'], 'Part.\n'))), { line: 501, offset: 5501 }) assert.deepEqual(position(markdownToAdf(nest(['expand', ...repeated('panel'), 'expand'], 'Part.\n'))), { line: 501, offset: 5501 })
assert.equal(code(markdownToAdf(nest(['panel', ...repeated('expand'), 'panel'], 'Part.\n'))), 'unsupported-nesting-depth') assert.equal(code(markdownToAdf(nest(['panel', ...repeated('expand'), 'panel'], 'Part.\n'))), 'unsupported-nesting-depth')
@@ -679,6 +680,19 @@ test('refuses input nested deeper than the parser carries', () => {
const directiveLists = largestNesting / 2 const directiveLists = largestNesting / 2
assert.ok(markdownToAdf(listed(directiveLists)).ok) assert.ok(markdownToAdf(listed(directiveLists)).ok)
assert.equal(code(markdownToAdf(listed(directiveLists + 1))), 'unsupported-nesting-depth') assert.equal(code(markdownToAdf(listed(directiveLists + 1))), 'unsupported-nesting-depth')
const asking = (body: string): string => `!adf:bulletList\n!adf:listItem\n${body}!adf:/listItem\n!adf:/bulletList\n`
// Two items past the largest list marker leave the list no readable spelling, so the emitter
// walks it as list plus item where the parser counted one level.
const overflowing = (body: string): string => {
const marker = '999999999. '
return `${marker}${body.replace(/^(?!$)/gm, ' '.repeat(marker.length)).slice(marker.length)}\n${marker}z\n`
}
const overflowed = (panels: number): string => asking(overflowing(overflowing(asking(`---\n${nest(repeated('panel', panels), 'Part.\n')}`))))
assert.equal(code(markdownToAdf(overflowed(493))), 'unsupported-node-shape')
assert.equal(code(markdownToAdf(overflowed(494))), 'unsupported-nesting-depth')
const rebasing = (panels: number): string => asking(`---\n${overflowing(asking(`---\n${nest(repeated('panel', panels), 'Part.\n')}`))}`)
assert.ok(markdownToAdf(rebasing(494)).ok)
assert.equal(code(markdownToAdf(rebasing(495))), 'unsupported-nesting-depth')
assert.ok(markdownToAdf(`${'- '.repeat(largestNesting)}a\n`).ok) assert.ok(markdownToAdf(`${'- '.repeat(largestNesting)}a\n`).ok)
assert.equal(code(markdownToAdf(`${'- '.repeat(largestNesting + 1)}a\n`)), 'unsupported-nesting-depth') assert.equal(code(markdownToAdf(`${'- '.repeat(largestNesting + 1)}a\n`)), 'unsupported-nesting-depth')
}) })
@@ -825,6 +839,33 @@ test('leaves the bracket pair no link parses as the text it holds', () => {
]) ])
}) })
test('leaves the brackets of a link whose text already holds one the text they are', () => {
const held: AdfMark = { attrs: { collection: 'c', href: '/u' }, type: 'link' }
assert.deepEqual(content(markdownToAdf('[<http://x/>](/v)\n')), [
{ content: [text('['), marked('http://x/', link('http://x/')), text('](/v)')], type: 'paragraph' },
])
assert.deepEqual(content(markdownToAdf('[a<http://x/>b](/v)\n')), [
{ content: [text('[a'), marked('http://x/', link('http://x/')), text('b](/v)')], type: 'paragraph' },
])
assert.deepEqual(content(markdownToAdf('[!adf:link[a]{collection=c href="/u"}](/v)\n')), [
{ content: [text('['), marked('a', held), text('](/v)')], type: 'paragraph' },
])
assert.deepEqual(content(markdownToAdf('[<http://x/>][r]\n\n[r]: /v\n')), [
{ content: [text('['), marked('http://x/', link('http://x/')), text(']'), marked('r', link('/v'))], type: 'paragraph' },
])
})
test('keeps the carry and the image the brackets a nested link leaves literal hold', () => {
assert.deepEqual(content(markdownToAdf(`[<http://x/>${carried}](/w)\n`)), [
{ content: [text('['), marked('http://x/', link('http://x/')), { type: 'placeholder' }, text('](/w)')], type: 'paragraph' },
])
assert.deepEqual(content(markdownToAdf(`[[<http://x/>](/c)${carried}](/w)\n`)), [
{ content: [text('[['), marked('http://x/', link('http://x/')), text('](/c)'), { type: 'placeholder' }, text('](/w)')], type: 'paragraph' },
])
// The failing bracket sits inside the image, not beside it: beside it the link-marked piece survives, and the deactivation stops being what the assertion pins.
assert.deepEqual(content(markdownToAdf('![![a [b](/c) ](/i)[![[<http://x/>](/c)](/y)](/w)](/v)\n')), [image('/v', 'a b [[http://x/](/c)](/w)')])
})
test('reads the reference links a definition resolves, and leaves the rest literal', () => { test('reads the reference links a definition resolves, and leaves the rest literal', () => {
assert.deepEqual(content(markdownToAdf('[a][r]\n\n[r]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('[a][r]\n\n[r]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }])
assert.deepEqual(content(markdownToAdf('[a][]\n\n[a]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('[a][]\n\n[a]: /url\n')), [{ content: [marked('a', link('/url'))], type: 'paragraph' }])
@@ -984,8 +1025,6 @@ test('refuses the directive link CommonMark could spell, and reads the one it co
assert.equal(content(markdownToAdf('| !adf:link[`]: a`]{href="/u"} |\n| --- |\n')), refused) assert.equal(content(markdownToAdf('| !adf:link[`]: a`]{href="/u"} |\n| --- |\n')), refused)
assert.equal(content(markdownToAdf('!adf:underline[!adf:link[a]{href="/u"}]\n')), refused) assert.equal(content(markdownToAdf('!adf:underline[!adf:link[a]{href="/u"}]\n')), refused)
assert.deepEqual(path(markdownToAdf('Part.\n\nSee !adf:link[a]{href="/u"}.\n')), ['content', 1]) assert.deepEqual(path(markdownToAdf('Part.\n\nSee !adf:link[a]{href="/u"}.\n')), ['content', 1])
const titled: AdfNode = { marks: [{ attrs: { title: 't' }, type: 'link' }], text: 'a', type: 'text' }
assert.deepEqual(content(markdownToAdf('!adf:link[a]{title=t}\n')), [{ content: [titled], type: 'paragraph' }])
const opening: AdfNode = { marks: [{ attrs: { href: '/u' }, type: 'link' }, { type: 'code' }], text: ']: a', type: 'text' } const opening: AdfNode = { marks: [{ attrs: { href: '/u' }, type: 'link' }, { type: 'code' }], text: ']: a', type: 'text' }
assert.deepEqual(content(markdownToAdf('!adf:link[`]: a`]{href="/u"}\n')), [{ content: [opening], type: 'paragraph' }]) assert.deepEqual(content(markdownToAdf('!adf:link[`]: a`]{href="/u"}\n')), [{ content: [opening], type: 'paragraph' }])
assert.deepEqual(content(markdownToAdf('!adf:heading {level=1 localId=h}\n!adf:link[`]: a`]{href="/u"}\n!adf:/heading\n')), [ assert.deepEqual(content(markdownToAdf('!adf:heading {level=1 localId=h}\n!adf:link[`]: a`]{href="/u"}\n!adf:/heading\n')), [
@@ -993,6 +1032,22 @@ test('refuses the directive link CommonMark could spell, and reads the one it co
]) ])
}) })
test('names the href the directive link spells no value for', () => {
const named = 'unsupported-node-shape: the link mark spells its href: this one spells none'
assert.equal(content(markdownToAdf('!adf:link[a]\n')), named)
assert.equal(content(markdownToAdf('!adf:link[a]{title=t}\n')), named)
assert.equal(content(markdownToAdf('See !adf:link[a]{id=01a032c3-7a90-70c9-88f6-c60f710eda07}.\n')), named)
assert.equal(content(markdownToAdf('!adf:link[<http://x/>]{collection=c}\n')), named)
})
test('names the link a directive link wraps, no link holding another', () => {
const named = 'unsupported-node-shape: no link wraps a link: the [content] this one marks already holds one'
assert.equal(content(markdownToAdf('!adf:link[<http://x/>]{collection=c href="/u"}\n')), named)
assert.equal(content(markdownToAdf('!adf:link[[a](/v)]{collection=c href="/u"}\n')), named)
assert.equal(content(markdownToAdf('!adf:link[a <http://x/> b]{collection=c href="/u"}\n')), named)
assert.equal(content(markdownToAdf('!adf:link[<http://x/>]{href="/u"}\n')), named)
})
test('names the directive mark left without the content it wraps', () => { test('names the directive mark left without the content it wraps', () => {
const named = 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps none' const named = 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps none'
assert.equal(content(markdownToAdf('!adf:underline[]\n')), named) assert.equal(content(markdownToAdf('!adf:underline[]\n')), named)
+159 -38
View File
@@ -2,30 +2,52 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { Block, DirectiveBlock } from './blocks.ts' import type { Block, DirectiveBlock } from './blocks.ts'
import type { BlockDirectiveNode } from './directive-nodes.ts' import type { BlockDirectiveNode } from './directive-nodes.ts'
import type { ConvertFault } from '../../result.ts' import type { ConvertFault } from '../../result.ts'
import type { LineContainer } from '../emit/line-escaping.ts' import type { Flavour } from '../plain-conventions.ts'
import type { LineContainer } from '../line-container.ts'
import type { LinkDefinitions } from './inline-content.ts' import type { LinkDefinitions } from './inline-content.ts'
import { carryName, readCarriedBlock } from '../opaque-carry.ts' import { carryName, readCarriedBlock } from '../opaque-carry.ts'
import { commonMarkSpelling } from '../emit/adf-to-markdown.ts' import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts' import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
import { inlineLeaves } from '../emit/plain-inline.ts'
import { languageSlot } from '../code-language.ts' import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { listBreakName, listBreakSpelling } from '../list-break.ts' import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain-conventions.ts'
import { nodeAttrs } from '../../adf/document.ts' import { listBreakName, listBreakSpelling } from '../block-directive.ts'
import { mintTaskIds } from './task-ids.ts'
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
import { parseBlocks } from './blocks.ts' import { parseBlocks } from './blocks.ts'
import { parseInlineContent } from './inline-content.ts' import { parseInlineContent } from './inline-content.ts'
import { readBlockDirectiveNode } from './directive-nodes.ts' import { readBlockDirectiveNode } from './directive-nodes.ts'
import { unsupportedNodeShape } from '../directive-syntax.ts' import { unsupportedNodeShape } from '../directive-syntax.ts'
type Paragraph = Extract<Block, { kind: 'paragraph' }>
// `inExpand` is whether an expand holds the blocks, which makes a folded callout a nestedExpand.
type Reading = { carried: Set<AdfNode>; definitions: LinkDefinitions; flavour: Flavour; inExpand: boolean; memo: SpellingMemo }
const documentStart: SourcePosition = { line: 1, offset: 0 } const documentStart: SourcePosition = { line: 1, offset: 0 }
const imageAfterMarker = 'an image fits only as a paragraph of its own: this one continues the paragraph a marker opens, which a blank line before it ends'
const imageOnMarkerLine = 'an image fits only as a paragraph of its own: this one shares a line with a marker'
export function markdownToAdf(markdown: string): Result<AdfDocument, ParseError> { export function markdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
const parsed = parseBlocks(markdown) return readDocument(markdown, 'lossless')
const content = positioned(blockNodes(parsed.blocks, parsed.definitions, [], 0), documentStart)
if (!content.ok) return content
return success(content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 })
} }
function blockNodes(blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode[]> { export function plainMarkdownToAdf(markdown: string): Result<AdfDocument, ParseError> {
return readDocument(markdown, 'plain')
}
function readDocument(markdown: string, flavour: Flavour): Result<AdfDocument, ParseError> {
const parsed = parseBlocks(markdown)
const reading: Reading = { carried: new Set(), definitions: parsed.definitions, flavour, inExpand: false, memo: new Map() }
const content = positioned(readBlocks(parsed.blocks, reading, [], 0), documentStart)
if (!content.ok) return content
const document: AdfDocument = content.value.length === 0 ? { type: 'doc', version: 1 } : { content: content.value, type: 'doc', version: 1 }
if (flavour === 'plain') mintTaskIds(document, markdown, reading.carried)
return success(document)
}
function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path) if (depth > largestNesting) return failure('unsupported-nesting-depth', `the input nests deeper than the ${largestNesting} levels the parser carries`, path)
const content: AdfNode[] = [] const content: AdfNode[] = []
for (const [index, block] of blocks.entries()) { for (const [index, block] of blocks.entries()) {
@@ -35,7 +57,7 @@ function blockNodes(blocks: readonly Block[], definitions: LinkDefinitions, path
if (fault !== undefined) return positioned(faulted(fault, nodePath), block.position) if (fault !== undefined) return positioned(faulted(fault, nodePath), block.position)
continue continue
} }
const node = positioned(blockNode(block, definitions, nodePath, depth), block.position) const node = positioned(readBlock(block, reading, nodePath, depth), block.position)
if (!node.ok) return node if (!node.ok) return node
content.push(node.value) content.push(node.value)
} }
@@ -55,50 +77,148 @@ function partsFault(): ConvertFault {
return unsupportedNodeShape(`${listBreakName} parts two adjacent lists of one type: this one parts something else`) return unsupportedNodeShape(`${listBreakName} parts two adjacent lists of one type: this one parts something else`)
} }
function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> { function readBlock(block: Block, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
switch (block.kind) { switch (block.kind) {
case 'blockquote': case 'blockquote':
return containerNode({ type: 'blockquote' }, block.blocks, definitions, path, depth) return reading.flavour === 'plain' ? quoteNode(block.blocks, reading, path, depth) : containerNode({ type: 'blockquote' }, block.blocks, reading, path, depth)
case 'bulletList': case 'bulletList':
return listNode({ type: 'bulletList' }, block.items, definitions, path, depth) return reading.flavour === 'plain' ? bulletNode(block.items, reading, path, depth) : listNode({ type: 'bulletList' }, block.items, reading, path, depth)
case 'code': case 'code':
return codeBlockNode(block.language, block.text, path, depth) return codeBlockNode(block.language, block.text, reading, path, depth)
case 'directive': case 'directive':
return directiveNode(block, definitions, path, depth) return directiveNode(block, reading, path, depth)
case 'fault': case 'fault':
return faulted(block.fault, path) return faulted(block.fault, path)
case 'heading': case 'heading':
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, definitions, path, 'heading') return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, reading, path, 'heading')
case 'html': case 'html':
return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path) return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path)
case 'orderedList': case 'orderedList':
return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, definitions, path, depth) return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, reading, path, depth)
case 'paragraph': case 'paragraph':
return paragraphNode(block.text, definitions, path) return paragraphNode(block.text, reading, path)
case 'rule': case 'rule':
return success({ type: 'rule' }) return success({ type: 'rule' })
case 'table': case 'table':
return tableNode(block.rows, definitions, path) return tableNode(block.rows, reading, path)
} }
} }
function directiveNode(block: DirectiveBlock, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> { function markerLed<T extends { length: number }>(block: Block | undefined, read: (text: string) => T | undefined): { marker: T; position: SourcePosition; text: string } | undefined {
if (block?.kind !== 'paragraph') return undefined
const marker = leadingMarker(block.text, read)
return marker === undefined ? undefined : { marker, position: block.position, text: block.text.slice(marker.length) }
}
function markerLine(text: string): { line: string; rest: string } {
const lineEnd = text.indexOf('\n')
const line = lineEnd === -1 ? text : text.slice(0, lineEnd)
const hardBreak = lineEnd !== -1 && /(?:^|[^\\])(?:\\\\)*\\$/.test(line)
return { line: (hardBreak ? line.slice(0, -1) : line).replace(/^[ \t]+/, ''), rest: lineEnd === -1 ? '' : text.slice(lineEnd + 1) }
}
function paragraphsOf(position: SourcePosition, ...texts: string[]): Paragraph[] {
return texts.filter((text) => text !== '').map((text) => ({ kind: 'paragraph', position, text }))
}
function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const [first, ...body] = blocks
const led = markerLed(first, readAlertMarker)
if (led === undefined) return containerNode({ type: 'blockquote' }, blocks, reading, path, depth)
const { folded, panelType } = led.marker
const { line, rest } = markerLine(led.text)
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readMarked(paragraphsOf(led.position, line, rest), line !== '', body, reading, path, depth))
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', 'lossless')
if (!title.ok) return title
if (title.value.image !== undefined) return failure('unmappable-image', imageOnMarkerLine, path)
const leaves: AdfNode[] = []
for (const [index, node] of title.value.nodes.entries()) {
const held = node.text === undefined ? inlineLeaves([node], 'paragraph', [...path, 'content', index], depth) : success([node])
if (!held.ok) return held
leaves.push(...held.value)
}
const text = titleText(leaves)
const type = reading.inExpand ? 'nestedExpand' : 'expand'
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readMarked(paragraphsOf(led.position, rest), false, body, { ...reading, inExpand: true }, path, depth))
}
// docs/decisions.md, A callout title keeps its link targets.
function titleText(nodes: readonly AdfNode[]): string {
let text = ''
let linked = ''
for (const [index, node] of nodes.entries()) {
const href = linkTarget(node)
text += node.text ?? ''
linked += href === undefined ? '' : node.text ?? ''
if (href === undefined || linkTarget(nodes[index + 1]) === href) continue
if (href !== linked && href !== `mailto:${linked}`) text += ` (${href})`
linked = ''
}
return text
}
function linkTarget(node: AdfNode | undefined): string | undefined {
const href = node?.marks?.find((mark) => mark.type === 'link')?.attrs?.href
return typeof href === 'string' ? href : undefined
}
// Atlassian's schema requires a panel and an expand to hold a block.
function filledNode(node: AdfNode, content: Result<AdfNode[]>): Result<AdfNode> {
if (!content.ok) return content
return success({ ...node, content: content.value.length === 0 ? [{ type: 'paragraph' }] : content.value })
}
// A paragraph split off a marker still refuses the image it held beside it; `onMarkerLine` is whether the first one opens on the marker's line.
function readMarked(marked: readonly Paragraph[], onMarkerLine: boolean, others: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
const read = readBlocks([...marked, ...others], reading, path, depth + 1)
if (!read.ok) return read
const image = read.value.slice(0, marked.length).findIndex((node) => node.type === 'mediaSingle')
if (image === -1) return read
return failure('unmappable-image', image === 0 && onMarkerLine ? imageOnMarkerLine : imageAfterMarker, [...path, 'content', image])
}
// A task list trailing an item's blocks stands beside it, as ADF nests one.
function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const led = []
for (const [first, ...others] of items) {
const marked = markerLed(first, readTaskMarker)
if (marked === undefined) return listNode({ type: 'bulletList' }, items, reading, path, depth)
led.push({ ...marked, others })
}
const tasks: AdfNode[] = []
for (const [index, { marker, others, position, text }] of led.entries()) {
const read = readMarked(paragraphsOf(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), markerLine(text).line !== '', others, reading, [...path, 'content', index], depth)
if (!read.ok) return read
let beside = read.value.length
while (read.value[beside - 1]?.type === 'taskList') beside -= 1
const kept = read.value.slice(0, beside)
const [only] = kept
const attrs = { state: marker.state }
const inline = kept.length <= 1 && (only === undefined || only.type === 'paragraph')
tasks.push(inline ? { attrs, content: nodeContent(only ?? {}).slice(), type: 'taskItem' } : { attrs, content: kept, type: 'blockTaskItem' })
for (const nested of read.value.slice(beside)) tasks.push(nested)
}
return success({ content: tasks, type: 'taskList' })
}
function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const read = readBlockDirectiveNode(block.name, block.argument, block.attributes, path) const read = readBlockDirectiveNode(block.name, block.argument, block.attributes, path)
if (!read.ok) return read if (!read.ok) return read
const built = directiveBody(read.value, block.blocks, definitions, path, depth) const inExpand = reading.inExpand || read.value.node.type === 'expand' || read.value.node.type === 'nestedExpand'
const built = directiveBody(read.value, block.blocks, { ...reading, inExpand }, path, depth)
if (!built.ok) return built if (!built.ok) return built
const readable = commonMarkSpelling(built.value, path, depth) const readable = commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', memo: reading.memo })
if (readable === undefined) return built if (readable === undefined) return built
if (!readable.ok) return readable if (!readable.ok) return readable
return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path) return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path)
} }
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> { function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const { contentModel, node } = read const { contentModel, node } = read
if (blocks === undefined) return success(node) if (blocks === undefined) return success(node)
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path) if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
if (contentModel === 'inline') return inlineBodyNode(node, blocks, definitions, path) if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path)
return containerNode(node, blocks, definitions, path, depth) return containerNode(node, blocks, reading, path, depth)
} }
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> { function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
@@ -114,13 +234,13 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver
return success(withContent(spelled, only.text === '' ? [] : [{ text: only.text, type: 'text' }])) return success(withContent(spelled, only.text === '' ? [] : [{ text: only.text, type: 'text' }]))
} }
function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> { function tableNode(rows: readonly string[][], reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
const content: AdfNode[] = [] const content: AdfNode[] = []
for (const [rowIndex, cells] of rows.entries()) { for (const [rowIndex, cells] of rows.entries()) {
const type = rowIndex === 0 ? 'tableHeader' : 'tableCell' const type = rowIndex === 0 ? 'tableHeader' : 'tableCell'
const row: AdfNode[] = [] const row: AdfNode[] = []
for (const [cellIndex, cell] of cells.entries()) { for (const [cellIndex, cell] of cells.entries()) {
const paragraph = contentNode({ type: 'paragraph' }, cell, definitions, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], 'table-cell') const paragraph = contentNode({ type: 'paragraph' }, cell, reading, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], 'table-cell')
if (!paragraph.ok) return paragraph if (!paragraph.ok) return paragraph
row.push({ content: [paragraph.value], type }) row.push({ content: [paragraph.value], type })
} }
@@ -129,16 +249,16 @@ function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path
return success({ content, type: 'table' }) return success({ content, type: 'table' })
} }
function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> { function inlineBodyNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
if (blocks.length === 0) return success(node) if (blocks.length === 0) return success(node)
const only = blocks.length === 1 ? blocks[0] : undefined const only = blocks.length === 1 ? blocks[0] : undefined
if (only?.kind === 'fault') return positioned(faulted(only.fault, path), only.position) if (only?.kind === 'fault') return positioned(faulted(only.fault, path), only.position)
if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body: this body is not one`, path) if (only?.kind !== 'paragraph') return failure('unsupported-node-shape', `${node.type} takes one paragraph as its body: this body is not one`, path)
return positioned(contentNode(node, only.text, definitions, path, 'paragraph'), only.position) return positioned(contentNode(node, only.text, reading, path, 'paragraph'), only.position)
} }
function containerNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> { function containerNode(node: AdfNode, blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const content = blockNodes(blocks, definitions, path, depth + 1) const content = readBlocks(blocks, reading, path, depth + 1)
if (!content.ok) return content if (!content.ok) return content
return success(withContent(node, content.value)) return success(withContent(node, content.value))
} }
@@ -147,20 +267,21 @@ function withContent(node: AdfNode, content: readonly AdfNode[]): AdfNode {
return content.length === 0 ? node : { ...node, content: [...content] } return content.length === 0 ? node : { ...node, content: [...content] }
} }
function listNode(node: AdfNode, items: readonly Block[][], definitions: LinkDefinitions, path: ConvertErrorPath, depth: number): Result<AdfNode> { function listNode(node: AdfNode, items: readonly Block[][], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const content: AdfNode[] = [] const content: AdfNode[] = []
for (const [index, blocks] of items.entries()) { for (const [index, blocks] of items.entries()) {
const item = containerNode({ type: 'listItem' }, blocks, definitions, [...path, 'content', index], depth) const item = containerNode({ type: 'listItem' }, blocks, reading, [...path, 'content', index], depth)
if (!item.ok) return item if (!item.ok) return item
content.push(item.value) content.push(item.value)
} }
return success({ ...node, content }) return success({ ...node, content })
} }
function codeBlockNode(language: string, text: string, path: ConvertErrorPath, depth: number): Result<AdfNode> { function codeBlockNode(language: string, text: string, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
if (language === carryName) { if (language === carryName) {
const carried = readCarriedBlock(text, depth) const carried = readCarriedBlock(text, depth)
if (carried.fault !== undefined) return faulted(carried.fault, path) if (carried.fault !== undefined) return faulted(carried.fault, path)
reading.carried.add(carried.value)
return success(carried.value) return success(carried.value)
} }
const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' } const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' }
@@ -168,15 +289,15 @@ function codeBlockNode(language: string, text: string, path: ConvertErrorPath, d
} }
// spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs. // spec/flavour.md, The CommonMark image: only a plain paragraph gives an image the block it needs.
function paragraphNode(text: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> { function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath): Result<AdfNode> {
const content = parseInlineContent(text, definitions, path, 'paragraph') const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.flavour)
if (!content.ok) return content if (!content.ok) return content
const image = content.value.image const image = content.value.image
return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image) return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image)
} }
function contentNode(node: AdfNode, text: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> { function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result<AdfNode> {
const content = parseInlineContent(text, definitions, path, container) const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour)
if (!content.ok) return content if (!content.ok) return content
if (content.value.image !== undefined) return failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path) if (content.value.image !== undefined) return failure('unmappable-image', `no ADF node carries an image inside a ${node.type}`, path)
return success(withContent(node, content.value.nodes)) return success(withContent(node, content.value.nodes))
@@ -0,0 +1,268 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import { adfToMarkdown } from '../emit/adf-to-markdown.ts'
import { adfToPlainMarkdown } from '../emit/plain-reduction.ts'
import { largestNesting } from '../../nesting.ts'
import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
import { toEditorNormal } from '../../adf/editor-normal.ts'
const code: AdfMark = { type: 'code' }
const em: AdfMark = { type: 'em' }
const highlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
const strong: AdfMark = { type: 'strong' }
const taskTypes = ['blockTaskItem', 'taskItem', 'taskList']
function read(markdown: string): readonly AdfNode[] | string {
const parsed = plainMarkdownToAdf(markdown)
if (!parsed.ok) return parsed.error.code
const blocks = toEditorNormal(parsed.value).content ?? []
const pending = [...blocks]
for (let block = pending.pop(); block !== undefined; block = pending.pop()) {
for (const child of block.content ?? []) pending.push(child)
if (!taskTypes.includes(block.type)) continue
const { localId, ...attrs } = block.attrs ?? {}
assert.equal(typeof localId, 'string', `${block.type} in ${JSON.stringify(markdown)}`)
if (Object.keys(attrs).length === 0) delete block.attrs
else block.attrs = attrs
}
return blocks
}
function normal(...blocks: AdfNode[]): readonly AdfNode[] {
return toEditorNormal(document(...blocks)).content ?? []
}
function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
function roundTripped(...content: AdfNode[]): readonly AdfNode[] | string {
const markdown = adfToPlainMarkdown(document(...content))
return markdown.ok ? read(markdown.value) : markdown.error.code
}
function text(value: string, ...marks: AdfMark[]): AdfNode {
return marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' }
}
function node(type: string, attrs: AdfAttributes, ...content: AdfNode[]): AdfNode {
return { attrs, content, type }
}
function bare(type: string, ...content: AdfNode[]): AdfNode {
return { content, type }
}
function paragraph(...content: AdfNode[]): AdfNode {
return bare('paragraph', ...content)
}
function said(value: string): AdfNode {
return paragraph(text(value))
}
function panel(panelType: string, ...content: AdfNode[]): AdfNode {
return node('panel', { panelType }, ...content)
}
function task(state: string, ...content: AdfNode[]): AdfNode {
return node('taskItem', { state }, ...content)
}
test('reads an alert to a panel by its GitHub word, in any case', () => {
const alert = (word: string): readonly AdfNode[] | string => read(`> [!${word}]\n>\n> Check it.\n`)
assert.deepEqual(alert('NOTE'), [panel('info', said('Check it.'))])
assert.deepEqual(alert('IMPORTANT'), [panel('note', said('Check it.'))])
assert.deepEqual(alert('TIP'), [panel('tip', said('Check it.'))])
assert.deepEqual(alert('WARNING'), [panel('warning', said('Check it.'))])
assert.deepEqual(alert('CAUTION'), [panel('error', said('Check it.'))])
assert.deepEqual(alert('Warning'), [panel('warning', said('Check it.'))])
assert.deepEqual(alert('caution'), [panel('error', said('Check it.'))])
})
test('reads an Obsidian callout to a panel by what its word means, any other word info', () => {
const alert = (word: string): unknown => {
const blocks = read(`> [!${word}]\n> Body.\n`)
return typeof blocks === 'string' ? blocks : blocks[0]?.attrs
}
assert.deepEqual(alert('hint'), { panelType: 'tip' })
for (const word of ['success', 'check', 'Done']) assert.deepEqual(alert(word), { panelType: 'success' }, word)
assert.deepEqual(alert('attention'), { panelType: 'warning' })
for (const word of ['danger', 'error', 'failure', 'fail', 'missing', 'BUG']) assert.deepEqual(alert(word), { panelType: 'error' }, word)
for (const word of ['info', 'note', 'question', 'my-type']) assert.deepEqual(alert(word), { panelType: 'info' }, word)
})
test('reads the rest of an alert marker line as the panel first body paragraph, the lines after as the next', () => {
assert.deepEqual(read('> [!NOTE]\n> Line **one**.\n>\n> Two.\n'), [panel('info', paragraph(text('Line '), text('one', strong), text('.')), said('Two.'))])
assert.deepEqual(read('> [!tip] Title\n'), [panel('tip', said('Title'))])
assert.deepEqual(read('> [!tip] Title\n> body\n'), [panel('tip', said('Title'), said('body'))])
assert.deepEqual(read('> [!tip] Title\\\n> body\n'), [panel('tip', said('Title'), said('body'))])
assert.deepEqual(read('> [!NOTE]\\\n> Broken.\n'), [panel('info', said('Broken.'))])
assert.deepEqual(read('> [!NOTE]\n'), normal(panel('info', paragraph())))
assert.deepEqual(read('> > [!WARNING]\n> > Inner.\n'), [bare('blockquote', panel('warning', said('Inner.')))])
})
test('leaves a quote plain where its first line is no alert marker', () => {
for (const markdown of ['> \\[!NOTE]\n> x\n', '> [!NOTE]x\n', '> **[!NOTE]**\n', '> See [!NOTE]\n', '> [!NOTE]**x**\n', '> [!]\n', '> ```\n> [!NOTE]\n> ```\n']) {
const blocks = read(markdown)
assert.equal(typeof blocks !== 'string' && blocks[0]?.type, 'blockquote', markdown)
}
})
test('reads a folded callout to an expand titled by the rest of its marker line, whatever the word', () => {
assert.deepEqual(read('> [!NOTE]- Build log\n>\n> Line.\n'), [node('expand', { title: 'Build log' }, said('Line.'))])
assert.deepEqual(read('> [!bug]+ Open **by** default\n> body\n>\n> Line.\n'), [node('expand', { title: 'Open by default' }, said('body'), said('Line.'))])
const link: AdfMark = { attrs: { href: 'https://example.com' }, type: 'link' }
assert.deepEqual(read('> [!faq]- Why?\n> See [the docs](https://example.com), **now**.\n'), [
node('expand', { title: 'Why?' }, paragraph(text('See '), text('the docs', link), text(', '), text('now', strong), text('.'))),
])
assert.deepEqual(read('> [!NOTE]- Two\\\n> lines\n'), [node('expand', { title: 'Two' }, said('lines'))])
assert.deepEqual(read('> [!faq]- See [x](http://y)\n'), normal(node('expand', { title: 'See x (http://y)' }, paragraph())))
assert.deepEqual(read('> [!faq]- [a **b**](u "t")[c](u) and [d](v)\n'), normal(node('expand', { title: 'a bc (u) and d (v)' }, paragraph())))
assert.deepEqual(read('> [!faq]- <http://y> or <a@b.c> or <http://a\\b>\n'), normal(node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph())))
assert.deepEqual(read('> [!faq]- [a&#10;b](u)\n'), normal(node('expand', { title: 'a\nb (u)' }, paragraph())))
assert.deepEqual(read('> [!faq]- [!adf:mention[@M]{id=5}](u) !adf:inlineCard{url="http://y"}\n'), normal(node('expand', { title: '@M (u) http://y' }, paragraph())))
assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), normal(node('expand', { title: 'Set ==x== here' }, paragraph())))
assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))])
})
test('reads a folded callout inside an expand to a nested expand', () => {
const markdown = '> [!NOTE]- Outer\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n>\n> > [!TIP]\n> >\n> > > [!NOTE]-\n'
assert.deepEqual(read(markdown), normal(node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph())))))
assert.deepEqual(read('- > [!NOTE]-\n'), normal(bare('bulletList', bare('listItem', bare('expand', paragraph())))))
assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), normal(bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph()))))
})
test('reads a bullet list whose every item leads with a task marker to a task list', () => {
assert.deepEqual(read('- [x] Write the spec\n- [ ] Ship **it**\n- [X] Tell\n'), [
bare('taskList', task('DONE', text('Write the spec')), task('TODO', text('Ship '), text('it', strong)), task('DONE', text('Tell'))),
])
assert.deepEqual(read('- [x]\n- [ ]\\\n after\n'), normal(bare('taskList', task('DONE'), task('TODO', text('after')))))
const minted = plainMarkdownToAdf('- [x] Parent\n - [ ] Child\n')
assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [
node(
'taskList',
{ localId: '51470556-7c91-46cb-b140-16e225a9b1f2' },
node('taskItem', { localId: 'e04cbd87-eb04-4737-ae72-6d56d7799874', state: 'DONE' }, text('Parent')),
node('taskList', { localId: '9ab33f3f-8c58-428f-b4eb-343a32a177d6' }, node('taskItem', { localId: 'b16a2f09-9543-499b-8a97-b88fc342b918', state: 'TODO' }, text('Child'))),
),
])
})
test('moves a nested task list beside its item and makes an item holding more than one block a block task item', () => {
assert.deepEqual(read('- [x] Parent\n - [ ] Child\n- [ ] Next\n'), [bare('taskList', task('DONE', text('Parent')), bare('taskList', task('TODO', text('Child'))), task('TODO', text('Next')))])
assert.deepEqual(read('- [x] First.\n\n Second.\n- [ ]\n\n ```\n x\n ```\n'), [
bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.')), node('blockTaskItem', { state: 'TODO' }, bare('codeBlock', text('x')))),
])
assert.deepEqual(read('- [x] A\n - plain\n'), [bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('A'), bare('bulletList', bare('listItem', said('plain')))))])
})
test('leaves mixed, ordered and unmarked lists plain', () => {
for (const markdown of ['- \\[x] a\n', '- [x] a\n- \\[ ] b\n', '- [x] a\n- b\n', '1. [x] a\n', '- [x]a\n', '- **[x]** a\n', '- [x]**a**\n', '- [-] a\n', '- > [x] a\n']) {
const blocks = read(markdown)
assert.notEqual(typeof blocks !== 'string' && blocks[0]?.type, 'taskList', markdown)
assert.equal(JSON.stringify(blocks).includes('taskItem'), false, markdown)
}
assert.deepEqual(read('- plain\n - [ ] nested\n'), [bare('bulletList', bare('listItem', said('plain'), bare('taskList', task('TODO', text('nested')))))])
})
test('reads a == pair to the editor default highlight, Yellow200 #f8e6a0 in @atlaskit/adf-schema 57.6.8', () => {
assert.deepEqual(read('a ==hi there== b\n'), [paragraph(text('a '), text('hi there', highlight), text(' b'))])
assert.deepEqual(read('**==hi==** b\n'), [paragraph(text('hi', highlight, strong), text(' b'))])
assert.deepEqual(read('==**a**_b_ `c`==\n'), [paragraph(text('a', highlight, strong), text('b', highlight, em), text(' ', highlight), text('c', code))])
assert.deepEqual(read('==`a`==\n'), [paragraph(text('a', code))])
assert.deepEqual(read('x==y==z ==a == b==, (==c==) _d_==e==\n'), [paragraph(text('x==y==z '), text('a == b', highlight), text(', ('), text('c', highlight), text(') '), text('d', em), text('e', highlight))])
assert.deepEqual(read('😀==b== ==c==😀 é==d==\n'), [paragraph(text('😀'), text('b', highlight), text(' '), text('c', highlight), text('😀 é==d=='))])
assert.deepEqual(read('この機能は==日本語==でのみ、中文==重点==内容、ภาษา==ไทย==ดี 𠀀==𠀁==𠀂 이 기능은 ==한국어==에서만 サーバー==停止==中 このiPhone==専用==アプリ\n'), [
paragraph(
text('この機能は'), text('日本語', highlight), text('でのみ、中文'), text('重点', highlight), text('内容、ภาษา'), text('ไทย', highlight), text('ดี 𠀀'), text('𠀁', highlight),
text('𠀂 이 기능은 '), text('한국어', highlight), text('에서만 サーバー'), text('停止', highlight), text('中 このiPhone'), text('専用', highlight), text('アプリ'),
),
])
assert.deepEqual(read('# ==h==\n\n| ==c== |\n| --- |\n'), [
node('heading', { level: 1 }, text('h', highlight)),
bare('table', bare('tableRow', bare('tableHeader', paragraph(text('c', highlight))))),
])
assert.deepEqual(read('> [!NOTE]\n> ==x==\n'), [panel('info', paragraph(text('x', highlight)))])
assert.deepEqual(read('==a==\\\n==b==\n'), [paragraph(text('a', highlight), { type: 'hardBreak' }, text('b', highlight))])
})
test('leaves a == no pair flanks as text', () => {
for (const markdown of ['\\==x==\n', '==x\\==\n', 'a == b == c\n', 'if a==b and c==d then\n', 'a==b== c\n', '==a==b\n', '====\n', '`==x==`\n', '==a\\\nb==\n', '**==a**==\n', '==a', '== a==\n', '==a ==\n']) {
assert.equal(JSON.stringify(read(markdown)).includes('backgroundColor'), false, markdown)
}
})
test('reads what the reduction wrote back to the node it reduced, less the attributes it drops', () => {
const localId = '01a0d99b-1f59-7e2c-a3d4-62c1f0b8e7a1'
for (const panelType of ['info', 'note', 'tip', 'warning', 'error']) {
assert.deepEqual(roundTripped(node('panel', { localId, panelType }, said('Check.'))), [panel(panelType, said('Check.'))], panelType)
}
const expand = node('expand', { localId, title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))
assert.deepEqual(roundTripped(node('panel', { panelType: 'tip' }, paragraph()), node('expand', { title: 'Empty' }, paragraph())), normal(panel('tip', paragraph()), node('expand', { title: 'Empty' }, paragraph())))
assert.deepEqual(roundTripped(expand), [node('expand', { title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))])
const tasks = bare(
'taskList',
node('taskItem', { localId, state: 'DONE' }, text('Write')),
bare('taskList', task('TODO', text('Review'))),
node('blockTaskItem', { state: 'TODO' }, said('First.'), said('Second.')),
node('blockTaskItem', { state: 'DONE' }, bare('codeBlock', text('x'))),
)
const plainTasks = bare(
'taskList',
task('DONE', text('Write')),
bare('taskList', task('TODO', text('Review'))),
node('blockTaskItem', { state: 'TODO' }, said('First.'), said('Second.')),
node('blockTaskItem', { state: 'DONE' }, bare('codeBlock', text('x'))),
)
assert.deepEqual(roundTripped(tasks), [plainTasks])
const colour: AdfMark = { attrs: { color: '#c6edfb' }, type: 'backgroundColor' }
assert.deepEqual(roundTripped(paragraph(text('a '), text('hi', colour, strong), text(' b'))), [paragraph(text('a '), text('hi', highlight, strong), text(' b'))])
assert.deepEqual(roundTripped(paragraph(text('=', colour), text(' '), text('a==b', colour))), [paragraph(text('=', highlight), text(' '), text('a==b', highlight))])
assert.deepEqual(roundTripped(paragraph(text('x'), text('y', colour))), [said('xy')])
assert.deepEqual(roundTripped(node('expand', { title: '**x** [y](z)' }, said('b'))), [node('expand', { title: '**x** [y](z)' }, said('b'))])
})
test('reads text the writer kept from reading as a marker back as text', () => {
const quote = bare('blockquote', said('[!NOTE] x'))
const list = bare('bulletList', bare('listItem', said('[x] a')), bare('listItem', said('[ ] b')))
assert.deepEqual(roundTripped(said('==x== a==b 日==本==語'), quote, list), [said('==x== a==b 日==本==語'), quote, list])
assert.deepEqual(roundTripped(bare('taskList', task('DONE', text('[x] ==a==')))), [bare('taskList', task('DONE', text('[x] ==a==')))])
})
test('keeps what markdownToAdf reads that no row reads, and refuses only what it refuses', () => {
assert.deepEqual(read('!adf:panel warning\n- [x] a\n!adf:/panel\n'), [panel('warning', bare('taskList', task('DONE', text('a'))))])
assert.deepEqual(read('!adf:taskList\n!adf:taskItem TODO\nb\n!adf:/taskItem\n!adf:/taskList\n'), [bare('taskList', task('TODO', text('b')))])
const listed = plainMarkdownToAdf('!adf:taskList {localId=01a0eeb2-be48-7ea7-8587-db5e013c374a}\n- [ ] b\n!adf:/taskList\n')
assert.deepEqual(listed.ok ? listed.value.content?.[0]?.attrs : listed.error.code, { localId: '01a0eeb2-be48-7ea7-8587-db5e013c374a' })
const future = bare('futureBlock', text('==x=='))
const carried = adfToMarkdown(document(future))
assert.deepEqual(carried.ok ? read(carried.value) : carried.error.code, [future])
const uncarriable = bare('taskList', node('taskItem', { extra: { a: 1 }, state: 'TODO' }, text('a')))
const carriedPanel = adfToMarkdown(document(node('panel', { extra: true, panelType: 'info' }, uncarriable)))
const restored = carriedPanel.ok ? plainMarkdownToAdf(carriedPanel.value) : carriedPanel
assert.deepEqual(restored.ok ? restored.value.content : restored.error.code, [node('panel', { extra: true, panelType: 'info' }, uncarriable)])
const red: AdfMark = { attrs: { color: '#ff0000' }, type: 'backgroundColor' }
const held = paragraph(text('a ==b== c', red), text(' ==d '), { attrs: { note: 'x' }, text: 'e==f', type: 'text' }, text(' g=='))
const spelled = adfToMarkdown(document(held))
assert.deepEqual(spelled.ok ? read(spelled.value) : spelled.error.code, [paragraph(text('a ==b== c', red), text(' '), text('d ', highlight), { attrs: { note: 'x' }, text: 'e==f', type: 'text' }, text(' g', highlight))])
const lossless = markdownToAdf('> [!NOTE]\n\n- [x] ==a==\n')
assert.deepEqual(lossless.ok ? lossless.value.content : lossless.error.code, [bare('blockquote', said('[!NOTE]')), bare('bulletList', bare('listItem', said('[x] ==a==')))])
assert.equal(read('!adf:panel\n'), 'malformed-directive')
const refusal = (markdown: string): unknown => {
const parsed = plainMarkdownToAdf(markdown)
return parsed.ok ? parsed.value : [parsed.error.code, parsed.error.message]
}
for (const markdown of ['> [!tip] ![a](u)\n', '> [!NOTE]- ![a](u)\n', '- [x] ![a](u)\n']) {
assert.deepEqual(refusal(markdown), ['unmappable-image', 'an image fits only as a paragraph of its own: this one shares a line with a marker'], markdown)
}
for (const markdown of ['> [!tip]\n> ![a](u)\n', '> [!tip] t\n> ![a](u)\n', '> [!NOTE]- t\n> ![a](u)\n', '- [x]\n ![a](u)\n']) {
assert.deepEqual(refusal(markdown), ['unmappable-image', 'an image fits only as a paragraph of its own: this one continues the paragraph a marker opens, which a blank line before it ends'], markdown)
}
let deep = 'x\n'
for (let level = 0; level < largestNesting; level += 1) deep = `> ${deep}`
assert.equal(typeof read(deep), 'object')
})
+60
View File
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { mintTaskIds } from './task-ids.ts'
const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
function taskIds(document: AdfDocument): unknown[] {
const ids: unknown[] = []
const pending: AdfNode[] = [...(document.content ?? [])].reverse()
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
if (['blockTaskItem', 'taskItem', 'taskList'].includes(node.type)) ids.push(node.attrs?.['localId'])
for (const child of [...(node.content ?? [])].reverse()) pending.push(child)
}
return ids
}
function tasks(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 }
}
test('mints each task node lacking a localId a UUID v4 in document order, unique and the same every run', () => {
const unminted = (): AdfDocument => tasks({ content: [{ attrs: { state: 'TODO' }, type: 'taskItem' }, { content: [{ attrs: { state: 'DONE' }, type: 'blockTaskItem' }], type: 'taskList' }], type: 'taskList' })
const minted = unminted()
mintTaskIds(minted, '- [ ] a\n', new Set())
const ids = taskIds(minted)
assert.equal(ids.length, 4)
for (const id of ids) assert.match(String(id), uuidV4)
assert.equal(new Set(ids).size, 4)
const again = unminted()
mintTaskIds(again, '- [ ] a\n', new Set())
assert.deepEqual(taskIds(again), ids)
const other = unminted()
mintTaskIds(other, '- [ ] b\n', new Set())
assert.equal(taskIds(other).some((id) => ids.includes(id)), false)
})
test('keeps a localId the document spells and skips it when minting, a carried one too', () => {
const first = tasks({ type: 'taskList' })
mintTaskIds(first, 'x', new Set())
const [taken] = taskIds(first)
const carried: AdfNode = { content: [{ attrs: { localId: String(taken) }, type: 'taskList' }], type: 'futureBlock' }
const spelled = tasks({ type: 'taskList' }, carried)
mintTaskIds(spelled, 'x', new Set([carried]))
const [minted, kept] = taskIds(spelled)
assert.equal(kept, taken)
assert.notEqual(minted, taken)
assert.match(String(minted), uuidV4)
})
test('leaves a node the carry restores as carried, minting neither it nor what it holds', () => {
const list = (): AdfNode => ({ content: [{ attrs: { state: 'TODO' }, type: 'taskItem' }], type: 'taskList' })
const carriedList = list()
const carriedPanel: AdfNode = { attrs: { panelType: 'info' }, content: [list()], type: 'panel' }
const future: AdfNode = { content: [list()], type: 'futureBlock' }
const document = tasks(carriedList, carriedPanel, future)
mintTaskIds(document, 'x', new Set([carriedList, carriedPanel]))
assert.deepEqual(document, tasks(list(), { attrs: { panelType: 'info' }, content: [list()], type: 'panel' }, { content: [list()], type: 'futureBlock' }))
})
+70
View File
@@ -0,0 +1,70 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
const taskTypes = new Set(['blockTaskItem', 'taskItem', 'taskList'])
export function mintTaskIds(document: AdfDocument, markdown: string, carried: ReadonlySet<AdfNode>): void {
const taken = new Set<string>()
for (const node of preorder(document, () => true)) {
const localId = nodeAttrs(node)['localId']
if (typeof localId === 'string') taken.add(localId)
}
const seed = hash128(markdown).join(' ')
let count = 0
for (const node of preorder(document, (held) => !carried.has(held) && blockNodeModel(held.type)?.contentModel === 'block')) {
if (!taskTypes.has(node.type) || carried.has(node) || typeof nodeAttrs(node)['localId'] === 'string') continue
let localId = ''
do {
count += 1
localId = uuidV4(hash128(`${seed} ${count}`))
} while (taken.has(localId))
taken.add(localId)
node.attrs = { ...node.attrs, localId }
}
}
function* preorder(document: AdfDocument, entered: (node: AdfNode) => boolean): Generator<AdfNode> {
const pending: AdfNode[] = []
const pushReversed = (nodes: readonly AdfNode[]): void => {
for (let index = nodes.length - 1; index >= 0; index -= 1) {
const node = nodes[index]
if (node !== undefined) pending.push(node)
}
}
pushReversed(document.content ?? [])
for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
yield node
if (entered(node)) pushReversed(nodeContent(node))
}
}
// cyrb128, public domain.
function hash128(text: string): number[] {
let h1 = 1779033703
let h2 = 3144134277
let h3 = 1013904242
let h4 = 2773480762
for (let index = 0; index < text.length; index += 1) {
const unit = text.charCodeAt(index)
h1 = h2 ^ Math.imul(h1 ^ unit, 597399067)
h2 = h3 ^ Math.imul(h2 ^ unit, 2869860233)
h3 = h4 ^ Math.imul(h3 ^ unit, 951274213)
h4 = h1 ^ Math.imul(h4 ^ unit, 2716044179)
}
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067)
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233)
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213)
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179)
h1 ^= h2 ^ h3 ^ h4
h2 ^= h1
h3 ^= h1
h4 ^= h1
return [h1 >>> 0, h2 >>> 0, h3 >>> 0, h4 >>> 0]
}
function uuidV4(lanes: readonly number[]): string {
const hex = lanes.map((lane) => lane.toString(16).padStart(8, '0')).join('')
const variant = ((Number.parseInt(hex.charAt(16), 16) & 3) | 8).toString(16)
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ConvertFault } from '../result.ts' import type { ConvertFault } from '../result.ts'
import { backslashEscape, claimsPipeLine, trimSpace } from './commonmark-grammar.ts' import { backslashEscape, claimsPipeLine, trimSpace } from './commonmark/grammar.ts'
const alignmentCell = /^:-+:?$|^-+:$/ const alignmentCell = /^:-+:?$|^-+:$/
const delimiterCell = /^-+$/ const delimiterCell = /^-+$/
+85
View File
@@ -0,0 +1,85 @@
import { isWordCharacter } from './commonmark/emphasis-matching.ts'
export type Flavour = 'lossless' | 'plain'
type AlertMarker = { folded: boolean; length: number; panelType: string }
export const foldedAlertMarker = '[!NOTE]-'
export const highlightDelimiter = '=='
const alertWords: Readonly<Record<string, string>> = {
error: 'CAUTION',
info: 'NOTE',
note: 'IMPORTANT',
success: 'TIP',
tip: 'TIP',
warning: 'WARNING',
}
// ー, ー and the kana voicing marks sit outside the kana scripts; Script_Extensions would also take Latin combining marks.
const boundingScript = /^[\u3099\u309a\u30fc\uff70\uff9e\uff9f\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Khmer}\p{Script=Lao}\p{Script=Myanmar}\p{Script=Thai}]$/u
const panelTypesByWord: Readonly<Record<string, string>> = {
attention: 'warning',
bug: 'error',
caution: 'error',
check: 'success',
danger: 'error',
done: 'success',
error: 'error',
fail: 'error',
failure: 'error',
hint: 'tip',
important: 'note',
missing: 'error',
success: 'success',
tip: 'tip',
warning: 'warning',
}
export function alertMarker(panelType: unknown): string {
const word = typeof panelType === 'string' && Object.hasOwn(alertWords, panelType) ? alertWords[panelType] : undefined
return `[!${word ?? 'NOTE'}]`
}
export function readAlertMarker(text: string): AlertMarker | undefined {
const marker = /^\[!([\w-]+)\]([+-]?)/.exec(text)
if (marker === null) return undefined
const word = (marker[1] ?? '').toLowerCase()
const panelType = Object.hasOwn(panelTypesByWord, word) ? panelTypesByWord[word] : undefined
return { folded: marker[2] !== '', length: marker[0].length, panelType: panelType ?? 'info' }
}
// A marker leads text that ends at it or goes on past whitespace or a hard break.
export function leadingMarker<T extends { length: number }>(text: string, read: (text: string) => T | undefined): T | undefined {
const marker = read(text)
if (marker === undefined) return undefined
const rest = text.slice(marker.length, marker.length + 2)
return rest === '' || /^(?:[ \t\n]|\\\n)/.test(rest) ? marker : undefined
}
// A delimiter is bounded outside by the code point beyond it, and flanks by the character inside it.
export function highlightFlanking(source: string, index: number): { closes: boolean; opens: boolean } {
const end = index + highlightDelimiter.length
const before = Array.from(source.slice(Math.max(0, index - 2), index)).at(-1) ?? ''
const after = Array.from(source.slice(end, end + 2))[0] ?? ''
return { closes: flanks(before) && bounds(after, before), opens: flanks(after) && bounds(before, after) }
}
function bounds(outside: string, inside: string): boolean {
return !isWordCharacter(outside) || boundingScript.test(outside) || boundingScript.test(inside)
}
function flanks(character: string): boolean {
return character !== '' && !/\s/.test(character)
}
export function taskMarker(state: unknown): string {
return state === 'DONE' ? '[x]' : '[ ]'
}
export function readTaskMarker(text: string): { length: number; state: 'DONE' | 'TODO' } | undefined {
const marker = /^\[([ xX])\]/.exec(text)
if (marker === null) return undefined
return { length: marker[0].length, state: marker[1] === ' ' ? 'TODO' : 'DONE' }
}
+1 -1
View File
@@ -1,2 +1,2 @@
// Levels count per AGENTS.md §11. // A level is one block-list recursion in either direction: a readable list's items sit one below it, its directive spelling's two.
export const largestNesting = 500 export const largestNesting = 500
+3 -3
View File
@@ -1,8 +1,8 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { readFileSync, readdirSync } from 'node:fs'
import test from 'node:test'
const sourceRoot = dirname(fileURLToPath(import.meta.url)) const sourceRoot = dirname(fileURLToPath(import.meta.url))
const union = /export type ConvertErrorCode =\n((?:\s+\| '[a-z-]+'\n)+)/ const union = /export type ConvertErrorCode =\n((?:\s+\| '[a-z-]+'\n)+)/
@@ -25,7 +25,7 @@ function calledCodes(): string[] {
return [...called].sort() return [...called].sort()
} }
// The list is frozen at 0.1.0 (AGENTS.md §8), so a code outliving its cause is a removal that costs a MAJOR. // Removing a code is breaking (docs/decisions.md §The code list), so a code must not outlive its cause.
test('every ConvertErrorCode is the code of a production call site, and every call site names a declared one', () => { test('every ConvertErrorCode is the code of a production call site, and every call site names a declared one', () => {
assert.deepEqual(calledCodes(), declaredCodes()) assert.deepEqual(calledCodes(), declaredCodes())
}) })
-823
View File
@@ -1,823 +0,0 @@
# Todo history
The done `todo.md` items in full, as they were written. `todo.md` keeps a one-line summary of each.
## Milestones
- [x] **0 — Scaffold.** `package.json` per §6, `tsconfig.json`, `.npmrc` (`save-exact=true`), the
Docker tooling, `renovate.json` (§9), and `.gitea/workflows/ci.yml` gating branches:
`runs-on: docker-host`, actions pinned to semver tags.
- [x] **1a — The directive grammar** (`spec/flavour.md`): inline/block/leaf directive forms,
attributes, escaping, nesting, canonical form, the opaque-carry spelling, the raw-HTML
input policy.
- [x] **1b — Block node syntaxes** in `spec/flavour.md`: panel, expand/nestedExpand, the media
family, the pipe-vs-directive table rule and the directive table form, task and decision
lists, layout, extensions, syncBlock.
- [x] **1c — Inline node syntaxes and marks** in `spec/flavour.md`: mention, emoji, status, date,
inlineCard, mediaInline; underline, subsup, textColor, border; the spelling for text nodes
whose whitespace CommonMark cannot hold (literal newlines, leading or trailing spaces) —
escape-based, never literal, since pipe cells trim and pad. At `mediaInline`, check real
payloads for external-URL support — if it exists, revisit the media section's
mid-text-image error and its "no slot" ground.
- [x] **1d — Corpus start** (§10): checked-in fixtures per spec'd node, in `corpus/`, one
directory per contract kind (`corpus/README.md`).
**Settled** (the maintainer, 2026-08-26): the nodes CommonMark spells get directive sections
of their own, rather than riding the opaque carry. Per `@atlaskit/adf-schema` 57.1.0 every
block node it spells — `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`,
`orderedList`, `paragraph`, `rule` — carries a `localId` with no spelling, `codeBlock` also
`hideLineNumbers`, `uniqueId` and `wrap`, `blockquote` also marks, and `hardBreak` `text`
and `localId`; 2f gives each a place, and the plain spelling stays wherever the attributes
are absent. That hands the two collision sites in `corpus/unspellable/` the second spelling
they lacked, so each takes the directive form as a `media` with an empty `alt` already does
(`spec/flavour.md`, the CommonMark image): a `codeBlock` whose info string is empty, and an
`orderedList` starting at 1.
**Also blocked**: the link rule covers destination spaces only, so two shapes
have no spelling and are refused meanwhile — href `https://example.com/a)b` and title
`He said "hi"`, both in `corpus/unspellable/`. Two defensible spellings each — angle
brackets or a backslash escape, and for titles `'…'` or `(…)` besides — so §8 leaves
the pick here. **Also blocked**: block separation is unstated for a CommonMark block beside a
directive block in a container body — an `expand` whose content is `paragraph` "A" then a
`panel` (`panelType` `warning`) holding "B" spells `A` and `:::panel warning` either on
consecutive lines or with a blank line between. Two defensible spellings, so §8 leaves the
pick here; the answer governs every unknown node type too, the block carry counting as a
CommonMark block since its spelling is a fenced code block.
`unspelled-block-separation` refuses the pair meanwhile, an empty paragraph's
`::paragraph` beside a CommonMark block included — and, since a `mediaSingle`'s spelling now
follows whether CommonMark can spell its URL, two sibling images differing only by an
`&amp;` land in the same refusal.
- [x] **1d1 — The CommonMark subset**: blockquote, bulletList, codeBlock, heading, orderedList,
paragraph, rule, listItem, hardBreak, text, code spans, and the `code`, `em`, `link`,
`strike` and `strong` marks — one mark per text node; nesting is 1d3's.
- [x] **1d2 — Block nodes**: panel, expand/nestedExpand, the media family and the CommonMark
image shape, both table forms, task and decision lists, layout, extensions, syncBlock —
with the reserved `marks` attribute and the fence lengths nesting forces.
- [x] **1d3 — Inline nodes and marks**: date, emoji, inlineCard, mediaInline, mention, status;
border, subsup, textColor, underline; the content slot's `text` attribute and the
`:text{text="…"}` whitespace spelling.
- [x] **2 — `adfToMarkdown`.** First real code. Each sub-item turns one corpus directory green;
the two that have no fixtures yet write them in the same chunk, tests first (§10).
- [x] **2a — The runner and the CommonMark subset.** The corpus runner: walk
`corpus/round-trip/`, assert `adfToMarkdown` emits each `.md` byte for byte. Decide here
where §10's coverage check lives, and gate that every `corpus/**/*.json` re-serializes to
itself under the library's own canonical serializer — one implementation, keys sorted, two
spellings: two-space indent for the corpus files and the block carry's body, compact for
the inline carry. `commonmark-subset/` green.
- [x] **2b — Block nodes.** `block-nodes/` green. A nested list that cannot interrupt the block
above it is refused meanwhile, not spelled: the maintainer's answer on tight-versus-blank
separation turns that refusal into an emission. The test is broader than the name it
carries — `interruptsParagraph` reads the next list alone, so a list after a block no
paragraph continues, a code block say, is refused too — and the same answer narrows it.
Block separation becomes
`separationBetween(previous, next, container)` here — a boolean cannot hold the third case
`spec/flavour.md` states for two directive blocks in a container body, and the maintainer's
answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give
the emitter's refusals a corpus home while the directories grow: `corpus/unspellable/`,
a `.json` beside the `ConvertErrorCode` it must return, the emitter half of `corpus/errors/`.
- [x] **2c — Inline nodes and marks.** `inline-nodes/` green. `InlineSegment` splits into its
two axes — escapability (`backslash`, `bracketed`, `none`) and the emphasis role. A lone
surrogate in a text node emits verbatim and becomes U+FFFD on any UTF-8 encode, a §2 break
plain text still holds open — attribute values already escape it. The pipe form's fallback
reads the emitted segments rather than naming the nodes whose attribute values spell a pipe
as syntax, so 2e's `\u007c` narrows it in one place.
- [x] **2d — The opaque carry** (§3). Fixtures and emitter together, into
`corpus/round-trip/opaque-carry/`: an unknown node in both positions, the reserved `adf`
info string, and the `codeBlock` whose language is `adf` — carried whole ahead of the
attribute fallback 2e owes, since the reservation leaves that node no other spelling
whatever 1d decides for its `localId`.
- [x] **2e — Carve-outs and combinations.** Fixtures and emitter together, into
`corpus/round-trip/combinations/`.
- [x] **2e1 — The carve-outs and the claimed line.** The three carve-outs
and their escapes, and a paragraph line inside a container body shaped like a closing
fence (`:::`, `::: x`). Guard `fenceNestingFault`'s bare-run pop here too — a run shorter
than the open fence is a fault, not a close — which today's emitter cannot reach.
- [x] **2e2 — Mark runs and the runs a carry breaks.** The longest-run rule, attributes
included, and a mark spelling that cannot open where it sits (`un**-real**istic`; the spec
owes the carry a trigger). One mark vocabulary lands here, before 2e3 changes the
attribute spelling: `emphasisSpellings`, `linkAttributes` and the `code`/`link` names join
the mark table (3a parted it across `adf/mark-attributes.ts` and
`markdown/mark-spellings.ts`), which holds four of the nine marks while the rest are
branch literals in the emitter — and the parser (3) needs every name to make `:em[x]`
the named error `spec/flavour.md` promises.
- [x] **2e3 — Attribute canonicalization and the quoted value's escape.**
**Settled** (the maintainer, 2026-08-26): a quoted attribute value escapes `` ` ``, `&`,
`<` and `|` as `\u0060`, `\u0026`, `\u003c` and `\u007c`, in every directive, block and
inline alike — the constructs those four open all bind at or before a directive does, and
nothing else reaches into `{attrs}`. Emitted attributes being inert leaves 3 free to keep
CommonMark's own precedence between a directive and a code span, and collapsed
`escaping: 'attribute'` into `none`. The escaper's link-opener scan skips emitted syntax
to match: a `](` inside a directive escapes no text `[`.
- [x] **2e4 — The carry's fallback triggers.** `spec/flavour.md` carries a node its section
cannot spell — an attrs key no section lists, a value that is not the section's type, an
arg slot holding no bare token, marks no nesting spells — where the emitter still refuses,
which leaves the refusals a container's own spelling owns. The flanking trigger 2e2
added to that list is the odd one out: `unspellableMark` finds it after assembly and
names a mark type against the line's path, so the failing run needs identifying before
the carry can replace the refusal `mark-inside-word` pinned.
- [x] **2e5 — Combined documents and the collision property.** Documents combining nodes rather
than isolating one, and the gate's collision property: no two corpus documents may emit
the same bytes — one spelling for two documents is a round-trip break no parser can undo,
and it is provable without one.
**Settled** (the maintainer, 2026-08-27): the approximation this item inherited — flanking
exact, CommonMark's *matching* unmodelled — had two round-trip breaks reachable by hand,
so the emitter now models the matching. `process_emphasis` runs over the runs the emitter
wrote (`emphasis-matching.ts`) and a pair it hands to another delimiter rides the carry,
which is what the multiple-of-3 rule did to the em in `un*a**b*****c**istic`. A delimiter
run in text now escapes wherever CommonMark could open or close with it, not only open:
one that could only close stole the spelling around it (`un*a* b*istic`), and escaping
both ways keeps every delimiter the emitter did not write out of the matching. The
canonical form gained a backslash where a run only closes — `\*not emphasis\*`, and
2e1's `carve-out-strike` a third and fourth.
- [x] **2f — The attributes CommonMark cannot hold.** 1d's settled answer: the block nodes
CommonMark spells — `blockquote`, `bulletList`, `codeBlock`, `heading`, `listItem`,
`orderedList`, `paragraph`, `rule` — get directive sections in `spec/flavour.md` carrying
`localId`, `codeBlock`'s `hideLineNumbers`, `uniqueId` and `wrap`, and `blockquote`'s
marks, while `hardBreak`'s `text` and `localId` join the inline directive it already has.
The plain spelling stays wherever those attributes are absent, so only a node that carries
one takes the directive form — which is what keeps a real payload readable rather than a
page of carried JSON. Fixtures and emitter together, and the three documents the answer
settles leave `corpus/unspellable/` as round-trip pairs: `block-local-id`,
`code-block-empty-language`, `ordered-list-start-one`.
**Settled** (the maintainer, 2026-08-27): the `codeBlock` directive's body is one fenced
code block, the language staying on the fence line so every renderer still highlights it;
a language no info string holds — empty, a backtick, edge whitespace, an entity reference
or the reserved `adf` — rides the `language` attribute with the fence bare, which retires
2d's carry for the reserved name along with the premise that left it no other spelling.
The plain spelling gives way wherever it cannot render what the node carries rather than
only where it has no place for it, so a heading level absent or outside 1-6 and an order
whose markers would run past 999999999 take the directive form too, and
`ambiguous-attribute-spelling`, `unspellable-code-block-language`,
`unspellable-list-marker`, `unspelled-block-marks` and `unsupported-heading-level` leave
`ConvertErrorCode`; content and placement refusals stay, which leaves the directive form
spelling an empty list or a non-`listItem` child that the plain form refuses. `order` is
the first marker, so `order: 1` keeps the plain `1.` — what a real payload carries — and a
list carrying no `order` has no number to take and takes the directive form.
2f raises what 1d's unspelled block separation costs: a single `localId` on a paragraph
beside a plain one now refuses every container body that is a directive's — a panel, an
expand, a table cell — where before 2f the attribute refused the document anyway.
- [x] **3 — `markdownToAdf` (`0.1.0`).** Each sub-item lands the fixtures its own code reads, and
the runner grows a parse half as they do: readers for `corpus/normalization/` (setext,
indented code, loose lists, `*`/`+`
bullets, entity references, soft wraps — one-way, the markdown not canonical) and
`corpus/errors/` (a markdown input per named error, the code in a `.error` beside it) with
the first fixture each. `commonmark-subset/` cannot be the first to green — `::paragraph`
and `:hardBreak{}` sit in it — so 3b through 3f answer to their own tests and the one-way
fixtures they land, and 3g is where the first directory reads back. The raw-HTML element
mapping is empty until milestone 6, so at `0.1.0` every raw-HTML construct in input — a
block, an inline tag, a comment, a processing instruction — is a named error. Input is where
unbounded nesting actually arrives, so §11's 500 binds all three of the emitter's guards
here: block depth at 3c and again at 3f's container fences, inline and mark depth at 3f and
3i, a carried value's JSON at 3j, where `isJsonValue` already bounds it.
- [x] **3a — The hierarchy.** Mechanical, ahead of the first parser file: `src/adf/` and
`src/markdown/` (`html/` arrives with its first file, 6-7), the grammar module shared
inside `markdown/`, and `emphasis-matching.ts` beside it — the parser reuses it whole,
`delimiterFlags` and `matchEmphasis` taking CommonMark's own run vocabulary rather than
the emitter's, so no second `process_emphasis` exists to drift from the first.
`block-directives.ts` and `inline-directives.ts` each part by file, a node table
milestones 6-7 need in `adf/` beside a markdown spelling that belongs in `markdown/`.
`directive-attributes.ts` cannot: `vocabularyPairs` walks the vocabulary and spells the
value in one pass, the type check living inside `spellAttributeValue`, so the check comes
out as its own predicate and goes to `adf/` with the walk while the spelling stays in
`markdown/`, `isBareToken` with it — only spelling calls it. `markSpellings` is the one
table whose keys part rather than its file, so key the markdown half off the ADF half's
type: a mark named in one and not the other is then a compile error instead of a false
refusal. `spellDestination`, `spellTitle` and `balanced` leave `markdown-inline.ts` here
too — CommonMark destination spelling `emitLink` and `tryImageLine` share, and the six
concerns that file carries are one fewer for it. `AttributeKind` and `AttributeVocabulary`
follow the walk into `adf/`, the vocabulary a string-typed attribute grammar needs and
HTML will want too, not a markdown spelling.
**Settled** (the maintainer, 2026-08-27): `markdown/` parts here as well, into `emit/` and
`parse/` with the shared set at the root — the grammar module, emphasis matching,
the tables' markdown halves — and `parse/` arriving with 3b's first
file, the rule `html/` already follows. And the node tables, a second copy of
`spec/flavour.md`'s prose whose mistyped attribute name degrades into a false refusal no
test catches, get their guard: a test reads the spec's node sections, takes each
`name (type)` list and asserts it equals the table, leaving the spec the source a human
writes with no build step and no generated file. It is built at 3g, where a wrong entry
starts refusing documents.
- [x] **3b — The leaf blocks.** The line walk that opens and closes a block, ahead of any inline
parsing: paragraph, ATX and setext heading, thematic break, fenced and indented code
block, the HTML block whose lines it swallows whether or not the construct then errors,
the link reference definitions a closing paragraph gives up, and the blank lines between
them. The openers are `commonmark-grammar.ts`'s — one table answers both directions, or
the emitter under-escapes a line the parser reads as a block and §2 breaks in silence —
the HTML block's start conditions excepted, which are new here since the emitter writes
none. Block-level claiming lands here too: a colon run or an unescaped leading `|` is
claimed, the parse behind it 3f's and 3h's, a claim with nothing yet to parse it the named
error the claim promises meanwhile. The runner's parse half comes with it, and the first
`normalization/` fixtures, holding inline-trivial content so 3d and 3e add beside them
rather than editing them.
- [x] **3c — The container blocks.** Blockquote, bullet and ordered list: the continuation a
marker's width sets, lazy continuation, and the tightness ADF does not record — `> `
repeated being two bytes a level, so this is the cheapest way to reach §11's 500. 3b's leaf
readers scan the physical line themselves, so a container re-cuts the walk rather than adding
to it: the open containers' prefix comes off the line first and the readers take one line at a
time, `LeafBlock` renamed with the union they join and `blockNode`'s chain gaining their
branches.
**Settled** (the maintainer, 2026-08-27): a claimed line ends lazy continuation, so a
closing fence on the line after a blockquote's open paragraph closes its container instead
of continuing the paragraph CommonMark would fold it into. Claiming at block level is
already absolute, and this binds input alone — 2e1's `closing-fence-line` orders the
emitter's blockquote away from the edge either way — so `spec/flavour.md`'s claiming
paragraph gains the case here. And 2b's tight-versus-blank, one answer for both
directions: the tight spelling stays wherever it parses back, a blank line going in only
where the nested list would be swallowed — `interruptsParagraph` inverted from a refusal
into the separation it names, and `spec/flavour.md`'s "none between a nested list and a
CommonMark block above it" gaining that exception. Every fixture spelled tight today keeps
its bytes, and `nested-list-tight` becomes the round-trip pair `nested-list-separation`.
- [x] **3d — Inline text.** The inline scanner over a block's content: backslash escapes, entity
references decoding to their characters, code spans and the literal they hold — directive
syntax and `~~` included — CommonMark's own hard breaks, a trailing backslash and two
trailing spaces alike, a soft line break as one space, the fenced info string's own decoding
the block walk leaves raw, and the raw inline tag, comment and processing instruction
refused by name, recognized by the `commonmark-grammar.ts` predicates the emitter already
escapes against, under 3b's one-table rule.
**Settled** (the maintainer, 2026-08-30): entity references decode against HTML5's whole
named table, checked in packed (§5) — a curated subset leaves 3k an exception class and a
cutoff line nobody can defend. And the escape superset the emitter reads for raw HTML
tightens into one precise CommonMark inline reader both directions share, the email
autolink parting off as 3e's own predicate: refusing on the superset would refuse
`1 <b 2`, a fourth carve-out §4 and the README do not list. `holdsEntityReference` reads
the table for the same reason, so `&notareference;` is emitted bare.
- [x] **3e — Emphasis and links.** `_`, `*` and `~~` runs through `matchEmphasis` to the `em`,
`strong` and `strike` marks; links inline and reference, 3b's definitions resolved here,
autolinks, and the image gap's named errors — a titled image, and one amid other text.
`spec/flavour.md` does not yet pin `~`'s `can_open`/`can_close`, which is transcription
rather than a decision: `delimiterFlags` already gives it CommonMark flanking, as for `*`,
and §8 fixed that the moment the emitter shipped.
**Settled** (the maintainer, 2026-08-27): 1d's deferred pair takes the backslash inside
the delimiters it already has — `[a](https://example.com/a\)b)` and
`[a](/url "He said \"hi\"")`. `<…>` stays reserved for the destination holding a space,
where nothing else works, so each construct keeps one spelling and a destination holding
both composes. `link-destination-parenthesis` and `link-title-quote` become round-trip
pairs.
**Settled** (the maintainer, 2026-08-31): the destination escapes only the parenthesis it
leaves unbalanced, so `/wiki/Foo_(bar)` keeps its bytes, and the backslash stays refused in
both the destination and the title — which leaves `[a](/a\b)` a §2 hole 3k's exception list
answers, as `<http://x?a=1&amp;b=2>` is, autolinks decoding neither escapes nor references.
The image gap mints `unmappable-image`, mirroring `unmappable-html` — a construct in input
no ADF node carries. And the CommonMark image shape lands here rather than at 3h: once
`[…](…)` reads, a lone `![alt](url)` would otherwise misparse as text plus a link, so 3h
keeps the rest of the media family and loses only that line.
**Settled** (the maintainer, 2026-08-31, on the review): an empty link text — `[](/u)` —
leaves the brackets the text they are rather than minting a refusal or dropping the
destination, giving the label back the way an unresolved pair does, so the shortcut behind
`[][r]` still reads. A description holding an image flattens to that image's own alt, which
is what alt text means and what keeps the documented gap to mid-text and titled images; a
break of either kind inside one reads as a space. And a destination or title whose entity
reference decodes to a control character — `[a](/x&#10;y)` — joins 3k's exception list
beside the two above: the reader takes cmark's reading, the emitter has no spelling for it.
- [x] **3f — The directive grammar.** The three forms — inline `:name[content]{attrs}`,
container `:::name arg {attrs}`, leaf `::name arg {attrs}` — the attribute grammar with
its quoting and escapes, the fence-length and nesting rules, and the malformed list
`spec/flavour.md` spells, each a named error. `corpus.test.ts`'s `fenceNestingFault` stays a
second reading of the fence rule over emitted bytes: the double entry is the check.
**Settled** (the maintainer, 2026-08-27): the code span, the entity and raw HTML bind
first in input, as 2e3 already assumed of the emitted side — a raw `` ` ``, `&`, `<` or
`|` inside `{attrs}` breaks the directive and is a named error, the author writing the
`\u0060` the emitter writes. One precedence covers both directions, and CommonMark's own
ordering stays untouched.
**Settled** (the maintainer, 2026-09-01): a directive whose name reads back to no node
takes its own code, `unknown-directive-name` — a well-formed spelling the vocabulary does
not hold is not a malformed one, and §8's "erroring input gaining meaning later is MINOR"
is what a consumer switches the two apart for. And input reads canonical spacing only: one
space parting the name, the argument, `{attrs}` and each attribute pair, no padding inside
the braces, trailing whitespace on a directive block line tolerated — §8 makes loosening a
MINOR, so strict is the reversible direction. `directive-attributes.ts` becomes
`directive-syntax.ts` with the readers in it: the whole directive grammar, both
directions, beside the escaping regexes and the spellings it must not drift from. And the
500-level guards compose here for the first time — a recursive reader stacked on the block
walk — so `nesting-depth-composed` pins both axes now rather than waiting for 3i's third.
A closing fence closes the innermost open container however long its run, which
`spec/flavour.md`'s closing-fence sentence now says: a run reaching past the innermost
leaves the fence it did not close a named error, which §2 prefers to closing more than the
author wrote.
- [x] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first
directory to. A parsed directive becomes its node: the name to the type and an unknown one
to a named error, the arg to the attribute it names, each value to the type its section
assigns, the body to `content`, the reserved `marks` key to the marks array. 3a's drift
guard is built here if the answer there was yes.
3f leaves two here: `Read<T>` moves to `src/result.ts` once a second reader takes it, and
the reserved `adf` name in block position needs an error of its own — 3f reports it as
`unknown-directive-name`, which §8 makes the signal that a later MINOR may give the name
meaning, and `adf` never will.
**Settled** (the maintainer, 2026-09-01): the reserved `adf` name in block position is a
`malformed-directive` — the grammar section states the reservation, so it is that spelling
the name breaks — and a well-formed directive the tables refuse is `unsupported-node-shape`,
the emitter's code for the same mismatch read the other way; AGENTS.md §8 carries the
split. And input reads canonical `{attrs}` alone, keys in order and every value spelled as
the emitter spells it, the error naming the spelling to write instead: §8 makes loosening a
MINOR, so strict is the reversible direction, as 3f already settled for spacing.
**Settled** (the maintainer, 2026-09-01, on the review): 2f's plain-versus-directive
choice is read back here rather than at 3h — a directive spelling a node CommonMark holds
is refused, so `::rule` and `:::blockquote` are errors while `::rule {localId=…}` is not.
The parser asks `spellsCommonMark`, the emitter's own choice, rather than restating the
per-node conditions: a copy would refuse the list whose first item reads back as a
thematic break, which the emitter does spell as a directive, and §2 breaks in silence.
Two refusals land here for a later chunk to lift, on the same rule: the inline `[content]`
slot, which 3i opens for `emoji`, `mention` and `status`, and the `codeBlock` content
model's fenced body, 3h's. `Read<T>` stays where 3f left it — the node reader knows its
path and returns `Result`, so no second reader took it. The drift guard earned itself on
the way in: the spec's `text` attribute was missing from three inline table entries, which
the content slot spells and the vocabulary walk already passes over.
- [x] **3h — The block nodes.** `block-nodes/` reads back: the `codeBlock` directive's fenced
body and the `language` attribute a bare fence leaves it; the media family's composition;
and both table forms, the pipe table's cell split and its named errors. `fenceInfo` is a
rule both directions answer alike and moves to the `markdown/` root with the language
attribute.
**Settled** (the maintainer, 2026-08-27): 1d's last pick, the one
`container-block-separation` holds — a CommonMark block and a directive block sit adjacent
in a container body with no blank line between them. That reduces the three cases to one
rule, separation only where its absence would merge the blocks: the `:::` fence is
separation already, and 3c's claim ends the lazy continuation that would otherwise swallow
it. The fixture becomes a round-trip pair, and with `nested-list-separation` and 3e's pair
that empties `corpus/unspellable/`: this chunk settles the directory's own guard in
`corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here.
The emitter's other refusals survive on causes no fixture in that directory covers, so
3k's one-list pass is where they get fixtures or the directory goes.
**Settled** (the maintainer, 2026-09-01): losing that cause closed one of the shapes input
accepted and emit refused, not the last. Two adjacent lists of a kind are what
`adfToMarkdown` refuses and one `- ` spelling cannot hold apart, and the walk reached them
two ways — a marker change, which CommonMark opens a second list on, and an empty last item,
whose blank line pops the container the list's identity hung from. The parser opens no list
beside one of its own kind instead, the way it already drops the blank lines between items;
3k owes the CommonMark suite an exception where the reference HTML holds two `<ul>`. The
`normalization/` arm emits each document and reads it back from here, so the population that
class lives in is checked rather than read. The README's canonical-fixpoint sentence still
claims more than the parser keeps — 3e's three shapes — which stays milestone 5's to
narrow.
- [x] **3i — The inline nodes and the marks.** `inline-nodes/` reads back: the content slot's
`text` attribute and the error a slot holding anything but one unmarked text node is; the
`:text{text="…"}` whitespace spelling; the four directive marks and their nesting order,
outermost first; and `:em[x]` as the error `spec/flavour.md` promises. Editor-normal's
merging half lands here, `text-whitespace` being the first fixture that forces it, and 4's
`toEditorNormal` is built on it.
3g's shape leaves three: `readInlineDirectiveNode` takes the name, the attributes and the
slot's parsed text rather than the span, since `inline-content.ts` already imports it and
parsing the slot inside it is a cycle; the four directive marks get `parse/directive-marks.ts`
that `inline-content.ts` tries ahead of the node reader, as `mark-spellings.ts` sits apart
from `emit/inline-directive-spelling.ts`; and the five markdown-spelled mark names in inline
directive position take `unsupported-node-shape` rather than a code of their own — §8
already answers a well-formed directive the node tables refuse, and the message names the
spelling to use (`*x*`), while `unknown-directive-name`'s "a later MINOR may give the name
meaning" stays the wrong signal, as it was for `adf`. `corpus/errors/directive-content-slot` goes when the slot opens.
The marks a spelling wraps answer the same question 3g settled for a block's form: only the
nesting the emitter writes parses back.
**Settled** (the maintainer, 2026-09-01): `:text` reads back what the emitter writes and
nothing else — one run of spaces and tabs, or one run of newlines. A mixed run, and text
CommonMark carries plainly, are named errors, as 3g refuses the directive form of a node
CommonMark spells. The reader takes the slot's parsed nodes rather than its text, so the
rule refusing anything but one unmarked text node sits beside the node tables that own the
slot. Only `text`, read ahead of the slot, names a refusal before the slot's own: a
doubly-broken span reports what its content holds, `:date[<div>]{timestamp=1}` being
`unmappable-html` rather than `date takes no content`, which the maintainer pinned with an
assertion rather than reordering the readers. `directive-content-slot` stays with the
fixtures, its cause now a marked slot rather than a slot at all. The slot's own whitespace
answers the rule the spelling does: `:text{text="\n"}` and `&#10;` alike reach a slot the
emitter refuses a line ending in, so one function answers both directions.
**Settled** (the maintainer, 2026-09-01): a name the other position spells names that
spelling rather than reading as unknown — `:::em` and `::date` take
`unsupported-node-shape` naming the inline form, `:paragraph[a]` the block one — leaving
`unknown-directive-name` for a name no table holds, which is the meaning §8 gives it. The
two readers lean on the tables being disjoint, so that is a test beside the spec drift
guard now.
The same read found the hole the other way: `attemptLine` refused a line edged with a
vertical tab or a form feed, where CommonMark strips spaces and tabs alone, so valid
CommonMark parsed to a document `adfToMarkdown` then refused. The edges that check covered
are carried before the line is assembled, so narrowing it to spaces and tabs left it no
cause and it goes with them.
- [x] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back:
the `adf` fence and `:adf{json="…"}` restoring a deep-equal node, invalid JSON in either a
named error, a carry inside a mark spelling another, and the three carve-outs' escapes
reading as the literal text they hold. 3g refuses the `adf` fence rather than reading a
`codeBlock` from it; the refusal goes when the carry reads it. 3i left the slot parse
contextless, so the refusal a carry inside a mark spelling earns needs a channel — a reader
context in place of `parseInline`'s `strip` flag, or a return arm from the slot — and
`directiveNodes` takes its fourth reader beside it.
`index.ts` gains `markdownToAdf` here, and the README's status line with it: this is the
last parser chunk, so `parsingDirectories` becomes `emittingDirectories` and the whole
corpus round-trips both ways — `0.1.0`'s proof, which 4 widens rather than replaces.
- [x] **3k — The CommonMark spec suite (`0.2.0`).** Checked in at `corpus/commonmark-spec/`,
pinned to the version it ships — the one `commonmark-grammar.ts` names for its start
conditions — `corpus/README.md` gaining the kind.
**Settled** (the maintainer, 2026-08-27): three checks an example must pass, the reference
HTML each ships read as corpus data — which adds no format and no direction (§1). §2's
canonical fixpoint: a named error, or markdown that parses and emits to itself byte for
byte. That HTML's text, tags stripped and entities decoded, against the parsed document's
concatenated `text`. And a count of the dozen elements the CommonMark subset covers
against the marks and nodes they map to — counting distinct mark types per text node, since
3e collapses a spelling nested inside its own kind and `*(*a*)*` is two `<em>` against one
`em`. The fixpoint alone is self-consistency a parser
returning the empty document passes, and the text alone one dropping every emphasis; the
counts close both. The exception list stays the maintainer's, and one entry is owed
already: 3h continues a list across the marker change CommonMark splits on, so an example
the reference HTML gives two `<ul>` counts one `bulletList`. One outcome is no
exception and must not be filed as one: a fixable §2 hole — valid CommonMark parsing to a
document `adfToMarkdown` refuses — which is what `corpus/unspellable/` held until 3c, 3e
and 3h landed their answers and emptied it. The permanent ones — a link destination or
title no escape spells, a paragraph opening with a code span — are the exceptions, named
by AGENTS.md §2.
- [x] **4 — Round-trip property tests (`0.2.0`)**, widening 3j's corpus round-trip past the
documents a human wrote — the thing that proves 2 and 3 beyond them.
**Settled** (the maintainer, 2026-09-13): `fast-check` generates and shrinks. The gate runs a
fixed seed, the properties together adding about five seconds per engine; an environment
variable raises the runs and randomizes the seed for local digging, and a counterexample
found becomes a round-trip fixture. The generators draw from the node tables — each node's
content model and attribute vocabulary as `adf/` records them, which 11 holds to Atlassian's
schema — and misplace a share of nodes so the carry (§3) is exercised; no JSON Schema walker
enters the tests. `toEditorNormal` stays internal. 2e5's collision test goes, since a
collision already fails the round-trip on the same fixtures; the fixture-duplicate test
stays.
- [x] **4.1 — Editor-normal and the node accessors.** `toEditorNormal(doc)` in
`src/adf/editor-normal.ts`, on 3i's merging: adjacent text nodes carrying identical marks and
no attributes merged, an empty `attrs`, `marks` or `content` the absent key, `-0` read as `0`
(§2); the round-trip tests compare the parser's output through it, and `serializeCanonicalJson`
beneath it walks iteratively. `nodeContent`/`nodeAttrs`/`nodeMarks` replace the 46 inline
`?? []`/`?? {}` reads in `src/` (23 `content`, 12 `marks`, 11 `attrs`) and the `attrs?.[key]`
reads, and the branch floor rises to the integer floor of what the suite then measures.
**Settled** (the maintainer, 2026-09-14): a text node carrying attributes never merges —
`0.1.0` merged a carried one into its neighbour on read-back — and the fix lands here, as does
the iterative serializer.
- [x] **4.2 — The ADF property.** `fast-check` joins `devDependencies`, AGENTS.md §5 naming what
it earns — shrinking a failing document to the nodes that break it — and §10 the properties
beside the corpus. A generated editor-normal document either refuses in `adfToMarkdown`
with a `ConvertError` or reads back through `markdownToAdf` to an equal document, and
nothing throws, under Node, Deno and Bun alike. 2e5's collision test is deleted.
**Settled** (the maintainer, 2026-09-14): about half the block positions draw attribute-less
CommonMark shapes — single-type lists, headings, blockquotes, pipe-table-shaped tables — where
the escaping lives. A round-trip break the property finds is fixed inside 4.2, one commit per
break with its round-trip fixture seen red first, and 4.2 lands when a deep run of about
10,000 per engine passes clean; a break needing design goes to the maintainer. The first two,
both shipped in `0.1.0`: an empty `href` with a title spelled `[a]( "")`, which reads back as
the href `""`, and a `[` or `]` in a link's destination or title inside a directive mark,
refused as the emitter's own output or, with `]`, losing the link. Later, settled the same
day: an autolink whose href holds a backtick takes the `[text](url)` form inside a
directive's content; and a V8 fault the deep runs hit — once `JSON.parse` has read a key
holding an escaped backslash, a later escaped quote or newline key comes back as that
backslash, on Node and Deno but not Bun — is accepted rather than worked around, since the
library only refuses such a document, so the generators' JSON keys avoid those characters; the
fault is reported upstream (https://issues.chromium.org/issues/521080746, nodejs/node#63785),
where the maintainer added to both on 2026-09-14. The review found one more break of the
same class, fixed the same way: a would-be inline directive in a link target inside a
directive's content.
- [x] **4.3 — The markdown property.** Generated markdown through `markdownToAdf` never throws,
and the runs fit the budget; where it parses and `adfToMarkdown` spells the result, that
spelling parses and emits to itself byte for byte (§2).
**Settled** (the maintainer, 2026-09-15): the generators and run parameters 4.2 and 4.3
share live in one test-only module in `src/`, kept out of the build and coverage, with
10c's properties as its third user. Under the gate seed the property asserts floors on the
runs reaching the fixpoint and on the directive-shaped ones. It lands when hunts of several
hundred thousand runs per engine pass clean, since the breaks hit once per ~150,000 runs,
past a 10,000-run bar. Two breaks, both shipped in `0.1.0`, are fixed inside it. A backtick
string an escape formed closed an earlier bare run's code span, since CommonMark reads no
escape inside one: an escaped backtick alone or, as the review found, one joined to the bare
run after it. A backtick run now escapes whole, and a bare run escapes wherever a later
string of its length forms around an escape in the same inline content: the generalized pass
the maintainer chose (2026-09-15). A paragraph's opening read as a link
reference definition across a `]` the emitter spelled: the emitter now escapes the opening
`[` exactly when the parser's own definition reader accepts the paragraph, and a link
opening it rides the carry until 13b.
- [x] **4.4 — The real payloads.** `corpus/real-payloads/` holds ADF Atlassian's editor wrote,
each round-tripped ADF→markdown→ADF with no expected markdown.
**Settled** (the maintainer, 2026-09-15): the chunk authors the payloads itself on the
maintainer's Atlassian test site — invented content, so nothing needs sanitizing — driving the
editor with Playwright, and reads the ADF back over REST. Only the documents are committed; no
client or fetch script enters the repo (§7). Later, settled the same day: the mentions keep
the test user's real account id (the maintainer, 2026-09-15).
- [x] **4b — The block walk's retry (`0.2.0`).** `emitBlock` walks a subtree twice wherever
`readableBlock` reads it whole and then gives up — a list item whose first line reads back
as a thematic break — and the walk below does the same, so the cost doubles per level:
3.4kB of nested lists takes half a second, depth 20 about eight, depth 24 minutes. It
predates 3g on both directions, and 3g's `commonMarkSpelling` gave it a second entry point.
The README's bot and pipeline personas feed markdown nobody typed, so this ships as a hang
on a small input; §11's scanning rule is the same argument one shape further in. The retry
is what to remove — one walk answering both the readable question and the directive
fallback. Memoizing `emitBlock` is the shortcut, and the node reference is the wrong key: a
caller may hold one node object at two positions, where the cached depth and path are
another node's. `0.1.0` ships with the retry in it, so a deep document is slow rather than
wrong until the patch. `adfDocumentFault` is the second site to look at: `isNodeArray` reads
every node and attribute value, then `nestingFault` reads them again, so the emit entry the
export persona runs in bulk walks the document twice. Both walks are linear, so this is a
constant factor rather than 4b's class change, and the parting is what gives depth its own
code (§8) — measure before joining them back.
**Settled** (the maintainer, 2026-09-18): the limit stays 500 readable lists, the walk
reporting its headroom (§11). Counting every list twice was rejected for halving the limit,
counting the directive form once for doubling the parser's frames per level.
**Measured** (2026-09-18): `adfDocumentFault` walks a 9 MB document in 52 ms against 314 ms
for the emit, so its two walks stay parted.
- [x] **4c — The scanning rule's remaining sites (`0.2.0`).** A trailing-anchored regex re-walks
its run from every start position, so an interior whitespace run costs quadratic time rather
than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk
replaced the regex. The sites the same sweep did not reach: `normalizeLabel` in
`link-syntax.ts`, whose shortcut-reference input is `scan.source.slice(...)` rather than the
999-capped `readLabel` value, and `carryEdges` in `emit/inline-line.ts`. A third of another
shape joins them: `readNestedDirective` restarts its depth counter per level, so each parse
level re-scans the region below it and nested inline directives cost O(depth × content),
bounded by the 500-level guard. A fourth predates 12c: the list-item walk re-scans the rest
of a line once per item level — `isThematicBreak` in `containerStart` on an opener line,
`isBlankLine` and `leadingColumns` in `continuesContainer` on a continuation line, and a
blank line continues every open item without consuming input; 30000 nested items take 4.4s
at 59 KB (the stability-reviewer, 2026-09-16). §11's scanning rule is the whole argument; the
pipeline persona feeds documents nobody typed. A fifth is a throw rather than a cost:
`adfDocumentFault` pushes a node's content with a spread, so past about 125k sibling nodes
the guard throws a `RangeError` where §11 owes a `Result` (the stability-reviewer and the
maintainer, 2026-09-18).
**Settled** (the maintainer, 2026-09-18): the five sites land in one PR rather than split
into sub-items, and a behaviour-preserving cost fix is accepted on the suite staying green
with no fixture output changed, plus the measurement below — §14 promises no figure, so
nothing times the gate. The guard's spread is the one behavioural fix and carries a test.
**Corrected** (2026-09-18): the entry filed two sites in `emit/inline-line.ts` on 2026-09-01
and the file has changed since — `tryImageLine`'s alternation measures linear (3.4 / 1.9 /
5.3 ms over 10k / 20k / 40k spaces), leaving `carryEdges`' trailing trim the only one.
**Measured** (2026-09-18), each at the size its filing named: `normalizeLabel` 1026 ms → 5 ms
at 40k interior spaces, `carryEdges` 1024 ms → 7 ms (its heading path 978 ms → 5 ms),
`readNestedDirective` 434 ms → 10 ms at 397 kB and 200 levels, the list-item walk 4196 ms →
39 ms at 30000 items, and the document guard a `RangeError` → 42 ms at 200k siblings under
one node. The list-item walk's mixed-marker shape, which the fix had to answer too, reads
38 ms where the tail scan alone would have left it quadratic.
The sixth site the sweep found went to 18 rather than landing here (the maintainer,
2026-09-18).
**Left as is** (the stability-reviewer, 2026-09-18): of the list-item walk's three re-scans
only `containerStart`'s is fixed. `continuesContainer`'s pair costs the same either way — 400
levels at 627 kB read 469 ms before and 448 ms after, linear in the line count and only
mildly superlinear in a depth the 500-level guard bounds — so it is measured and left rather
than made an item.
**Widened** (the systems-architect, 2026-09-18): the guard's spread was a class rather than a
site, and two more threw out of the public API — `readIndentedCodeLine` releasing the blank
lines an indented code block held (200k of them at 200 kB), and `emitRun` joining a mark
run's segments (200k nodes under one mark). Both fixed here with the same loop and a test
each, and §11 gained the rule so the spelling cannot walk back in.
- [x] **5a — Rename to `@larvit/adf-codec` (`0.1.0`).** Before the first publish, the name being
the published identity: `package.json` `name` and `repository`, the Gitea repo and its
remote, the README title, §6's published-as line, the checkout directory.
**Settled** (the maintainer, 2026-09-01): ADF's own `A` is "Atlassian", and "converter" is
the one-way lossy tool §2 exists to replace, where a codec is both directions. It names the
hub, not the formats around it.
- [x] **5b — The consumer's error surface (`0.1.0`).** A product-owner read of the public surface
found the error result legible to the library and opaque to the consumer holding it, and the
README documenting no part of it. The sub-items are that read's answers, and they land before
5 because §8 freezes the code list at `0.1.0` and 5b4's table is what reads the list before
the freeze closes it.
- [x] **5b1 — The error's source position.** A parse error names an ADF path into a document the
caller does not hold yet — `unmappable-html` at `["content", 5]` for a `<span>` on line
12 — and no coordinate into the markdown string it passed in. `ConvertError` gains an
optional `position` the parser carries to every parse-side mint, and the README's published
shape gains it.
**Settled** (the maintainer, 2026-09-03): the position is the parse side's alone — an
emitter has no source string to point into, so emit-side errors keep `path` unchanged. The
representation and where the position is captured are implementation judgment.
The block walk mints it and the node walk attaches it as results return — at `blockNodes`,
and at the inline body a directive holds — so the innermost block wins and the emitter's
own refusals, which the parser re-enters for the CommonMark spelling, get an input
coordinate too. `markdownToAdf` wraps the walk once more, which is what turns the wide
`Result<T>` into the `Result<T, ParseError>` its signature promises rather than guarding
anything: the depth guard under it cannot fire at depth 0. A paragraph names the line its
kept text starts on, never a link reference definition it gave up. Line endings stay as
the input spells them, so an offset indexes the string the caller passed rather than a
normalized copy of it. §8 records the framings the review settled beside it:
`unsupported-node-shape` stays one code across the two directions, `unmappable-html` names
the version rather than the element, and a direction that reads a source returns the
narrowed error type.
- [x] **5b2 — The error messages.** Most state the rule and leave the violation to be inferred —
`a text node holds text` for a node holding none — so `rule: violation` becomes house style
across the sites that do. `not-an-adf-document` gives one sentence of eight words to `null`,
a string, a missing `version`, a `type` that is not `doc` and a REST envelope around the
document; naming the check that failed makes the highest-frequency integrator mistake
self-diagnosing without the library naming a REST shape (§7). The three carve-out claim
messages name the escape that unclaims the line — `\|`, `\~~`, `\:::` — which today only
`spec/flavour.md` holds. `unmappable-html` reads as §8 now frames it: this version converts
no raw HTML, never a permanent judgment on the element.
Thirty-odd sites gained the violation clause and §8 gained the house style. `isAdfDocument`
parts into `adfDocumentFault`, the guard reading it, so the first failing check is the
message — the wrapper mistake names the key it found. Two carve-outs claim a line, not
three: a matched `~~` pair spells `strike` silently, so nothing refuses it and no message
names `\~~`. The escape lands on the refusals a prose line hits, in the form that was
claimed — `\:::` on the directive line's, `\|` on the pipe table's, `\:` on the inline
directive's, the attribute-pair and unknown-name faults taking whichever form read them.
`a pipe table row holds 1 cells` gained its plural.
- [x] **5b3 — The code list and the flavour's gaps.** A second read of the surface, this one on
the fifteen names §8 freezes at `0.1.0`: two pairs of them are one cause each, and one
names a state the flavour leaves no way out of. `unspellable-character` and the text half
of `unspellable-whitespace` are one refusal — a character CommonMark rewrites, the message
naming it — and merge, `unspellable-whitespace` keeping the code for its other cause, the
content slot no inline directive spans. `unspellable-link-destination` and
`unspellable-link-title` become `unspellable-link`, the message naming the attribute.
`unspellable-adjacent-lists` goes entirely: two adjacent `bulletList` nodes are valid ADF a
site writes, and refusing them leaves the viewer persona a document it cannot render at
all, so the flavour gains the separator that spells the pair apart, both directions,
`spec/flavour.md` and fixtures. Thirteen codes stand — `unspellable-whitespace` keeps its own.
The bare pipe table — `a | b` over `--- | ---`, GFM's shape without the leading pipes — is
the one input that loses structure silently, reading back as a paragraph of prose; it
becomes a `malformed-pipe-table` naming the form a row takes. That code keeps its name for
the alignment colon: the flavour's own delimiter row is `-` runs, so the grammar is what
refuses, and §8 records it rather than answering it again each review. The ninth
`adfDocumentFault` branch names no node and carries the document's own path, the one branch
the other eight outshine; §8 records why the guard stays a boolean.
`::listBreak` is the separator's spelling: the grammar's leaf form, and a second reserved
name beside `adf` — every other name is an ADF node or mark type, and this one builds none.
It reads only between two adjacent lists of one type, and takes the separation any
directive block takes where it sits, so a directive container holds it with no blank line.
A hard break is the one spelling that can put a bare delimiter row under a row of its own,
so the emitter escapes that line's first character rather than refusing the document.
`spec/flavour.md` had two directive blocks inside a container taking no blank line; the
rule both directions keep is that a pair holding one takes none.
- [x] **5b4 — The README's consumer surface.** §8 invites an exhaustive switch on `code` and no
code name appears in the README, so it gains a table — code, when it fires, what the
consumer does — grouped by direction, over the thirteen names 5b3 settled. Four things a
reader who has not opened the code cannot know: raw
HTML is core CommonMark and every construct in input is an error until `0.3.0`, which the
guarantees' "three carve-outs and one gap" denies and which is the bot and LLM personas'
most common failure; `adfToHtml`, `htmlToAdf`, `markdownToHtml` and `htmlToMarkdown` sit
unmarked in the code block people copy from, as do the two HTML guarantee bullets, and take
a `0.3.0` mark or leave the block; `adfToMarkdown` is partial on valid ADF — a text node
holding a carriage return, a link destination no canonical escape spells — which the viewer
persona needs told along with
what to do about it; and GFM past tables and strikethrough is literal text, task lists
taking `:::taskList`. One sentence for the LLM persona: `code` is stable across minors,
`message` is free text. The type-level surface freezes at the same moment and gets the same
read: what `index.ts` exports and what it withholds, `ParseError` against `ConvertError`
where a direction reads a source, and `ConvertFault` staying internal — the README table
names the shapes a consumer switches on, so the two audits are one.
The direction grouping is read off the call sites rather than the code prefixes, which do
not partition by direction: the parser asks the emitter which CommonMark spelling a node
takes (§11), so six codes reach a `markdownToAdf` caller as well as an `adfToMarkdown` one.
The trailing pipe of a pipe-table row is optional in input, not required; the leading one
is what every row must carry.
- [x] **5c — The build and the release pipeline.** Split out of 5, which kept only the
maintainer's own acts. The build: `tsconfig.build.json` gains emit of JS and `.d.ts` to
`dist/` (its own `allowImportingTsExtensions` forces `noEmit`, so
`rewriteRelativeImportExtensions` lands beside it), plus `exports`/`files` in
`package.json`. Publish-on-version-change (§9) as `publish.sh`, run by a `main`-only job
needing the gate. The `ConvertErrorCode` freeze (§8) is checkable here: 3h landed the last
decision `corpus/unspellable/` held and the directory went with it, so what the code list
holds from here is permanent. The parser's own code additions are read here as one list
before that freeze — nine sessions mint them independently, and one cause wearing two codes
is breaking to undo after `0.1.0`. That read gets a test rather than an eye — every
`ConvertErrorCode` member named at a production call site, the way `spec.test.ts` guards the
node tables — since `unspelled-block-separation` outlived its cause until 3h went looking.
All thirteen have a call site; the audit's find was the depth one 5 predicted, read wrong in
its own text: an attribute value past 500 levels was `unsupported-node-shape` on parse and
`not-an-adf-document` on emit, the document guard counting the `attrs` object as a level the
parser does not, so a value at exactly 500 parsed into a document the emitter then refused.
Depth left the shape predicates on both sides: `isJsonValue` structural and `overNested`
beside it, `adfDocumentFault` returning the code with the message and `attributeValue` the
reason it refused, so both directions answer with `unsupported-nesting-depth` naming the
attribute, and `isAdfDocument` calls a deep document a document as it always did a deep
block.
`engines.node` gets its one-line proof too — the built entrypoint imported and round-tripped
under a pinned Node 18 image, which cannot run the suite that type stripping wants 22+ for,
but proves exactly what the field claims. Beside it, the emitted `.d.ts` typechecked from a
consumer's position: declaration emit leaves the `.ts` specifiers `rewriteRelativeImportExtensions`
rewrites in the JavaScript, and nothing else in the repo reads them the way an installed
consumer would. 3k's exception list landing after the release left the README's
canonical-fixpoint sentence claiming more than `0.1.0` keeps — 3e names three shapes that
parse and then refuse — so it now says a parse succeeding is no promise of a way back, and
names them.
- [x] **5d — The browser leg.** §6's browser half is checkable on the emitted `dist/index.js` a
browser can load — the compile gate names no host API, and a real page converting the corpus
is the other half. Headless Firefox is that page, settling both at once: the browser proof,
and the only SpiderMonkey there is, the gate's three engine legs being two V8s and a
JavaScriptCore that is not Safari's. The mechanism is the decision this item opens with: a
browser leg wants an image, a driver and a way to carry a verdict back out, none of which
the gate's plain `docker run` per engine has. The answer is `with_firefox`, which runs the
Firefox image beside the node one in a shared network namespace, so the page's server and
the driver are each other's `127.0.0.1` and no user-defined network, container name or
geckodriver `--allow-hosts` entry is wanted; its `EXIT INT TERM` trap bakes in the container
id, since the `local` holding it is gone by the time the trap fires. `browser-tests/run.js`
serves the repo, drives one `execute/sync` and asserts the results against the corpus with the
Node-side `assert.deepEqual` the corpus runner uses, so the browser page holds no second copy
of the comparison. The whole corpus fits: 118 fixtures in 8s warm against a 120s script
timeout — no slice was worth choosing. A `try` around the dynamic import is what turns a
broken build into SpiderMonkey's own message rather than an undefined global.
**Settled** (the maintainer, 2026-09-04): `selenium/standalone-firefox` over the smaller
`instrumentisto/geckodriver`, currency over size — the leg's whole worth is a real
SpiderMonkey, which decays the moment the pin stops moving, and the smaller image was four
Firefox majors behind with a publisher that may go quiet while Renovate stays silent.
- [x] **11 — Atlassian's ADF schema as the tables' truth (`0.2.0`).** `@atlaskit/adf-schema`'s two
JSON Schemas vendored rather than the package installed (AGENTS.md §5), and the node tables
gated against them (§10). **Settled** (the maintainer, 2026-09-13): vendored at
`spec/adf-schema/` and re-pinned by hand when a need shows; the gate compares attribute names
and kinds, never value sets, over `full.json` and `stage-0.json` together.
- [x] **11a — The vendored schema.** `full.json` and `stage-0.json`, byte-exact from
`@atlaskit/adf-schema@57.4.9`'s `dist/json-schema/v1/`, at `spec/adf-schema/`, each pinned
by its SHA-256 in a test the way `spec.json` is. The version, the source and the Apache-2.0
attribution sit beside them with the licence text; no gate re-serializes either file.
- [x] **11b — The gate.** For each node and mark type the tables spell, the attribute names and
kinds equal the union over every definition in both files whose `type` enum names it,
`anyOf`/`allOf` branches included, the argument slot (`panelType`, `state`) counting as
spelled. Kinds: `string`; `number`, `integer` included; `boolean`; `json` for an object, an
array or an untyped value; an `enum`-only attribute takes its values' kind. What the schema
holds past the tables is pinned in two exact lists — an entry the schema no longer needs is
red, like a difference neither list names: gaps, attributes of a spelled type (57.4.9:
`link` `collection` `id` `occurrenceKey`, `rule` `color` `style` `weight`, `layoutSection`
`columnRuleStyle`), emptied by 13; and carried, types the tables do not spell (`alignment`
`annotation` `backgroundColor` `blockCard` `bodiedRule` `breakout` `dataConsumer`
`embedCard` `fontSize` `fragment` `indentation` `inlineExtension` `placeholder`), `doc` and
`text` counting as the grammar's own.
- [x] **12 — The `!adf:` re-spelling (`0.2.0`).** Replace the colon directive grammar with the
namespaced prefix, a breaking change to the emitted contract (shipped `0.1.0`, so §8 makes it
`0.2.0`). Forms: block container `!adf:name arg {attrs}` … `!adf:/name` — the `/` parts open
from close, nestable without a fence-length discipline, so the `::::`/`:::::` runs and their
length rule go and every container opens the constant `!adf:`; block leaf `!adf:name arg
{attrs}` with no closer; inline node `!adf:name[content]{attrs}`; directive marks
`!adf:border`/`subsup`/`textColor`/`underline` `[content]{attrs}`. Attributes and their
escaping stay `{key=value}`; the literal escape is `\!adf:`. Leaf vs container is decided by
the node's content model rather than syntax — the `::`/`:::` split goes, a simplification the
carry makes safe (an unknown *block* node already rides the fence, not the directive). The
carry's reserved name becomes `carry`, both spellings — the block fence info string `carry`
and the inline `!adf:carry{json="…"}` — named for what it does: it carries a node verbatim,
never "unknown-node", since a known node no section spells where it stands rides it too. No
`ConvertErrorCode` is added, removed or renamed, and the round-trip guarantee and the carry
both hold through it. Mechanical surface: the grammar in `spec/flavour.md`,
`src/adf/block-directives.ts` + `inline-directives.ts`, `src/markdown/`'s
`directive-syntax.ts`, `opaque-carry.ts` and the `emit/` + `parse/` readers, every corpus
fixture (round-trip, normalization and `errors/`), the prose reader over `spec/flavour.md`,
the markdown property's generator, and the README's examples.
**Settled** (the maintainer, 2026-09-13):
- A line opening `!adf:name` is a block line when a space or the line's end follows the name,
and a paragraph when `[` or `{` does. Claiming stays syntactic and structure comes from the
tables: an unknown name is `unknown-directive-name` at the opener, whatever follows it.
- An unescaped `!adf:` claims on its own anywhere inline: one completing no directive is
`malformed-directive`, the emitter escapes every literal `!adf:`, and `!adf:hardBreak{}`
keeps its braces. Block and inline share the one `\!adf:` escape hint.
- A closer names the innermost open container, crosses no list-item or blockquote edge,
indents as a fence does and carries nothing after the name; anything else is
`malformed-directive`.
- A node holding no content whose content model takes some is an empty opener–closer pair,
never a leaf.
- A spelled node's content model is frozen with its spelling: changing it is MAJOR (§8).
- The colon spellings are dropped, not refused: `0.1.0` markdown reads back as prose, `adf`
is no longer a reserved language, and `MIGRATION.md` tells a consumer to convert stored
markdown through `0.1.0`'s parser and `0.2.0`'s emitter.
- Inputs moving between codes ride the break: a leaf given a body, a container missing its
closer and `listBreak` with a body are `malformed-directive`, and an empty inline-body
container parses.
- Split by construct, each sub-item both directions: 55 of 78 round-trip fixtures feed both
the emit and the read-back test, so an emit-only chunk cannot land green.
- The spec leads the code from 12a to 12d: `spec/flavour.md` and `AGENTS.md` §4 spell the
`!adf:` grammar whole, while code and fixtures reach it one form at a time. A reader landing
in either without `todo.md` sees a gap that is the plan, not a defect.
- [x] **12a — The spec and the decision.** `spec/flavour.md` rewritten to the `!adf:` grammar and
the settled answers above, no colon directive form left in it; AGENTS.md §4's directive
bullet and prior-art line, and §8's escape hints and `::adf`/`::listBreak` examples, name
the new forms, §8 gaining the frozen content model.
- [x] **12b — The inline form.** Inline nodes, directive marks, `text` and the inline carry
`!adf:carry{json=…}` spelled and read as `!adf:name[content]{attrs}`, with the prefix claim
and its escape; the round-trip, normalization and `errors/` fixtures holding inline forms
re-spelled, and the gate green. The content slot of `emoji`, `mention` and `status` refuses a
text node carrying attributes as `unsupported-node-shape`, which it drops silently today (the
maintainer, 2026-09-14).
- [x] **12c — The block form.** Openers and `!adf:/name` closers, leaf vs container by content
model, empty pairs, `listBreak` and the `carry` fence, spelled and read; the fence-length
rule and the corpus test's fence nesting check deleted; the remaining fixtures re-spelled
and `errors/` re-derived under the shifted codes, and the gate green.
12b's two temporary seams expire here: `carryFence` folds back into `carryName` once the
fence reads `carry`, and `directiveLineEscape` into `inlineDirectiveEscape` once one escape
serves both forms. `spellLeafDirective` takes the `Inline` its reader-side regex already
carries, and `header` versus `opener` settles as one word in spec and code. While
`readNestedDirective` is open, its `[content]` and `{attrs}` reads lift out as named steps,
and the two `charAt`-against-`!` fast paths ahead of `claimsDirectivePrefix` — in
`readDirectiveContent` and `line-escaping`'s `bracketed-link-target` arm — either earn a
reason or go (the systems-architect, 2026-09-16).
- [x] **12d — The README, `MIGRATION.md` and the sweep.** The README's examples and error tables
follow, `MIGRATION.md` linked from one README line; docs and fixtures swept for any stale
`::`/`:name` spelling.
- [x] **13 — The schema's gap attributes (`0.2.0`).** Spell the attributes 11b pins as gaps, in
12's grammar, and empty the list.
**Settled** (the maintainer, 2026-09-13): a link `[text](url "title")` cannot hold takes the
directive mark `!adf:link[text]{attrs}` — one carrying `collection`, `id` or `occurrenceKey`,
or an `href` or `title` no CommonMark escape writes — and a directive link CommonMark could
spell is `unsupported-node-shape`. That leaves `unspellable-link` no cause, so it leaves
`ConvertErrorCode` in `0.2.0`, §8 recording the removal.
- [x] **13a — `rule` and `layoutSection`.** `rule`'s `color`, `style` and `weight` and
`layoutSection`'s `columnRuleStyle` join their tables and `spec/flavour.md` bullets, with
round-trip fixtures; their gap entries go.
- [x] **13b — The directive link.** `link` spelled as above in both directions, with a round-trip
fixture per trigger, `spec/flavour.md`'s Marks section following; `unspellable-link` removed
from the code list, its `errors/` fixtures and the CommonMark suite's `unspellable`
exceptions it cures re-derived, the README's code table and its "not every document
converts back" guarantee following, and `MIGRATION.md` naming the removed code; the gap
list is empty. A round-trip fixture holds the shape 4.2's review left refused until then:
an autolink-shaped link under a directive mark whose href holds `\!adf:name{`. A link
opening a paragraph whose opening reads as a link reference definition, which 4.3 leaves
riding the carry, takes the directive link too, with its round-trip fixture (the
maintainer, 2026-09-15).
## 5 — Ship `0.1.0`
- [ ] **5 — Ship `0.1.0`.** Only the maintainer's own acts are left (§15): make the Gitea repo
public (§6), create the `NPM_TOKEN` secret, confirm the Actions token may push tags — the
publish succeeds and the tag push then reddens the run, though the next push to `main`
retries the tag alone — and open the bump PR that sets `version` to `0.1.0` and drops
`private: true`, the guard against any earlier publish. The bump and the drop go in one
commit: dropping `private` alone publishes `0.0.0`, which also differs from npm's nothing. `0.1.0` is the
markdown round-trip: both markdown directions, the types, `isAdfDocument`, proved over the
checked-in corpus.
**Settled** (the maintainer, 2026-09-01): the round-trip proved over the checked-in corpus
is what `0.1.0` ships on, and the open-ended proof work follows it rather than gating it —
3k's spec suite and 4's generators and maintainer-supplied payloads are `0.2.0`, 4b's retry
`0.1.1`. A consumer using the library is worth more than a wider proof nobody has needed
yet, and §8's pre-1.0 rules cover what the wider proof then finds.
**Shipped** 2026-09-05: `@larvit/adf-codec@0.1.0` published and `v0.1.0` tagged on `8a847de`. Publishing needed a
bypass-2FA token — the account carrying no write-2FA requirement was not enough, npm demanded an
OTP until the token itself bypassed it.
+172 -251
View File
@@ -1,259 +1,180 @@
# Todo # todo
The plan. Design questions are settled in `AGENTS.md`; remaining spec detail is settled at its own ## Scoring
milestone. A done item shrinks to its title here; its full text moves to `todo-history.md`.
## Milestones `Score = -R - S/4 + 2*A + 2*G*W`
Shipping order: 3h, 3i, 3j, 5a, 5b, 5c, 5d, 5 → `0.1.0` (shipped 2026-09-05); 3k, 11, 4, 12, 13, 4b, `Bar = 9`
4c, 14, 15, 16, 18, 4d, 17, 10, 6, 7, 5f, 5g → `0.2.0`; 8, 9 → TBD; 5e last.
The numbering is the order the work was planned in, not the order it ships. Everything known and
shaped ships in one release rather than a string of them: nothing waits on a version, and no
consumer is served by the churn (the maintainer, 2026-09-18). So `0.2.0` completes §1's three
formats, and `0.2.1` and `0.3.0` are gone. `8` and `9` stay out as the two goals nothing has shaped
yet. `0.2.0`'s order is settled (the maintainer, 2026-09-13, extended 2026-09-18): 11 makes the
tables 4 generates from answer to Atlassian's schema, 4 proves 12, 13 spells 11's gaps in 12's
grammar, and 12 rewrites code 4b and 4c change; then 14 moves the files 15, 16 and 10 edit and HTML
is written against that layout, 4d marks the gate legs before 17 adds one, 17 puts the complexity
guardrail under the largest body of new code, and 5f and 5g read last because 7 is what changes the
bundle size and the tagline.
- [x] **0 — Scaffold.** `Next ID = 52`
- [x] **1a — The directive grammar.**
- [x] **1b — Block node syntaxes.**
- [x] **1c — Inline node syntaxes and marks.**
- [x] **1d — Corpus start.**
- [x] **1d1 — The CommonMark subset.**
- [x] **1d2 — Block nodes.**
- [x] **1d3 — Inline nodes and marks.**
- [x] **2 — `adfToMarkdown`.**
- [x] **2a — The runner and the CommonMark subset.**
- [x] **2b — Block nodes.**
- [x] **2c — Inline nodes and marks.**
- [x] **2d — The opaque carry.**
- [x] **2e — Carve-outs and combinations.**
- [x] **2e1 — The carve-outs and the claimed line.**
- [x] **2e2 — Mark runs and the runs a carry breaks.**
- [x] **2e3 — Attribute canonicalization and the quoted value's escape.**
- [x] **2e4 — The carry's fallback triggers.**
- [x] **2e5 — Combined documents and the collision property.**
- [x] **2f — The attributes CommonMark cannot hold.**
- [x] **3 — `markdownToAdf`.**
- [x] **3a — The hierarchy.**
- [x] **3b — The leaf blocks.**
- [x] **3c — The container blocks.**
- [x] **3d — Inline text.**
- [x] **3e — Emphasis and links.**
- [x] **3f — The directive grammar.**
- [x] **3g — The node tables read backwards.**
- [x] **3h — The block nodes.**
- [x] **3i — The inline nodes and the marks.**
- [x] **3j — The carry and the combinations.**
- [x] **3k — The CommonMark spec suite.**
- [x] **4 — Round-trip property tests.**
- [x] **4.1 — Editor-normal and the node accessors.**
- [x] **4.2 — The ADF property.**
- [x] **4.3 — The markdown property.**
- [x] **4.4 — The real payloads.**
- [x] **4b — The block walk's retry (`0.2.0`).**
- [x] **4c — The scanning rule's remaining sites (`0.2.0`).**
- [ ] **4d — What the gate says while it runs (`0.2.0`).** `ci.sh` runs nine legs and announces
none of them, so five minutes of a Gitea run read as silence and a hang cannot be told from
a slow pull — the maintainer hit exactly this on the `0.1.0` release. Three causes, each its
own fix. The legs need markers: `plainpages`' `ci.sh` prints a `step()` header per leg and
this one prints nothing, so name the leg and the image before each. The longest leg is the
quietest: `test_output=$(… npm test 2>&1)` buffers the whole Node run to replay it after,
because the zero-test guard greps the count — stream it and grep a copy (`tee`), rather than
trading the output for the guard. And two legs are silenced outright, `npm pack` and the
tarball install, whose `>/dev/null` predates the offline install that made them quick and
quiet. `publish.sh` owes the same: today it says nothing between reading `private` and the
registry answering, which is where its `npm ci` and rebuild sit — the seconds §9 accepts
rather than promoting the gate's `dist`, and unmeasured until the log shows them. Per-leg
timing is what turns "slow or hung" from a guess into a reading; the browser leg's own
5.4–7.9s against a 17s warm gate is the number that made it obviously cheap.
- [x] **5 — Ship `0.1.0`.**
- [ ] **5e — The publish token's deadline.** `0.1.0` published only once the npm
token carried **Bypass 2FA**: the account requiring no 2FA on writes was not enough, and npm
answered `EOTP` until the token itself bypassed. npm retires bypass-2FA tokens for direct
publishing around January 2027, leaving them `npm stage publish`, which a maintainer
approves with 2FA; its replacement — trusted publishing over OIDC — supports GitHub-hosted
Actions, GitLab.com's shared runners and CircleCI's cloud, self-hosted runners planned
without a date. So the release path has an expiry date and no drop-in successor yet. Revisit:
whether npm has added Gitea or self-hosted OIDC, and otherwise whether the
release moves to the staged publish — which fits badly with publish-on-merge,
and is the trade to weigh rather than discover on a red release run.
**Settled** (the maintainer, 2026-09-13): last of the known work, clear of `0.2.0`, placed
there knowing the cutoff may land before `0.2.0` ships.
- [ ] **5f — Publish the bundle size (`0.2.0`).** Measure the shipped artifact and put the number in the
README, kept honest by the release pipeline rather than by a human re-reading it. The
quantity is what a consumer downloads and loads: the tarball `npm pack` produces, its
unpacked `dist`, and the built JavaScript minified + gzipped — the figure the competitors
advertise (marklassian's "12kb") and the only apple-to-apple one, since ours ships tsc's
unminified output and no minifier yet (decide here whether to minify for the build or report
the unminified gzip). A publish/pipeline leg measures it and fails when the README figure
drifts, so the number can't rot; the figure lands in README §The package beside the
"no runtime dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked
221.5 kB, JS gzipped 45.6 kB.
- [ ] **5g — Reweight the README for the reader (`0.2.0`).** It opens with the pre-launch rationale —
Atlassian's REST APIs, `pf-editor-service/convert` being decommissioned, a link to
JRACLOUD-77436 — where a shipped package should answer what it is, what it does and for whom
first, then the shortest runnable example.
**Settled** (the maintainer, 2026-09-13): the background goes entirely, no endpoint, ticket or
"why" note left. The top follows the package-README order: an npm version badge and the Gitea
Actions badge, a tagline that is also `package.json`'s `description`, a feature list and a
one-line table of contents, then install and the shortest runnable example; a table of
everything exported sits near the bottom. The HTML directions were to stay an aside until a
later release shipped them; 7 now ships in this one and reads ahead of this item, so the
README documents HTML as it documents markdown, the tagline and `description` naming both
(the maintainer, 2026-09-13, revised 2026-09-18).
- [x] **5a — Rename to `@larvit/adf-codec`.**
- [x] **5b — The consumer's error surface.**
- [x] **5b1 — The error's source position.**
- [x] **5b2 — The error messages.**
- [x] **5b3 — The code list and the flavour's gaps.**
- [x] **5b4 — The README's consumer surface.**
- [x] **5c — The build and the release pipeline.**
- [x] **5d — The browser leg.**
- [ ] **6 — The HTML dialect spec (`0.2.0`).** Element-by-element mapping, the `data-*` fidelity
scheme, the opaque-carry form, and the documented foreign-element set `htmlToAdf` accepts.
- [ ] **7 — HTML, the third format (`0.2.0`).** `adfToHtml`, `htmlToAdf`, the composed
`markdownToHtml` / `htmlToMarkdown`. CommonMark spec suite runs against `markdownToHtml` from
here (§10). The README's tagline and `package.json`'s `description` regain HTML (5g).
- [ ] **8 — CLI.** A later goal, shaped around the personas once the library exists.
- [ ] **9 — The online sandbox.** A web page with two textboxes converting back and forth between ADF and markdown, powered by the library's browser build.
- [ ] **10 — Lossy conversion (`0.2.0`).** Markdown other tools render readably, to and from ADF,
keeping the content while dropping what markdown cannot hold — format, design and the richer
nodes.
**Settled** (the maintainer, 2026-09-14): two exports composed around the lossless pair, so §1's
four conversions stay four. `adfToPlainMarkdown(doc)` reduces the document ADF→ADF and hands it
to `adfToMarkdown`; `plainMarkdownToAdf(markdown)` hands the markdown to `markdownToAdf` and
lifts the result ADF→ADF. Both carry markdown conventions, so the reduction sits in
`src/markdown/emit/`, the lift in `src/markdown/parse/` and what both read in `src/markdown/`
(§11). The markdown is the flavour without directives — CommonMark, the pipe table and `~~` —
plus the conventions below, chosen for readability from a survey of GitHub, GitLab, Gitea,
Obsidian, Pandoc, MkDocs, Docusaurus, Typora, Joplin, Logseq, Bear, Notion, Azure DevOps and
Discord, GitHub's renderer confirming each shape. Writing refuses only what the document guard
refuses (`not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`)
and degrades every other shape; reading refuses what `markdownToAdf` refuses. A lifted node
carries no `localId`. The lift also reads other tools' spellings — type words in any case,
Obsidian's aliases, `[X]` — since it reads their output and never writes those spellings.
- A `panel` is an alert: the marker alone on the quote's first line, a blank `>`, then the body
(`> [!WARNING]`), in GitHub's five words by colour — info `NOTE`, note `IMPORTANT`, tip and
success `TIP`, warning `WARNING`, error `CAUTION`, custom `NOTE`. The lift reads those words
back (`NOTE` info, `IMPORTANT` note, `TIP` tip, `WARNING` warning, `CAUTION` error) and
Obsidian's by meaning (hint tip; success, check and done success; attention warning; danger,
failure, fail, missing and bug error; any other word info). Text after a marker in its
paragraph is the panel's first body paragraph.
- An `expand` or `nestedExpand` is Obsidian's folded callout, `> [!NOTE]- Title`, a blank `>`,
then the body. The lift reads a fold sign (`-` or `+`) as an expand whatever the word, the
rest of the marker's paragraph as its title, and an expand inside an expand as a
`nestedExpand`.
- A `taskList` is a bullet list whose items lead with `[x]` or `[ ]` (`- [x] Write the spec`).
The lift reads a list whose every item is so marked back as a `taskList` — a `blockTaskItem`
where an item holds more than one block, a nested task list moved beside its item — and
leaves mixed and ordered lists plain. A `decisionList` is a plain bullet list.
- `backgroundColor` is `==text==`, and the lift gives `==text==` the Atlassian editor's default
highlight colour.
- `layoutSection`/`layoutColumn`, `bodiedExtension`, `bodiedSyncBlock`, `multiBodiedExtension`
and `extensionFrame` unwrap to their body blocks in order; the CommonMark blocks keep their
spelling, attributes dropped.
- `mention` and `status` become their text, the mention's `@` kept; `emoji` its text or else its
`shortName`; `date` its ISO date in UTC (`2026-09-13`); `inlineCard`, `blockCard` and
`embedCard` a link to their `url`, dropped when they carry only `data`; a `mediaSingle`
holding an external image stays `![alt](url)`; `media`, `mediaGroup` and `mediaInline` their
`alt` text or nothing; `caption` its text as a paragraph; `extension`, `inlineExtension` and
`syncBlock` their `text` attribute or nothing; `placeholder` nothing; a node no row names, or
one standing where no spelling holds it, its blocks or its text.
- A table stays a pipe table: the first row becomes the header, a cell's blocks join on one line
with spaces, and spans and the cells they cover drop.
- `code`, `em`, `link`, `strike` and `strong` stay and every other mark drops, keeping its text —
`subsup` too, since `~2~` is a strike on GitHub; a link no CommonMark escape writes becomes its
text, and a mark run CommonMark's flanking or matching cannot spell drops its mark.
- A newline in text becomes a hard break and edge whitespace is trimmed; carriage returns and
null characters are removed; a paragraph line opening with a code span whose backticks would
read as a fence loses the code mark; an empty paragraph drops, and adjacent lists of one type
merge.
- Rejected in the survey: `~sub~` and `^sup^`, underline and colour spellings, raw HTML
(`<details>`, `<mark>`), MkDocs `!!!` and the `:::` admonition family, footnotes, definition
lists, wikilinks, embeds, tags, comments, TOC tokens, spoilers, task states past `[x]`/`[ ]`,
and lifting bare URLs, `@name`, `:shortcode:` or ISO dates into nodes.
- [ ] **10a — The reduction.** `adfToPlainMarkdown`'s ADF→ADF reduction, tests first, a test per
row above.
- [ ] **10b — The lift.** `plainMarkdownToAdf`'s ADF→ADF lift, tests first, a test per row it reads,
other tools' spellings included; the editor's default highlight colour looked up and cited.
- [ ] **10c — The exports.** `adfToPlainMarkdown` and `plainMarkdownToAdf` exported with their README
sections, and two properties over 4.2's generators: writing refuses only the guard's codes,
and markdown `adfToPlainMarkdown` wrote reads back through `plainMarkdownToAdf` and writes
again byte for byte. AGENTS.md §1 records the pair as composed around the lossless one.
- [x] **11 — Atlassian's ADF schema as the tables' truth.**
- [x] **11a — The vendored schema.**
- [x] **11b — The gate.**
- [x] **12 — The `!adf:` re-spelling.**
- [x] **12a — The spec and the decision.**
- [x] **12b — The inline form.**
- [x] **12c — The block form.**
- [x] **12d — The README, `MIGRATION.md` and the sweep.**
- [x] **13 — The schema's gap attributes (`0.2.0`).**
- [x] **13a — `rule` and `layoutSection`.**
- [x] **13b — The directive link.**
- [ ] **14 — The CommonMark subset's directory (`0.2.0`).** `src/markdown/` holds 16 source
files at its root and 10 adds more there. The CommonMark subset moves under
`src/markdown/commonmark/` — `backtick-runs.ts`, `commonmark-grammar.ts` as `grammar.ts`,
`emphasis-matching.ts`, `entity-references.ts` with its test, `link-reference-definitions.ts`
and `link-syntax.ts` — leaving the flavour's own constructs at the root, the split
`spec/flavour.md` draws between the subset and the flavour (the systems-architect and the
maintainer, 2026-09-16).
- [ ] **15 — The href-less directive link (`0.2.0`).** Refuse `!adf:link[text]` spelling no `href`
with `unsupported-node-shape` naming the attribute, so the mark has one spelling: today it
parses to a mark the emitter writes back as a carry, while the schema requires `href` and
every other directive mark spells without attributes in both directions alike (the
stability-reviewer, 2026-09-16; the maintainer, 2026-09-17).
- [ ] **16 — The link wrapping a link (`0.2.0`).** Read `[<http://x/>](/v)` and
`[!adf:link[a]{href="/u"}](/v)` as `[[a](/u)](/v)` reads — the inner link wins and the outer
brackets stay literal text, CommonMark's rule that no link holds another — rather than
dropping the outer link silently as `closeLink`'s `applyMark` does today, with a normalization
fixture per shape (the stability-reviewer, 2026-09-16; the maintainer, 2026-09-17).
- [ ] **17 — A machine-enforced size guardrail (`0.2.0`).** Add a per-function complexity check to
the gate — branch count or size — so the fits-in-your-head guardrail fails the build rather
than waiting for a review to catch it (the systems-architect, 2026-09-16). It reads ahead of
6, 7 and 10 so the largest body of new code is written under it, which is also what decides
the threshold: today's worst is `readDirectiveContent`, 27 lines and about 12 decision points
over four concerns in one loop — escape, code span, nested directive, bracket balance — which
4c left half-split and this item either passes or forces apart (the systems-architect and the
maintainer, 2026-09-18).
- [ ] **18 — The subtree the directive spelling asks about (`0.2.0`).** The parser asks
`commonMarkSpelling` at every directive-spelled block and the answer emits the whole subtree
below, so a node at depth d is spelled d times: three nested rule-first directive lists cost
18 asks over 10 nodes, and 250 levels parse in 1.2 s at 16.4 kB, 4.9 s at 261 kB with a
kilobyte of content per level. The depth guard bounds the levels at about 250, never the
content, so this is the pipeline persona's hang on an input nobody typed (§11). Keeping each
child's emitted result for its parent's ask is not a straight handover: the same node object
is asked at different depths — 4, 3 and 2 for the innermost list of three — because the
parser counts a list and its item as two levels where the emitter's readable list counts one
(4b), and `headroom` is that guard's slack. The parts that survive the measurement: the paths
agree, `text` and `spelling` carry no depth, `headroom` is affine in it, and the parser asks
first at the deepest of them, so a kept result rebases by the difference. Either rebase and
record that argument in `AGENTS.md`, or give both directions one list accounting so a node
has one depth and nothing needs rebasing — which reopens 4b. A single post-build walk was
rejected: it reports the outer offender where the build reports the inner one (the
maintainer, 2026-09-18).
## The ADF inventory to cover | Goal | W |
|---|---|
| 1 | 1.00 |
| 2 | 0.89 |
| 3 | 0.78 |
| 4 | 0.67 |
| 5 | 0.56 |
| 6 | 0.44 |
| 7 | 0.33 |
| 8 | 0.22 |
| 9 | 0.11 |
From Atlassian's [structure ## Items
reference](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/) — not the
whole schema: real payloads also carry `taskList`/`taskItem`, `decisionList`/`decisionItem`,
`layoutSection`/`layoutColumn`, `blockCard`/`embedCard`, `extension`/`bodiedExtension`/`inlineExtension`
and `placeholder`, none documented there. The documented set is the floor: the floor gets designed
syntax, the rest rides the opaque carry (§3) until it does too.
| | | | ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
| --- | --- | |---|---|---|---|---|---|---|---|---|---|
| Top-level block | `blockquote` `bodiedSyncBlock` `bulletList` `codeBlock` `expand` `heading` `mediaGroup` `mediaSingle` `multiBodiedExtension` `orderedList` `panel` `paragraph` `rule` `syncBlock` `table` | | 40 | 0.2.0 | decision | **Make `markdownToAdf(adfToMarkdown(doc))` deep-equal `doc` for every document `adfToMarkdown` takes.** | 6 | 7 | 8 | 9 | 1 | 26.2 |
| Child block | `blockTaskItem` `extensionFrame` `listItem` `media` `nestedExpand` `tableCell` `tableHeader` `tableRow` | | 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 3 | 25.8 |
| Inline | `date` `emoji` `hardBreak` `inlineCard` `mediaInline` `mention` `status` `text` | | 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
| Marks | `border` `code` `em` `link` `strike` `strong` `subsup` `textColor` `underline` | | 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 3 | 24.7 |
| 43 | 0.2.0 | | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 3, 4 | 22.3 |
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.** | 4 | 5 | 5 | 8 | 3, 4 | 17.2 |
| 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 3, 4 | 12.4 |
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 3, 4, 6 | 10.4 |
| 38 | 0.3.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 4 | 7 | 1 | 19.5 |
| 47 | 0.3.0 | | **Open the README with what the package is, what it does and for whom.** | 1 | 4 | 7 | 9 | 9 | 14.0 |
| 34 | 0.3.0 | | **Read emphasis flanking by the whole character beside an astral symbol.** | 2 | 3 | 3 | 6 | 3, 4 | 12.6 |
| 42 | 0.3.0 | | **Trim a text leaf's trailing blanks in linear time.** | 1 | 2 | 5 | 9 | 8 | 12.5 |
| 48 | 0.3.0 | | **Keep the release path publishing past npm's bypass-2FA token retirement.** | 4 | 4 | 8 | 3 | 9 | 11.7 |
| 31 | 0.3.0 | | **Make the branch-coverage figure repeat across runs of an unchanged tree.** | 2 | 3 | 3 | 4 | 1 | 11.2 |
| 33 | 0.3.0 | | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 8 | 10.7 |
| 46 | 0.3.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 7, 9 | 10.3 |
| 9 | 0.3.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 6 | 8 | 9 | 10.3 |
| 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 9 | 10.6 |
Plain markdown covers `blockquote`, `bulletList`, `codeBlock`, `heading`, `orderedList`, ## Details
`paragraph`, `rule`, `listItem`, `hardBreak`, `text`, and the `code`, `em`, `link` and `strong`
marks; `strike` is the flavour's `~~` carve-out. Everything else is what the flavour is for. ### 40. Make `markdownToAdf(adfToMarkdown(doc))` deep-equal `doc` for every document `adfToMarkdown` takes.
Today it holds for editor-normal documents only: two adjacent text nodes with the same marks merge,
an empty `attrs`, `marks` or `content` drops, and `-0` reads back `0` — shapes pipelines and bots
build. Spell each so it reads back as written; CommonMark's spelling stays wherever the document
holds none of these shapes. The spellings are part of the chunk. `docs/decisions.md` §Equality is
editor-normal, `spec/flavour.md` and `corpus/README.md` follow, and the tests drop `toEditorNormal`.
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.
Lands after item 6. The CommonMark spec suite also runs against `markdownToHtml`. The README
documents HTML as it documents markdown, and its tagline and `package.json`'s `description` regain
HTML.
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
Goal 1 has every call return a result; the boolean guard is the one export that does not, and it
cannot say which branch refused, where `not-an-adf-document`'s message already does. Breaking:
`MIGRATION.md` shows the guard's replacement.
### 6. Specify the HTML dialect.
Element-by-element mapping, the `data-*` fidelity scheme, the opaque-carry form, and the documented
foreign-element set `htmlToAdf` accepts — the set `markdownToAdf` shares (`spec/flavour.md` §Raw
HTML in input). The set sorts per `docs/decisions.md` §Foreign HTML sorts three ways.
### 43. Give each markdown input its own reader, strict to its own standard.
Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a
directive, a pipe table or a `~~` pair becomes a flavour node where CommonMark reads plain text. A
caller names the markdown it hands in: CommonMark, read as its spec says, or the lossless flavour,
read as `spec/flavour.md` says. Breaking: `MIGRATION.md` says which call a caller takes.
### 49. Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.
Lands after item 43. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads
two (spec examples 301 and 302), and so must the CommonMark reader. `spec/flavour.md` merges them in
the lossless flavour on purpose and parts two adjacent lists with `!adf:listBreak`. The chunk
settles by Goals 3 and 4 whether the flavour follows, and asks where the Goals do not decide. That
answer also settles what `adfToMarkdown` and `adfToPlainMarkdown` write for two adjacent lists, and
whether `!adf:listBreak` still reads. Breaking, so it ships beside item 43: `MIGRATION.md`'s
Readings table gains its row, and its Spellings table one if `!adf:listBreak` retires. Examples 301
and 302 lose their `pending` exceptions, and the spelling leaves the README's "Four CommonMark
spellings" bullet, which counts one fewer.
### 51. Match a reference label to its definition under Unicode case folding.
`link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec
example 540); lowercasing and then uppercasing folds it. Breaking, so it ships beside item 43:
`MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go, and its spelling leaves
the README's "Four CommonMark spellings" bullet, which counts one fewer.
### 50. Read `[](/url)` and `[]()` as CommonMark's empty link.
Both stay literal text today (spec examples 484 and 487). ADF holds no empty text node to carry a
link mark, so the chunk settles what the empty link builds by Goals 3 and 6, and asks where they do
not decide; whatever it builds, both still parse, since a bot relies on plain CommonMark being valid
input. Breaking, so it ships beside item 43: `MIGRATION.md`'s Readings table gains its row. Its
`pending` exceptions go, and its spelling leaves the README's "Four CommonMark spellings" bullet,
which counts one fewer.
### 38. Spell a lone surrogate in a text node so it survives a UTF-8 encode.
`adfToMarkdown` emits it verbatim, so markdown stored as UTF-8 reads back U+FFFD; attribute values
already escape it.
### 47. Open the README with what the package is, what it does and for whom.
It opens with the pre-launch rationale — Atlassian's REST APIs, `pf-editor-service/convert` being
decommissioned, a link to JRACLOUD-77436. The background goes entirely, no endpoint, ticket or "why"
note left. The badges are npm's version and the Gitea Actions status. The README names the lossy
pair, `adfToPlainMarkdown` and `plainMarkdownToAdf`, and the flavours it writes and reads — GitHub
Flavored Markdown's alerts and task lists, Obsidian Flavored Markdown's callouts — so a search for
any of these names finds the package.
### 34. Read emphasis flanking by the whole character beside an astral symbol.
Check whether `line-escaping.ts`'s `charAt` and the parser's flanking read one UTF-16 unit beside an
astral symbol — a lone surrogate is neither punctuation nor symbol, where CommonMark reads `😀` as
punctuation — and, where they do, read the code point, with a fixture per direction.
### 42. Trim a text leaf's trailing blanks in linear time.
`plain-inline.ts`'s `leafEdges` finds the trail with an unanchored `/[ \t]*$/`, quadratic in a run
of blanks inside one leaf: a paragraph of `a`, 80 000 spaces, `b` takes 6.5 s in
`adfToPlainMarkdown`. Scan backward, as the expand title's trim does.
### 48. Keep the release path publishing past npm's bypass-2FA token retirement.
Lands after 2027-01-01, or after a release run fails on the token, whichever comes first: the
maintainer chose on 2026-10-02 to wait and see whether the retirement bites. It holds back no
release: when the rest of its release is done, it moves to the next. `0.1.0` published only once the
npm token carried **Bypass 2FA**: the account requiring no 2FA on writes was not enough, and npm
answered `EOTP` until the token itself bypassed. npm retires bypass-2FA tokens for direct publishing
around January 2027, leaving them `npm stage publish`, which a maintainer approves with 2FA; its
replacement — trusted publishing over OIDC — supports GitHub-hosted Actions, GitLab.com's shared
runners and CircleCI's cloud, self-hosted runners planned without a date. Revisit: whether npm has
added Gitea or self-hosted OIDC, and otherwise whether the release moves to the staged publish —
which fits badly with publish-on-merge, and is the maintainer's trade to weigh. The Goals and G
cells are provisional: no README goal covers the release path.
### 31. Make the branch-coverage figure repeat across runs of an unchanged tree.
Three Node test legs over one unchanged tree reported `emit/inline-line.ts` at 95.83%, 96.23% and
96.23%, and the total at 98.80%, 98.84% and 98.84% (2026-09-21). `--experimental-test-coverage`
counts branches off V8's own coverage, which the runner's parallel files and V8's optimization make
run-dependent, so the number the floor is read against is not the code's alone. The floor of 98
holds today on 0.8 points of slack and `docs/decisions.md` §The coverage floors says it only ever
moves upward, so the first raise to the measured figure reddens a run that changed nothing. Make the
measurement repeatable, or state the number the floor may be raised to and why it is not the
measured one.
### 33. Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.
`adfToMarkdown` spends 23 s on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking
cannot spell re-emits the whole line before riding the carry, quadratic in the runs, and the plain
reduction's `spellableLine` drops one mark per re-emit the same way. Make both linear.
### 46. Publish the bundle size in the README, failing the release pipeline when it drifts.
Lands after item 7, which changes it. The quantity is what a consumer downloads and loads: the
tarball `npm pack` produces, its unpacked `dist`, and the built JavaScript minified + gzipped — the
figure the competitors advertise (marklassian's "12kb") and the only apples-to-apples one, since
ours ships tsc's unminified output and no minifier yet (decide here whether to minify for the build
or report the unminified gzip). The figure lands in README §The package beside the "no runtime
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6
kB.
### 8. Ship a CLI.
The Goals and G cells are provisional: no README goal or persona covers a CLI yet. The chunk
proposes both (the maintainer, 2026-10-02), and they land in the README's `## Goals` and `##
Audience` with the CLI.
+1 -1
View File
@@ -27,6 +27,6 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"exclude": ["src/**/*.test.ts", "src/property-harness.ts"], "exclude": ["src/**/*.test.ts", "src/conformance/property-harness.ts"],
"include": ["src"] "include": ["src"]
} }