Compare commits
84 Commits
b9760d0af2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5798a3cde1 | |||
| 55c6f01517 | |||
| 8a27cb52d8 | |||
| 730bf88da7 | |||
| db4599837c | |||
| b36f6fbeef | |||
| be54d24dc0 | |||
| e844943d46 | |||
| 0fd98de1fd | |||
| 7dd3ef0b24 | |||
| 03dbc46763 | |||
| 04931ab35a | |||
| d88f4a4351 | |||
| bb83888d8f | |||
| a0e4247cfd | |||
| b01dfd4b0b | |||
| 63e0e1dd52 | |||
| 7b57c1edc8 | |||
| 03f18ee17b | |||
| bfbe4a51fb | |||
| 5a9fd06974 | |||
| 758549ab24 | |||
| e8733068ab | |||
| 2f0ab4dda9 | |||
| 9f23c89719 | |||
| 58a9c89dfc | |||
| fb0d2b4eac | |||
| 638881b555 | |||
| d26e4e8152 | |||
| e501b95cb3 | |||
| b9fa9bff85 | |||
| 9778844c11 | |||
| 261dc3a5cf | |||
| addef08fa4 | |||
| 438bf8876b | |||
| bf610fc438 | |||
| f9f863fb28 | |||
| 9c1573ce02 | |||
| 146257cc49 | |||
| 9f87aa8b8b | |||
| 19a70e0e18 | |||
| 5e5e123ff7 | |||
| 7217b75a1d | |||
| deb639d004 | |||
| 31d3f53a58 | |||
| 80a45b69da | |||
| 8287a483db | |||
| 7b38eba29e | |||
| ee9a7e7562 | |||
| 317262b240 | |||
| eccfd12d2d | |||
| 7bab478729 | |||
| 00c89dd63b | |||
| fd3452b124 | |||
| d56e5b4f57 | |||
| 36604b1e2b | |||
| 1791542b3c | |||
| f7d2eb6517 | |||
| 79005f07be | |||
| 7f67998c3b | |||
| b39525cad7 | |||
| d0cb63fcf2 | |||
| 1d3a020926 | |||
| 4579f32fcd | |||
| 6df17a7069 | |||
| 4349df9017 | |||
| d68f311a14 | |||
| 608ef266ed | |||
| a2c620da27 | |||
| e4eecebeab | |||
| 230d590fb4 | |||
| 7846502a1a | |||
| 79d9b4db53 | |||
| 912c5039aa | |||
| fb78063f12 | |||
| 5cffe82dd5 | |||
| d9f2d325ca | |||
| 419bbcb4f2 | |||
| 4d7e8c944c | |||
| ff9bb7fab2 | |||
| 5a7279059d | |||
| 0ac5dccf11 | |||
| 6fc0d6bba9 | |||
| 423dcbbff5 |
@@ -48,11 +48,13 @@ In `docs/decisions.md`:
|
|||||||
- Firefox reads the build
|
- Firefox reads the build
|
||||||
- The coverage floors
|
- The coverage floors
|
||||||
- The size ratchet
|
- The size ratchet
|
||||||
|
- The project ships under the comprehension floor until items 59, 60, 61 and 62 land
|
||||||
- Properties on a fixed seed
|
- Properties on a fixed seed
|
||||||
- The CommonMark suite checks three ways
|
- The CommonMark suite checks three ways
|
||||||
- The flavour spec is read as a source
|
- The flavour spec is read as a source
|
||||||
- The node tables answer to Atlassian's schema
|
- The node tables answer to Atlassian's schema
|
||||||
- Nothing recurses unbounded
|
- Nothing recurses unbounded
|
||||||
|
- No value `JSON.parse` or `structuredClone` builds loops a call
|
||||||
- Nothing spreads an unbounded array
|
- Nothing spreads an unbounded array
|
||||||
- A retry loop checks its own termination
|
- A retry loop checks its own termination
|
||||||
- Readers scan by index
|
- Readers scan by index
|
||||||
@@ -167,16 +169,16 @@ reading is the ask. Never ask "A or B?": state the gap, the earlier entries of i
|
|||||||
nearest text, a candidate entry in that file's voice, and the instance it yields. An entry that
|
nearest text, a candidate entry in that file's voice, and the instance it yields. An entry that
|
||||||
keeps collecting instances is wrong: rewrite it.
|
keeps collecting instances is wrong: rewrite it.
|
||||||
|
|
||||||
Which output the audience expects — README goal 5 — is settled by a reader panel rather than
|
Which output the audience expects — README goal 6 — is settled by a reader panel rather than
|
||||||
asked: three fresh-context readers, one per README persona the question serves, each given only
|
asked: three fresh-context readers, one per README persona the question serves, each given only
|
||||||
`## Audience` and the input, writing what they expect before picking among outputs the goals
|
`## Audience` and the input, writing what they expect before picking among outputs the goals
|
||||||
allow, rendered, shuffled, with no rationale and nothing saying what is implemented. Three agreeing
|
allow, rendered, shuffled, with no rationale and nothing saying what is implemented. Three agreeing
|
||||||
settle it; otherwise four more read, five of seven settle it, and less is a missing goal, asked.
|
settle it; otherwise four more read, five of seven settle it, and less is a missing goal, asked.
|
||||||
The verdict lands in `docs/decisions.md`.
|
The verdict lands in `docs/decisions.md`.
|
||||||
|
|
||||||
Every new or changed markdown or HTML spelling goes to such a panel, seated by the people who read
|
A writer panel settles every new or changed markdown or HTML spelling: a reader panel whose readers
|
||||||
and write that format (README `## Audience`); a question about what an app relies on seats the
|
are the people who read and write that format (README `## Audience`). A panel of the developer
|
||||||
developer personas.
|
personas settles a question about what an app relies on.
|
||||||
|
|
||||||
### Stated numbers
|
### Stated numbers
|
||||||
|
|
||||||
|
|||||||
+22
-10
@@ -4,18 +4,28 @@
|
|||||||
|
|
||||||
- **Breaking:** directives, the inline opaque carry among them (now `!adf:carry{json="…"}`), are
|
- **Breaking:** directives, the inline opaque carry among them (now `!adf:carry{json="…"}`), are
|
||||||
spelled under an `!adf:` prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`,
|
spelled under an `!adf:` prefix (`!adf:name … !adf:/name`, `!adf:name[content]{attrs}`,
|
||||||
`!adf:name arg {attrs}`) in place of the `:::`/`::`/`:name` forms, and the block carry is a code
|
`!adf:name arg {attrs}`) in place of the `:::`/`::`/`:name` forms: text holding an unescaped
|
||||||
fence whose info string `adf:<type>` names the node's type, its body the node's JSON without
|
`!adf:` is claimed. Convert stored markdown per `MIGRATION.md`.
|
||||||
`type`: text holding an unescaped `!adf:` and a code fence whose info string opens `adf:` are
|
- **Breaking:** the block carry is a code fence whose info string `adf:<type>` names the node's
|
||||||
|
type, its body the node's JSON without `type`, and a code fence whose info string opens `adf:` is
|
||||||
claimed, and `adf` is an ordinary code block language. Convert stored markdown per `MIGRATION.md`.
|
claimed, and `adf` is an ordinary code block language. Convert stored markdown per `MIGRATION.md`.
|
||||||
- **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` read markdown holding no block as a
|
- **Breaking:** `markdownToAdf` and `plainMarkdownToAdf` read markdown holding no block as a
|
||||||
document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}` spells a document holding no
|
document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}`
|
||||||
`content` key.
|
spells a document holding no `content` key.
|
||||||
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc`: two adjacent text nodes a reader would
|
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it: two adjacent text
|
||||||
join are parted by `!adf:textBreak{}`, an empty `attrs`, `content` or `marks` is spelled
|
nodes CommonMark would read back as one are parted by `!adf:textBreak{}`, an empty `attrs`,
|
||||||
`{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is spelled `-0`, and a `codeBlock`
|
`content` or `marks` is spelled `{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is
|
||||||
of several text nodes is a fence per node. A `codeBlock` holding other than plain text nodes
|
spelled `-0`, and a `codeBlock` of several text nodes is a fence per node. A `codeBlock` holding
|
||||||
rides the block carry, where it was refused.
|
other than plain text nodes rides the block carry, where it was refused.
|
||||||
|
- **Breaking:** `unspellable-character` and `unspellable-line-start` leave `ConvertErrorCode`, and
|
||||||
|
`adfToMarkdown` no longer returns `unspellable-whitespace`. A carriage return in text is spelled
|
||||||
|
` ` and a null character `!adf:text{text="\u0000"}`; a paragraph line opening with a code span
|
||||||
|
is spelled as that code span, its backticks opening no fence; a code block holding either
|
||||||
|
character, a code span holding a null character, a text node holding no text or holding
|
||||||
|
`content`, and an `emoji`, `mention` or `status` whose `text` holds a line ending or a null
|
||||||
|
character ride the carry. See `MIGRATION.md`.
|
||||||
|
- Fix a link `title` holding a null character: it is written as `!adf:link[text]{attrs}`, where it
|
||||||
|
read back as U+FFFD.
|
||||||
- **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no
|
- **Breaking:** `unspellable-link` leaves `ConvertErrorCode`; a link whose `href` or `title` no
|
||||||
CommonMark escape spells is written as `!adf:link[text]{attrs}`.
|
CommonMark escape spells is written as `!adf:link[text]{attrs}`.
|
||||||
- **Breaking:** some directive refusals carry `malformed-directive` where they carried
|
- **Breaking:** some directive refusals carry `malformed-directive` where they carried
|
||||||
@@ -30,6 +40,8 @@
|
|||||||
`collection`, `id` and `occurrenceKey` directly where they rode the opaque carry.
|
`collection`, `id` and `occurrenceKey` directly where they rode the opaque carry.
|
||||||
- Fix an image inside another image's description: it flattens into the alt text, where it was
|
- Fix an image inside another image's description: it flattens into the alt text, where it was
|
||||||
refused.
|
refused.
|
||||||
|
- Fix a cyclic document, in which an object holds itself: `adfToMarkdown` and `adfToPlainMarkdown`
|
||||||
|
refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, where all three hung.
|
||||||
|
|
||||||
## 0.1.0
|
## 0.1.0
|
||||||
|
|
||||||
|
|||||||
+18
-8
@@ -5,8 +5,9 @@
|
|||||||
Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling without an error,
|
Directives moved under the `!adf:` prefix. `0.2.0` reads `0.1.0`'s spelling without an error,
|
||||||
turning each directive into text and each carried node into an `adf` code block. Before `0.2.0`
|
turning each directive into text and each carried node into an `adf` code block. Before `0.2.0`
|
||||||
reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the
|
reads any `0.1.0` markdown, convert what is stored or in flight (an open editor, a queue) with the
|
||||||
recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by
|
recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by the
|
||||||
the tables below. Stored ADF needs no change.
|
tables below. Stored ADF needs one change: give a document holding no `content` key `content: []`.
|
||||||
|
`0.1.0` built that shape from empty markdown, meaning the empty document.
|
||||||
|
|
||||||
### Convert markdown
|
### Convert markdown
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ import { markdownToAdf as markdownToAdf010 } from 'adf-codec-0.1'
|
|||||||
|
|
||||||
function migrateMarkdown(stored: string) {
|
function migrateMarkdown(stored: string) {
|
||||||
const parsed = markdownToAdf010(stored)
|
const parsed = markdownToAdf010(stored)
|
||||||
// 0.1.0's reader was editor-normal, so a document with no content key always meant an empty one.
|
// 0.1.0 dropped an empty content array, so a document with no content key meant an empty one.
|
||||||
return parsed.ok ? adfToMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed
|
return parsed.ok ? adfToMarkdown({ ...parsed.value, content: parsed.value.content ?? [] }) : parsed
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -30,8 +31,9 @@ function migrateMarkdown(stored: string) {
|
|||||||
- Convert each document once: a second pass can return ok while turning the directives into text.
|
- Convert each document once: a second pass can return ok while turning the directives into text.
|
||||||
Stop `0.1.0` writing first, and record which documents are converted.
|
Stop `0.1.0` writing first, and record which documents are converted.
|
||||||
- A refusal carrying `position` is `0.1.0`'s parse, which refused that markdown before too. One
|
- A refusal carrying `position` is `0.1.0`'s parse, which refused that markdown before too. One
|
||||||
without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF rather than keeping
|
without is `0.2.0`'s emit: store the document `markdownToAdf010` read as ADF, with
|
||||||
the unconverted markdown.
|
`content: parsed.value.content ?? []` as the recipe gives it, rather than keeping the unconverted
|
||||||
|
markdown.
|
||||||
|
|
||||||
### Spellings
|
### Spellings
|
||||||
|
|
||||||
@@ -56,16 +58,24 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them |
|
| a link whose text already holds one (`[a<https://example.com/>b](/v)`) | marks every node the inner link does not, splitting the outer link around it | leaves the outer brackets literal text; write the pieces as separate links to keep them |
|
||||||
| markdown holding no block (`markdownToAdf("")`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former |
|
| markdown holding no block (`markdownToAdf("")`) | `{ type: 'doc', version: 1 }` | `{ content: [], type: 'doc', version: 1 }`; `!adf:doc {content=none}` reads as the former |
|
||||||
| a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | the block carry; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block |
|
| a code fence whose info string opens `adf:` (```` ```adf:x ````) | a `codeBlock` with that language | the block carry, refusing a body that is not one node's canonical JSON; write `!adf:codeBlock {language="adf:x"}` around a bare fence to keep the code block |
|
||||||
|
|
||||||
### Error codes
|
### Error codes
|
||||||
|
|
||||||
`unspellable-link` leaves `ConvertErrorCode`: a `switch` naming it stops compiling, and the link
|
`unspellable-character`, `unspellable-line-start` and `unspellable-link` leave `ConvertErrorCode`:
|
||||||
it named converts.
|
a `switch` naming one stops compiling. A document `adfToMarkdown` refused with one of them now
|
||||||
|
converts; markdown the parser refused with one of them now refuses with the code the table gives.
|
||||||
|
|
||||||
| Input | `0.1.0` | `0.2.0` |
|
| Input | `0.1.0` | `0.2.0` |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` |
|
| a link whose `href` or `title` no CommonMark escape spells, on emit | `unspellable-link` | spells `!adf:link[text]{attrs}` |
|
||||||
|
| a carriage return in text, on emit | `unspellable-character` | spells ` ` |
|
||||||
|
| a null character in text, on emit | `unspellable-character` | spells `!adf:text{text="\u0000"}` |
|
||||||
|
| a carriage return or null character in a code block, or a null character in a code span, on emit | `unspellable-character` | rides the carry |
|
||||||
|
| a paragraph line opening with a code span of three or more backticks, on emit | `unspellable-line-start` | spells the code span |
|
||||||
|
| a block directive whose CommonMark spelling holds a carriage return, a null character or a code span opening a line, on parse | `unspellable-character`, `unspellable-line-start` | `unsupported-node-shape`; write the CommonMark spelling |
|
||||||
|
| an `emoji`, `mention` or `status` whose `text` holds a line ending or a null character, on emit | `unspellable-whitespace`, `unspellable-character` | rides the inline carry |
|
||||||
|
| a text node holding no text, or holding `content`, on emit | `unsupported-node-shape` | rides the carry |
|
||||||
| a `codeBlock` holding other than plain text nodes, on emit | `unsupported-node-shape` | rides the block carry |
|
| a `codeBlock` holding other than plain text nodes, on emit | `unsupported-node-shape` | rides the block carry |
|
||||||
| a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` |
|
| a leaf node given a body (`media`, `listBreak`) | `unsupported-node-shape` | `malformed-directive` |
|
||||||
| a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` |
|
| a node with a block body written as a leaf (`panel`) | `unsupported-node-shape` | `malformed-directive` |
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ The most useful ADF conversion library available, judged by these goals, in prio
|
|||||||
|
|
||||||
1. **Lossless, and every call returns a result, never a throw.**
|
1. **Lossless, and every call returns a result, never a throw.**
|
||||||
2. **ADF is the hub.**
|
2. **ADF is the hub.**
|
||||||
3. **Each format reads and writes as its standard says.**
|
3. **Easy to find, and clear at a glance what it does.**
|
||||||
4. **Our markdown is CommonMark, extended only where CommonMark has no spelling.**
|
4. **Each format reads and writes as its standard says.**
|
||||||
5. **No surprises: output reads and edits the way its audience expects.**
|
5. **Our markdown is CommonMark, extended only where CommonMark has no spelling.**
|
||||||
6. **Lossy conversion drops form, never content.**
|
6. **No surprises: output reads and edits the way its audience expects.**
|
||||||
7. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.**
|
7. **Lossy conversion drops form, never content.**
|
||||||
8. **Fast, and linear in the document's size.**
|
8. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.**
|
||||||
9. **Easy to find, and clear at a glance what it does.**
|
9. **Fast, and linear in the document's size as JSON.**
|
||||||
|
|
||||||
## Audience
|
## Audience
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ if (result.ok) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Serves Goals 1, 2 and 7. Pure functions, each taking a whole document and returning a whole
|
Serves Goals 1, 2 and 8. Pure functions, each taking a whole document and returning a whole
|
||||||
result; no I/O, no configuration. `markdownToHtml` and `htmlToMarkdown` convert through ADF:
|
result; no I/O, no configuration. `markdownToHtml` and `htmlToMarkdown` convert through ADF:
|
||||||
they keep only what ADF holds, and refuse what `markdownToAdf` or `htmlToAdf` refuses.
|
they keep only what ADF holds, and refuse what `markdownToAdf` or `htmlToAdf` refuses.
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ htmlToMarkdown(html: string): Result<string> // 0.2.0
|
|||||||
|
|
||||||
## Plain markdown
|
## Plain markdown
|
||||||
|
|
||||||
Serves Goal 6. Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` writes
|
Serves Goal 7. Plain markdown is a second flavour of the same grammar. `adfToPlainMarkdown` writes
|
||||||
markdown other tools render — GitHub, GitLab, Obsidian and the like — keeping the content and
|
markdown other tools render — GitHub, GitLab, Obsidian and the like — keeping the content and
|
||||||
dropping the rest: attributes, colours, layout, identity. Content is what a reader of the rendered
|
dropping the rest: attributes, colours, layout, identity. Content is what a reader of the rendered
|
||||||
document sees or follows: its text, images and link targets. It refuses only
|
document sees or follows: its text, images and link targets. It refuses only
|
||||||
@@ -136,6 +136,7 @@ read replaces mentions, attachments and macros with text.
|
|||||||
- A newline in text is a hard break and in an expand's title a space, edge whitespace outside a
|
- A newline in text is a hard break and in an expand's title a space, edge whitespace outside a
|
||||||
link or code span is trimmed, carriage returns and null characters are removed, and an empty
|
link or code span is trimmed, carriage returns and null characters are removed, and an empty
|
||||||
paragraph drops.
|
paragraph drops.
|
||||||
|
- A text node's `content` drops: Atlassian's schema forbids it, so no rendered document shows it.
|
||||||
- An ordered list numbered past `999999999`, or adjacent ordered lists whose numbering does not
|
- An ordered list numbered past `999999999`, or adjacent ordered lists whose numbering does not
|
||||||
continue, is one bullet list keeping its numbers as text.
|
continue, is one bullet list keeping its numbers as text.
|
||||||
- A task list beside a bullet or decision list, or holding a block other than a task, joins one
|
- A task list beside a bullet or decision list, or holding a block other than a task, joins one
|
||||||
@@ -175,17 +176,18 @@ Parsing — `markdownToAdf` and `plainMarkdownToAdf`, and `htmlToAdf` at `0.2.0`
|
|||||||
|
|
||||||
| Code | Fires when | What you can do |
|
| Code | Fires when | What you can do |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in an opaque carry | write the spelling the message names, or keep it literal: escape the prefix — `\!adf:`, block and inline alike — or wrap a code fence whose info string opens `adf:` in `!adf:codeBlock {language="adf:…"}` with a bare fence |
|
| `malformed-directive` | an `!adf:` the grammar cannot read — a prefix completing no directive, an unclosed container, `[content]` or `{attrs}`, a closer with no container of its name open, a leaf given a body, `{attrs}` out of order or duplicated, invalid JSON in an opaque carry | write the spelling the message names, or keep it literal: escape the prefix — `\!adf:`, block and inline alike — or, for a code fence whose info string opens `adf:`, drop the info string and wrap the fence in `!adf:codeBlock {language="adf:…"}` |
|
||||||
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
|
| `malformed-pipe-table` | a pipe row that is no pipe table — a missing or ragged `---` delimiter row, an alignment colon in it, or a row not opening with a pipe | open every row with a pipe and give the delimiter row the header's cell count; to keep the lines literal text instead, escape the leading pipe of every one — escaping a single row leaves the next to open a fresh table and fail the same way |
|
||||||
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
|
| `unknown-directive-name` | a directive whose name is no node or mark this version spells | check the name in `spec/flavour.md`, or escape the prefix as `\!adf:`; the spelling itself is well formed, so a later minor may give the name meaning |
|
||||||
| `unmappable-html` | the input holds an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the lossless flavour |
|
| `unmappable-html` | the input holds an HTML construct the documented element set does not map, a comment and a processing instruction among them — at this version that is every raw HTML construct in markdown, the element set landing at `0.2.0` | remove the construct, or write what it holds in the lossless flavour |
|
||||||
| `unmappable-image` | an image sits inside other content that is not another image's description, or carries a title | give the image a paragraph of its own and drop the title |
|
| `unmappable-image` | an image sits inside other content that is not another image's description, or carries a title | give the image a paragraph of its own and drop the title |
|
||||||
|
| `unspellable-whitespace` | an `emoji`, `mention` or `status` directive's content slot spells a newline or a carriage return — ` `, ` `, `!adf:text{text="\n"}` | replace it with a space, or write the node as `!adf:carry` — an inline directive never spans lines |
|
||||||
|
|
||||||
Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`:
|
Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`:
|
||||||
|
|
||||||
| Code | Fires when | What you can do |
|
| Code | Fires when | What you can do |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `not-an-adf-document` | the value handed in is no ADF document — a missing or wrong `type`, a stray key, a node that is not a node | guard the boundary you receive JSON at with `isAdfDocument`; the message names the branch that refused |
|
| `not-an-adf-document` | the value handed in is no ADF document — a missing or wrong `type`, a stray key, a node that is not a node, an object in the document holding itself | guard the boundary you receive JSON at with `isAdfDocument`; the message names the branch that refused |
|
||||||
| `unsupported-document-version` | the document's `version` is not 1 | keep the ADF and pass the document over, or show it read-only; the version is the site's, not yours to change |
|
| `unsupported-document-version` | the document's `version` is not 1 | keep the ADF and pass the document over, or show it read-only; the version is the site's, not yours to change |
|
||||||
|
|
||||||
Either direction. The emitter's own refusals are in this group — a parse reaches them by asking it
|
Either direction. The emitter's own refusals are in this group — a parse reaches them by asking it
|
||||||
@@ -194,32 +196,27 @@ emit refuses:
|
|||||||
|
|
||||||
| Code | Fires when | What you can do |
|
| Code | Fires when | What you can do |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `unspellable-character` | text or a code block holds a carriage return or a null character, which CommonMark rewrites wherever it sits | strip or replace the character; no escape carries it through the round-trip |
|
|
||||||
| `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span |
|
|
||||||
| `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines |
|
|
||||||
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
|
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
|
||||||
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark | write the shape the message names; `spec/flavour.md` lists every type's attributes and body |
|
| `unsupported-node-shape` | parsing: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting: a node carrying one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content` | fix what the message names; `spec/flavour.md` lists every type's attributes and body |
|
||||||
|
|
||||||
## The guarantees
|
## The guarantees
|
||||||
|
|
||||||
Serves Goals 1, 3 and 4.
|
Serves Goals 1, 4 and 5.
|
||||||
|
|
||||||
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` — every key and value as `doc` holds it,
|
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as JSON, for a document of plain objects
|
||||||
adjacent text nodes, an empty `attrs`, `content` or `marks` and `-0` included, and unknown node
|
as `JSON.parse` builds them — every key and value as `doc` holds it, adjacent text nodes, an
|
||||||
types carried opaquely
|
empty `attrs`, `content` or `marks` and `-0` included, and unknown node types carried opaquely
|
||||||
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
|
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#unknown-nodes-ride-the-carry)).
|
||||||
- Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from
|
- Markdown this library reads, and markdown it writes, means what the CommonMark spec says; from
|
||||||
`0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
|
`0.2.0`, well-formed HTML means what the HTML standard says, read or written. The bullets below
|
||||||
name every exception.
|
name every exception.
|
||||||
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
|
- Plain CommonMark is valid input to `markdownToAdf` apart from the raw HTML `unmappable-html`
|
||||||
names, with four carve-outs — literal text matching directive, pipe-table or strikethrough
|
names, with four carve-outs — literal text matching directive, pipe-table or strikethrough syntax,
|
||||||
syntax, and a code fence whose info string opens `adf:`, is claimed (escapable — `spec/flavour.md`)
|
and a code fence whose info string opens `adf:`, are claimed (each can be kept literal —
|
||||||
— and one gap: a CommonMark image fits only as
|
`spec/flavour.md`) — and one gap: a CommonMark image fits only as its own title-less paragraph;
|
||||||
its own title-less paragraph; mid-text and titled images are error results, save an image inside
|
mid-text and titled images are error results, save an image inside another's description, which
|
||||||
another's description, which flattens into the alt text. Converting back yields the library's
|
flattens into the alt text. Converting back yields the library's canonical spelling, which
|
||||||
canonical spelling, which round-trips byte-identically — where it converts back at all: a parse
|
round-trips byte-identically.
|
||||||
succeeding is no promise of that, so keep the source until the way back succeeds.
|
|
||||||
``` ` `` ` ``` reads cleanly and then refuses.
|
|
||||||
- Four CommonMark spellings parse without an error and build a document the reference
|
- Four CommonMark spellings parse without an error and build a document the reference
|
||||||
implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's
|
implementation renders differently: `[](/url)` and `[]()` stay literal text against CommonMark's
|
||||||
empty link, a list continuing past a marker change stays one list against CommonMark's two, a
|
empty link, a list continuing past a marker change stays one list against CommonMark's two, a
|
||||||
@@ -228,18 +225,17 @@ Serves Goals 1, 3 and 4.
|
|||||||
text, which the spec requires and the reference itself breaks, nesting one `<a>` in the other.
|
text, which the spec requires and the reference itself breaks, nesting one `<a>` in the other.
|
||||||
The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite
|
The first three are pinned `pending` in `corpus/commonmark-spec/exceptions.json`; the suite
|
||||||
holds no example of the fourth.
|
holds no example of the fourth.
|
||||||
- Not every document converts back: `adfToMarkdown` is partial on valid ADF — a text node holding
|
- Not every document converts back: `adfToMarkdown` refuses a document nesting past 500 levels, and
|
||||||
a carriage return, or a paragraph line beginning with a code span whose backticks read back as a
|
the node shapes the `unsupported-node-shape` row lists. Show the refusal and keep the document
|
||||||
fence. Show the refusal and keep the document read-only; saving markdown you could not produce
|
read-only; saving markdown you could not produce is the loss the round-trip exists to stop.
|
||||||
is the loss the round-trip exists to stop.
|
|
||||||
- The pipe table narrows GFM's twice: every row opens with a pipe, so GFM's bare form is an error
|
- The pipe table narrows GFM's twice: every row opens with a pipe, so GFM's bare form is an error
|
||||||
result rather than the prose it reads as, and an alignment colon in the delimiter row is an
|
result rather than the prose it reads as, and an alignment colon in the delimiter row is an
|
||||||
error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in
|
error too — ADF holds no column alignment. The trailing pipe is canonical output, optional in
|
||||||
input.
|
input.
|
||||||
- Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist
|
- Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist
|
||||||
is the `taskList` directive — `plainMarkdownToAdf` turns the marker into a `taskList`.
|
is the `taskList` directive — `plainMarkdownToAdf` turns the marker into a `taskList`.
|
||||||
- A document nested deeper than 500 levels is an error result, not a stack overflow, and no input
|
- A document nested deeper than 500 levels is an error result, not a stack overflow, and no call
|
||||||
makes a call loop forever.
|
loops forever on a string, or on a value `JSON.parse` or `structuredClone` builds.
|
||||||
- The emitted formats are semver surface
|
- The emitted formats are semver surface
|
||||||
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#the-formats-are-api)).
|
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#the-formats-are-api)).
|
||||||
- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` deep-equals `doc`; fidelity HTML cannot express rides
|
- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` deep-equals `doc`; fidelity HTML cannot express rides
|
||||||
@@ -249,7 +245,7 @@ Serves Goals 1, 3 and 4.
|
|||||||
|
|
||||||
## The package
|
## The package
|
||||||
|
|
||||||
Serves Goal 7. ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts`
|
Serves Goal 8. ESM only, no runtime dependencies, public npm. Built JavaScript with `.d.ts`
|
||||||
beside it. Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs
|
beside it. Pure ECMAScript at an ES2022 baseline, reaching for no host API; the test suite runs
|
||||||
under Node, Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
|
under Node, Deno and Bun, and a headless Firefox converts the corpus through the built entrypoint.
|
||||||
Contract: [`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#any-es2022-engine), §Any
|
Contract: [`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#any-es2022-engine), §Any
|
||||||
|
|||||||
+6
-5
@@ -16,10 +16,11 @@ One directory per contract kind:
|
|||||||
is the suite; `refusals.json` pins each refusing example to its error `code`; `exceptions.json`
|
is the suite; `refusals.json` pins each refusing example to its error `code`; `exceptions.json`
|
||||||
pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a
|
pins each known divergence by `check`, `example`, `kind` and the exact `divergence`, with a
|
||||||
`reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node
|
`reason`. `kind` is `mark-model` (the permanent count divergence from ADF's mark-per-text-node
|
||||||
model), `unspellable` (parses but the flavour has no spelling) or `pending` (a parser gap).
|
model) or `pending` (a parser gap).
|
||||||
|
|
||||||
JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's
|
JSON is two-space indent, keys sorted, and a document read back must deep-equal the fixture's
|
||||||
(`docs/decisions.md` §Equality is deep). `spec.json` is the vendored, upstream machine-readable suite, byte-exact from
|
(`docs/decisions.md` §Equality is deep). `spec.json` is the vendored, upstream machine-readable
|
||||||
[spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json) (CommonMark 0.31.2, © John
|
suite, byte-exact from [spec.commonmark.org](https://spec.commonmark.org/0.31.2/spec.json)
|
||||||
MacFarlane, [CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not
|
(CommonMark 0.31.2, © John MacFarlane,
|
||||||
re-serialized by the corpus gate.
|
[CC-BY-SA-4.0](https://creativecommons.org/licenses/by-sa/4.0/)), and is not re-serialized by the
|
||||||
|
corpus gate.
|
||||||
|
|||||||
@@ -13,27 +13,6 @@
|
|||||||
"kind": "pending",
|
"kind": "pending",
|
||||||
"reason": "A list continuing past a marker change renders as two lists, the parser opens one."
|
"reason": "A list continuing past a marker change renders as two lists, the parser opens one."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"check": "fixpoint",
|
|
||||||
"divergence": "unspellable-line-start",
|
|
||||||
"example": 330,
|
|
||||||
"kind": "unspellable",
|
|
||||||
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"check": "fixpoint",
|
|
||||||
"divergence": "unspellable-line-start",
|
|
||||||
"example": 331,
|
|
||||||
"kind": "unspellable",
|
|
||||||
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"check": "fixpoint",
|
|
||||||
"divergence": "unspellable-line-start",
|
|
||||||
"example": 340,
|
|
||||||
"kind": "unspellable",
|
|
||||||
"reason": "A paragraph opens with a code span whose backticks read back as a fence."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"check": "count",
|
"check": "count",
|
||||||
"divergence": "em 2/1",
|
"divergence": "em 2/1",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
!adf:codeBlock {attrs=empty}
|
||||||
|
```js
|
||||||
|
a
|
||||||
|
```
|
||||||
|
```js
|
||||||
|
b
|
||||||
|
```
|
||||||
|
!adf:/codeBlock
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
unsupported-node-shape
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
!adf:codeBlock
|
||||||
|
```
|
||||||
|
a
|
||||||
|
```
|
||||||
|
```
|
||||||
|
```
|
||||||
|
!adf:/codeBlock
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "a\r\nb",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "codeBlock"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
```adf:codeBlock
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "a\r\nb",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -82,6 +82,13 @@
|
|||||||
},
|
},
|
||||||
"marks": [],
|
"marks": [],
|
||||||
"type": "codeBlock"
|
"type": "codeBlock"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"language": "js"
|
||||||
|
},
|
||||||
|
"content": [],
|
||||||
|
"type": "codeBlock"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"type": "doc",
|
"type": "doc",
|
||||||
|
|||||||
@@ -30,3 +30,6 @@ Next
|
|||||||
```js
|
```js
|
||||||
```
|
```
|
||||||
!adf:/codeBlock
|
!adf:/codeBlock
|
||||||
|
|
||||||
|
!adf:codeBlock {content=empty language=js}
|
||||||
|
!adf:/codeBlock
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"type": "code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "``",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
``` `` ```
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": "a\r\nb\u0000c",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"marks": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"href": "https://example.com/",
|
||||||
|
"title": "t\u0000"
|
||||||
|
},
|
||||||
|
"type": "link"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"text": "link",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"attrs": {
|
||||||
|
"color": "neutral",
|
||||||
|
"text": "a\nb"
|
||||||
|
},
|
||||||
|
"type": "status"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
a !adf:text{text="\n"}b!adf:text{text="\u0000"}c!adf:link[link]{href="https://example.com/" title="t\u0000"}
|
||||||
|
|
||||||
|
!adf:carry{json="{\"attrs\":{\"color\":\"neutral\",\"text\":\"a\\nb\"},\"type\":\"status\"}"}!adf:carry{json="{\"type\":\"text\"}"}
|
||||||
+126
-108
@@ -23,73 +23,62 @@ sanitized and a mention keeps the test user's real account id.
|
|||||||
|
|
||||||
## Markdown in is a canonical fixpoint
|
## Markdown in is a canonical fixpoint
|
||||||
|
|
||||||
2026-08-23, the maintainer. Goals 1 and 4. Valid while markdown input may be written by hand.
|
2026-08-23, the maintainer; every parsed document has converted back since 2026-10-04. Goals 1 and
|
||||||
|
5. Valid while markdown input may be written by hand.
|
||||||
|
|
||||||
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
|
The other direction is a canonical fixpoint, not byte-identity: human markdown normalizes, the way
|
||||||
back yields the library's canonical spelling, and that spelling round-trips byte-identically —
|
back yields the library's canonical spelling, and that spelling round-trips byte-identically.
|
||||||
where there is a way back. CommonMark spells some things the flavour has no escape for — a
|
|
||||||
paragraph opening with a code span whose backticks read back as a fence — so a parse succeeding
|
|
||||||
does not imply a spellable document; `corpus/commonmark-spec/exceptions.json` names those.
|
|
||||||
|
|
||||||
## Equality is deep
|
## Equality is deep
|
||||||
|
|
||||||
2026-08-24, deep 2026-10-03, the maintainer. Goal 1. Valid while a pipeline or a bot can build a
|
2026-08-24, deep 2026-10-03, the maintainer. Goal 1. Valid while a pipeline or a bot can build a
|
||||||
shape the editor would not.
|
shape the editor would not.
|
||||||
|
|
||||||
"Equals" is `assert.deepStrictEqual`: every key and value `doc` holds, two adjacent text nodes, an
|
"Equals" is deep equality over the document's JSON values: `assert.deepStrictEqual` on plain objects
|
||||||
empty `attrs`, `content` or `marks` and `-0` included, with no normalization on either side.
|
as `JSON.parse` builds them, since ADF is JSON. Every key and value in `doc` counts, including two
|
||||||
CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader
|
adjacent text nodes, an empty `attrs`, `content` or `marks`, and `-0`. Neither side is normalized.
|
||||||
builds what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction
|
CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader builds
|
||||||
reads and writes editor-normal ADF — adjacent text nodes of identical marks and no attributes
|
what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction reads and
|
||||||
merged, `-0` as `0`, and an empty `attrs`, `content` or `marks` the absent key — so two documents
|
writes editor-normal ADF — adjacent text nodes of identical marks and no attributes merged, `-0` as
|
||||||
the editor holds equal write the same plain markdown.
|
`0`, and an empty `attrs`, `content` or `marks` the absent key, except the doc's `content`, which
|
||||||
|
ADF's schema requires — so two documents the editor holds equal write the same plain markdown.
|
||||||
|
|
||||||
## `!adf:textBreak{}` parts text CommonMark would join
|
## `!adf:textBreak{}` parts text CommonMark would join
|
||||||
|
|
||||||
2026-10-03, the maintainer. Goal 1. Valid while CommonMark reads adjacent text as one run.
|
2026-10-03, the maintainer. Goal 1. Valid while CommonMark reads adjacent text as one run.
|
||||||
|
|
||||||
Two adjacent text nodes a reader would build back as one — neither carried, neither holding
|
Two adjacent text nodes CommonMark would read back as one are parted by the reserved inline leaf
|
||||||
`attrs` or an empty key, their marks identical, attributes included — are parted by the reserved
|
`!adf:textBreak{}`, mirroring `!adf:listBreak`: a leaf building no node keeps both nodes and asks
|
||||||
inline leaf `!adf:textBreak{}`, mirroring `!adf:listBreak`: it builds no node, and anywhere but
|
nothing of the text around it. A code span holds no directive, so the spans close and reopen
|
||||||
between two such nodes, or given `[content]` or `{attrs}`, it is `unsupported-node-shape`. It sits
|
around the leaf. The grammar: `spec/flavour.md` §Inline nodes, **Adjacent text nodes**.
|
||||||
inside every mark spelling the pair shares, `**Hello, !adf:textBreak{}world**`; a code span holds
|
|
||||||
no directive, so it closes and reopens, `` `a`!adf:textBreak{}`b` ``. A carried node never joins
|
|
||||||
its neighbour, so a carry needs no break. A mark run breaks on any difference in the mark, `attrs:
|
|
||||||
{}` against no `attrs` included.
|
|
||||||
|
|
||||||
## An empty key spells `empty`
|
## An empty key spells `empty`
|
||||||
|
|
||||||
2026-10-03, a writer panel and the maintainer. Goals 1 and 5. Valid while no attribute value
|
2026-10-03, a writer panel and the maintainer. Goals 1 and 6. Valid while no attribute value
|
||||||
spells an empty object or array.
|
spells an empty object or array.
|
||||||
|
|
||||||
An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the
|
An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the
|
||||||
bare value `empty` on every directive — block, inline node and mark: `{attrs=empty}`,
|
bare value `empty`, so a container opener and closer with nothing between them stays the node
|
||||||
`{content=empty}`, `{marks=empty}`. A writer panel chose the spelling, 5 of 7. A container
|
holding no `content` key. A writer panel chose the spelling, 5 of 7. The grammar: `spec/flavour.md`
|
||||||
spelling `content=empty` closes with no body, so an empty pair stays the node holding no content
|
§Directives, **Attributes**, and §Marks.
|
||||||
key; a leaf spells it too, `!adf:rule {content=empty}`. A node CommonMark spells takes its
|
|
||||||
directive form to hold an empty key, and a text node holding one rides the inline carry, as does a
|
|
||||||
node under an `em`, `strong`, `strike`, `code` or `link` mark whose `attrs` is empty, since none of
|
|
||||||
those spellings holds attributes. Any other value of a reserved key is `unsupported-node-shape`,
|
|
||||||
except on a block's `marks`, which reads it as the marks array in JSON.
|
|
||||||
|
|
||||||
## `-0` is spelled `-0`
|
## `-0` is spelled `-0`
|
||||||
|
|
||||||
2026-10-03, the maintainer. Goal 1. Valid while JSON's own serialization writes `-0` as `0`.
|
2026-10-03, the maintainer. Goal 1. Valid while JSON's own serialization writes `-0` as `0`.
|
||||||
|
|
||||||
Wherever the flavour writes a number or a JSON value — an attribute, a `json` value, the carry —
|
`-0` is spelled `-0` wherever the flavour writes a number or a JSON value, since JSON's grammar
|
||||||
`-0` is `-0`, which JSON's grammar reads back as `-0`. An `orderedList` whose `order` is `-0` takes
|
reads it back as `-0`. The grammar: `spec/flavour.md` §Block nodes and §The CommonMark blocks.
|
||||||
the directive form, since no list marker spells the sign.
|
|
||||||
|
|
||||||
## Empty markdown is a document of no blocks
|
## Empty markdown is a document of no blocks
|
||||||
|
|
||||||
2026-10-03, a panel and the maintainer. Goals 1 and 5. Valid while ADF's schema requires
|
2026-10-03, a writer panel and the maintainer. Goals 1 and 6. Valid while ADF's schema requires
|
||||||
`content` on `doc`.
|
`content` on `doc`.
|
||||||
|
|
||||||
Markdown holding no block reads as `{ content: [], type: 'doc', version: 1 }`, the document
|
Markdown holding no block reads as `{ content: [], type: 'doc', version: 1 }`, the document
|
||||||
`spec/adf-schema/full.json` requires. A document holding no `content` key is
|
`spec/adf-schema/full.json` requires. A document holding no `content` key is
|
||||||
`!adf:doc {content=none}` standing alone as its only block, and a named error anywhere else. The
|
`!adf:doc {content=none}` as its only block, and a named error anywhere else. The writer panel split
|
||||||
writer panel split 4 for `none` and 3 for `absent`, and the maintainer chose `none`; all seven rejected a
|
4 for `none` and 3 for `absent`, and the maintainer chose `none`; all seven rejected a bare
|
||||||
bare `!adf:doc`.
|
`!adf:doc`.
|
||||||
|
|
||||||
## Unknown nodes ride the carry
|
## Unknown nodes ride the carry
|
||||||
|
|
||||||
@@ -105,17 +94,17 @@ checking its position, and refusing loses a document ADF itself keeps in an `uns
|
|||||||
|
|
||||||
## The carry fence names the node type
|
## The carry fence names the node type
|
||||||
|
|
||||||
2026-10-03, the maintainer. Goals 1 and 5. Valid while a code fence's info string reads back
|
2026-10-03, the maintainer. Goals 1 and 6. Valid while a code fence's info string reads back
|
||||||
verbatim.
|
verbatim.
|
||||||
|
|
||||||
The block carry is a code fence whose info string `adf:<type>` names the node's type, its body the
|
The block carry is a code fence whose info string `adf:<type>` names the node's type, its body the
|
||||||
node's JSON without `type`: ```` ```adf:blockCard ````. A type no info string carries back — by the
|
node's JSON without `type`: ```` ```adf:blockCard ````. A type no info string carries back — by the
|
||||||
rule a code language follows — leaves the info string `adf:` and keeps `type` in the body. Every
|
rule a code language follows — leaves the info string `adf:` and keeps `type` in the body. Every
|
||||||
info string opening `adf:` is reserved, so a `codeBlock` whose language opens so takes the
|
info string opening `adf:` is reserved, so a `codeBlock` whose language opens so takes the
|
||||||
`language` attribute, and `carry` is an ordinary language. A body holding `type` under a named
|
`language` attribute, and `carry` is an ordinary language. A body holding `type` under a named type,
|
||||||
type, or an `adf:` fence whose type an info string carries, is `unsupported-node-shape`. The
|
or a fence whose info string is `adf:` alone while its body's `type` could be spelled in the info
|
||||||
reservation claims a fence CommonMark reads as code until `todo.md` item 43 gives CommonMark its own
|
string, is `unsupported-node-shape`. The reservation claims a fence CommonMark reads as code until
|
||||||
reader.
|
`todo.md` item 43 gives CommonMark its own reader.
|
||||||
|
|
||||||
## A code block is a fence per text node
|
## A code block is a fence per text node
|
||||||
|
|
||||||
@@ -123,15 +112,13 @@ reader.
|
|||||||
node.
|
node.
|
||||||
|
|
||||||
A `codeBlock` holding several text nodes is the `!adf:codeBlock` container holding one fence per
|
A `codeBlock` holding several text nodes is the `!adf:codeBlock` container holding one fence per
|
||||||
node, each fence's info string the language; its other attributes sit on the opener, and a
|
node, so each node keeps its own text. The fences carry one info string, since ADF holds one
|
||||||
language no info string carries stays the opener's `language` with bare fences. A `codeBlock`
|
language, and none is empty beside another, since a text node holds text. The grammar:
|
||||||
spelling `content=empty` has no fence to carry the language, so the opener does. Fences with
|
`spec/flavour.md` §The CommonMark blocks, the `codeBlock` bullet.
|
||||||
differing info strings are `unsupported-node-shape` — ADF holds one language — and so is an empty
|
|
||||||
fence beside another, since a text node holds text.
|
|
||||||
|
|
||||||
## Foreign HTML sorts three ways
|
## Foreign HTML sorts three ways
|
||||||
|
|
||||||
2026-08-23, the sort 2026-09-20, the maintainer. Goals 1 and 4. Valid while ADF holds no node
|
2026-08-23, the sort 2026-09-20, the maintainer. Goals 1 and 5. Valid while ADF holds no node
|
||||||
for a bare container, a comment or a script. Lands with `todo.md` item 6.
|
for a bare container, a comment or a script. Lands with `todo.md` item 6.
|
||||||
|
|
||||||
Every foreign element `htmlToAdf` and `markdownToAdf` read sorts one of three ways, never a silent
|
Every foreign element `htmlToAdf` and `markdownToAdf` read sorts one of three ways, never a silent
|
||||||
@@ -152,14 +139,14 @@ than they buy.
|
|||||||
|
|
||||||
## Names stay text
|
## Names stay text
|
||||||
|
|
||||||
2026-08-23, the maintainer. Goal 7. Valid while resolving a name to an id needs I/O.
|
2026-08-23, the maintainer. Goal 8. Valid while resolving a name to an id needs I/O.
|
||||||
|
|
||||||
A bare `@name` or `:smile:` in typed text stays a text node. Only directives produce
|
A bare `@name` or `:smile:` in typed text stays a text node. Only directives produce
|
||||||
mention/emoji/media nodes; resolving names to ids is the consumer's job.
|
mention/emoji/media nodes; resolving names to ids is the consumer's job.
|
||||||
|
|
||||||
## Directives under `!adf:`
|
## Directives under `!adf:`
|
||||||
|
|
||||||
2026-08-23, prefixed `!adf:` 2026-09-16, the maintainer. Goals 4 and 5. Valid while prose does not
|
2026-08-23, prefixed `!adf:` 2026-09-16, the maintainer. Goals 5 and 6. Valid while prose does not
|
||||||
write `!adf:`.
|
write `!adf:`.
|
||||||
|
|
||||||
Directives are one grammar for everything markdown lacks, namespaced under `!adf:`:
|
Directives are one grammar for everything markdown lacks, namespaced under `!adf:`:
|
||||||
@@ -170,23 +157,23 @@ opener nests by itself and leaf versus container falls out of the node's content
|
|||||||
|
|
||||||
## CommonMark is a subset
|
## CommonMark is a subset
|
||||||
|
|
||||||
2026-08-23, the maintainer. Goal 4. Valid while prose rarely writes the shapes the carve-outs claim.
|
2026-08-23, the maintainer. Goal 5. Valid while prose rarely writes the shapes the carve-outs claim.
|
||||||
|
|
||||||
Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a
|
Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a
|
||||||
directive, a pipe table or a `~~` pair is claimed, and so is a code fence whose info string opens
|
directive, a pipe table or a `~~` pair is claimed, and so is a code fence whose info string opens
|
||||||
`adf:` (§The carry fence names the node type) — plus one image gap. `todo.md` item 43 ends the
|
`adf:` (§The carry fence names the node type) — plus one image gap. `todo.md` item 43 ends the
|
||||||
claims, giving CommonMark its own reader.
|
claims, giving CommonMark its own reader, and `todo.md` item 65 ends the image gap.
|
||||||
|
|
||||||
## Tables
|
## Tables
|
||||||
|
|
||||||
2026-08-23, the maintainer. Goal 5. Valid while a pipe table holds only one header row and inline
|
2026-08-23, the maintainer. Goal 6. Valid while a pipe table holds only one header row and inline
|
||||||
cells.
|
cells.
|
||||||
|
|
||||||
One header row plus plain inline cells → pipe table; anything richer → directive form.
|
One header row plus plain inline cells → pipe table; anything richer → directive form.
|
||||||
|
|
||||||
## Links
|
## Links
|
||||||
|
|
||||||
2026-09-13, nesting 2026-09-17, the maintainer. Goals 1 and 5. Valid while CommonMark's link
|
2026-09-13, nesting 2026-09-17, the maintainer. Goals 1 and 6. Valid while CommonMark's link
|
||||||
syntax is what readers edit.
|
syntax is what readers edit.
|
||||||
|
|
||||||
`[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark spells the
|
`[text](url "title")`, or `<url>` for a bare autolink-shaped text, wherever CommonMark spells the
|
||||||
@@ -205,7 +192,7 @@ accepted.
|
|||||||
|
|
||||||
## Plain task ids come from position
|
## Plain task ids come from position
|
||||||
|
|
||||||
2026-09-26, spelling 2026-09-29, the maintainer. Goals 6 and 7. Valid while a site rejects a task
|
2026-09-26, spelling 2026-09-29, the maintainer. Goals 7 and 8. Valid while a site rejects a task
|
||||||
node with no `localId`.
|
node with no `localId`.
|
||||||
|
|
||||||
`plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId`
|
`plainMarkdownToAdf` gives each `taskList`, `taskItem` and `blockTaskItem` lacking one a `localId`
|
||||||
@@ -217,7 +204,7 @@ ids are only skipped.
|
|||||||
|
|
||||||
## A callout title keeps its link targets
|
## A callout title keeps its link targets
|
||||||
|
|
||||||
2026-09-29, the maintainer. Goals 5 and 6. Valid while an expand's `title` is a string.
|
2026-09-29, the maintainer. Goals 6 and 7. Valid while an expand's `title` is a string.
|
||||||
|
|
||||||
`plainMarkdownToAdf` writes a link in a folded callout's title as its text and its target in
|
`plainMarkdownToAdf` writes a link in a folded callout's title as its text and its target in
|
||||||
parentheses: `> [!faq]- See [x](http://y)` reads to the title `See x (http://y)`. A link whose text
|
parentheses: `> [!faq]- See [x](http://y)` reads to the title `See x (http://y)`. A link whose text
|
||||||
@@ -226,7 +213,7 @@ is its target, with or without `mailto:`, keeps its text alone: `<http://y>` tit
|
|||||||
|
|
||||||
## The plain flavour's spellings
|
## The plain flavour's spellings
|
||||||
|
|
||||||
2026-09-14, panels 2026-09-25 and 2026-09-29, the maintainer. Goals 5 and 6. Valid while GitHub's
|
2026-09-14, panels 2026-09-25 and 2026-09-29, the maintainer. Goals 6 and 7. Valid while GitHub's
|
||||||
renderer is the one the audience's markdown is read in.
|
renderer is the one the audience's markdown is read in.
|
||||||
|
|
||||||
README §Plain markdown's rows come from a survey of GitHub, GitLab, Gitea, Obsidian, Pandoc,
|
README §Plain markdown's rows come from a survey of GitHub, GitLab, Gitea, Obsidian, Pandoc,
|
||||||
@@ -245,7 +232,7 @@ past `[x]`/`[ ]`, and lifting bare URLs, `@name`, `:shortcode:` or ISO dates int
|
|||||||
|
|
||||||
## The HTML dialect
|
## The HTML dialect
|
||||||
|
|
||||||
2026-08-23, the maintainer. Goals 5 and 7. Valid while HTML output is read by consumers styling it
|
2026-08-23, the maintainer. Goals 6 and 8. Valid while HTML output is read by consumers styling it
|
||||||
themselves.
|
themselves.
|
||||||
|
|
||||||
The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*`
|
The HTML dialect mirrors the markdown flavour: semantic elements, stable `adf-*` classes, `data-*`
|
||||||
@@ -253,7 +240,7 @@ for what HTML cannot express, text always escaped. No stylesheet ships.
|
|||||||
|
|
||||||
## No runtime dependencies
|
## No runtime dependencies
|
||||||
|
|
||||||
2026-08-23, the maintainer. Goal 7. Valid while ~20 lines of own code, or a vendored table, do each
|
2026-08-23, the maintainer. Goal 8. Valid while ~20 lines of own code, or a vendored table, do each
|
||||||
job a dependency would.
|
job a dependency would.
|
||||||
|
|
||||||
`dependencies` is empty. A runtime dependency enters only through an entry here stating why ~20
|
`dependencies` is empty. A runtime dependency enters only through an entry here stating why ~20
|
||||||
@@ -263,7 +250,7 @@ and HTML parsers are written in this repo.
|
|||||||
## Standards ship as data
|
## Standards ship as data
|
||||||
|
|
||||||
2026-08-30, the CommonMark suite 2026-09-05 and ADF's schemas 2026-09-13, the maintainer. Goals 1,
|
2026-08-30, the CommonMark suite 2026-09-05 and ADF's schemas 2026-09-13, the maintainer. Goals 1,
|
||||||
4 and 7. Valid while each table is fixed data a dependency would only wrap.
|
5 and 8. Valid while each table is fixed data a dependency would only wrap.
|
||||||
|
|
||||||
A table a standard fixes is data rather than a dependency: HTML5's 2125 semicolon-terminated
|
A table a standard fixes is data rather than a dependency: HTML5's 2125 semicolon-terminated
|
||||||
character references ship packed in their own module, so entity decoding is complete without one.
|
character references ship packed in their own module, so entity decoding is complete without one.
|
||||||
@@ -286,7 +273,7 @@ nodes that break it.
|
|||||||
|
|
||||||
## Any ES2022 engine
|
## Any ES2022 engine
|
||||||
|
|
||||||
2026-09-01, the maintainer. Goal 7. Valid while ES2022 is the floor browsers and servers share.
|
2026-09-01, the maintainer. Goal 8. Valid while ES2022 is the floor browsers and servers share.
|
||||||
|
|
||||||
The library runs on any ES2022 engine, not only Node — a browser as readily as a server. The
|
The library runs on any ES2022 engine, not only Node — a browser as readily as a server. The
|
||||||
shipped source is ECMAScript and nothing else: no host import, no host global, no DOM.
|
shipped source is ECMAScript and nothing else: no host import, no host global, no DOM.
|
||||||
@@ -300,13 +287,13 @@ never the higher one those repo-only tools want.
|
|||||||
|
|
||||||
## ESM only
|
## ESM only
|
||||||
|
|
||||||
2026-08-23, the maintainer. Goal 7. Valid while the audience's toolchains all import ES modules.
|
2026-08-23, the maintainer. Goal 8. Valid while the audience's toolchains all import ES modules.
|
||||||
|
|
||||||
No CommonJS build, no dual-package hazard.
|
No CommonJS build, no dual-package hazard.
|
||||||
|
|
||||||
## One built entrypoint
|
## One built entrypoint
|
||||||
|
|
||||||
2026-08-23, the maintainer. Goal 7. Valid while Node refuses to type-strip under `node_modules`.
|
2026-08-23, the maintainer. Goal 8. Valid while Node refuses to type-strip under `node_modules`.
|
||||||
|
|
||||||
Built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint — Node refuses to
|
Built JavaScript, `.d.ts` beside it. Do not add a TypeScript-source entrypoint — Node refuses to
|
||||||
type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so it cannot serve
|
type-strip under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`), so it cannot serve
|
||||||
@@ -314,7 +301,7 @@ an npm consumer.
|
|||||||
|
|
||||||
## Public on npm
|
## Public on npm
|
||||||
|
|
||||||
2026-08-23, the name 2026-09-01, the maintainer. Goals 2 and 7. Valid while the package's source
|
2026-08-23, the name 2026-09-01, the maintainer. Goals 2 and 8. Valid while the package's source
|
||||||
stays public beside it.
|
stays public beside it.
|
||||||
|
|
||||||
Published to public npm as `@larvit/adf-codec`. Public source: the Gitea repo goes public,
|
Published to public npm as `@larvit/adf-codec`. Public source: the Gitea repo goes public,
|
||||||
@@ -349,8 +336,9 @@ switches on `code` with no `default`.
|
|||||||
`unsupported-node-shape` — since a code no input reaches is one no consumer can switch on
|
`unsupported-node-shape` — since a code no input reaches is one no consumer can switch on
|
||||||
(2026-09-20).
|
(2026-09-20).
|
||||||
- A refusal no spelling recovers from is a gap in the flavour rather than a code: give the flavour
|
- A refusal no spelling recovers from is a gap in the flavour rather than a code: give the flavour
|
||||||
the spelling and the code goes (`unspellable-link`, 2026-09-13). A cause the carry answers gets
|
the spelling and the code goes (`unspellable-link`, 2026-09-13; `unspellable-character` and
|
||||||
no code: a mark no spelling writes rides the carry with its node.
|
`unspellable-line-start`, 2026-10-04). A cause the carry answers gets no code: a mark no
|
||||||
|
spelling writes rides the carry with its node.
|
||||||
|
|
||||||
## Which code a cause takes
|
## Which code a cause takes
|
||||||
|
|
||||||
@@ -359,25 +347,24 @@ handles one cause alike whichever node, attribute or direction raised it.
|
|||||||
|
|
||||||
- A code names the cause; where one cause recurs across node types, across one mark's attributes
|
- A code names the cause; where one cause recurs across node types, across one mark's attributes
|
||||||
or across directions, one code covers them all and `path` and `message` say which —
|
or across directions, one code covers them all and `path` and `message` say which —
|
||||||
`unsupported-nesting-depth` is the 500-level guard whichever direction hits it,
|
`unsupported-nesting-depth` is the 500-level guard whichever direction hits it.
|
||||||
`unspellable-character` the text node and the code block alike. Where two codes stay apart, the
|
`unspellable-whitespace` is the newline an inline directive's content slot spells in input; the
|
||||||
line between them is what they name: `unspellable-character` is a character CommonMark rewrites
|
emitter carries such a node. A null character in that slot is `unsupported-node-shape`, since
|
||||||
wherever text holds it, `unspellable-whitespace` the newline no inline directive's content slot
|
`unspellable-whitespace` does not read true of it (2026-10-04).
|
||||||
spans, in either direction.
|
|
||||||
- A claim code names the spelling claimed, never the node that spelling would have built: a
|
- A claim code names the spelling claimed, never the node that spelling would have built: a
|
||||||
malformed `!adf:table` is a `malformed-directive`, and an alignment colon a
|
malformed `!adf:table` is a `malformed-directive`, and an alignment colon a
|
||||||
`malformed-pipe-table` — the flavour's own delimiter row is `-` runs, so the grammar refuses the
|
`malformed-pipe-table` — the flavour's own delimiter row is `-` runs, so the grammar refuses the
|
||||||
colon rather than ADF's missing column model doing it. What the grammar itself refuses stays a
|
colon rather than ADF's missing column model doing it. What the grammar itself refuses stays a
|
||||||
claim code, key order among it, and a leaf given a body is refused at its opener, as a container
|
claim code, key order among it, and a leaf given a body is refused at its opener, as a container
|
||||||
missing its closer is (2026-09-16).
|
missing its closer is (2026-09-16).
|
||||||
- A directive whose name reads back to no node is `unknown-directive-name` rather than a claim
|
- A directive whose name reads back to no node is `unknown-directive-name` rather than a claim code
|
||||||
code — the spelling is well formed, and telling that apart from a typo is what a consumer
|
— the spelling is well formed, and telling that apart from a typo is what a consumer switches on
|
||||||
switches on when a later MINOR gives the name meaning. A reserved name is a known name, so never
|
when a later MINOR gives the name meaning. A reserved name is a known name, so never that code,
|
||||||
that code, and the names the flavour reserves part on form: a form the grammar does not have is
|
and the names the flavour reserves part on form: a form the grammar does not have is a claim code
|
||||||
a claim code — `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place
|
— `!adf:carry`, whose carry is the fence — and a well-formed form in the wrong place is
|
||||||
is `unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one
|
`unsupported-node-shape`, `!adf:listBreak` parting anything but two adjacent lists of one type,
|
||||||
type, `!adf:textBreak{}` anything but two text nodes a reader joins, `!adf:doc` standing beside
|
`!adf:textBreak{}` anything but two adjacent text nodes CommonMark would read back as one,
|
||||||
another block (2026-09-01, the text break and `doc` 2026-10-03).
|
`!adf:doc` standing beside another block (2026-09-01, the text break and `doc` 2026-10-03).
|
||||||
- A well-formed directive the node tables refuse — an attribute a node does not hold or spells
|
- A well-formed directive the node tables refuse — an attribute a node does not hold or spells
|
||||||
elsewhere, a value outside its kind or its canonical spelling, an argument, or a body of a shape
|
elsewhere, a value outside its kind or its canonical spelling, an argument, or a body of a shape
|
||||||
its content model does not take — is `unsupported-node-shape`, the emitter's code for the same
|
its content model does not take — is `unsupported-node-shape`, the emitter's code for the same
|
||||||
@@ -388,21 +375,21 @@ handles one cause alike whichever node, attribute or direction raised it.
|
|||||||
|
|
||||||
## `message` and `path`
|
## `message` and `path`
|
||||||
|
|
||||||
2026-09-03, the path 2026-09-23, the maintainer. Goals 1 and 4. Valid while a person fixing the
|
2026-09-03, the path 2026-09-23, the maintainer. Goals 1 and 5. Valid while a person fixing the
|
||||||
input reads `message`.
|
input reads `message`.
|
||||||
|
|
||||||
- A message names the violation, not the rule alone — a rule by itself states a truth the reader
|
- A message names the violation, not the rule alone — a rule by itself states a truth the reader
|
||||||
must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose
|
must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose
|
||||||
it names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and
|
it names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and
|
||||||
inline alike, `\|` for every pipe row.
|
inline alike, `\|` for every pipe row.
|
||||||
- `not-an-adf-document` carries the document's own path throughout: seven of the guard's eight
|
- `not-an-adf-document` carries the document's own path throughout: seven of the guard's nine
|
||||||
branches read the document's own shape, and threading a path to the eighth — a malformed node
|
branches read the document's own shape, and threading a path to the other two, which look anywhere
|
||||||
anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces. The message
|
in the tree — a malformed node, or an object holding itself — wants the manual stack §Nothing
|
||||||
names the violation instead.
|
recurses unbounded forces. The message names the violation instead.
|
||||||
|
|
||||||
## Publish on a version bump
|
## Publish on a version bump
|
||||||
|
|
||||||
2026-08-23, converging 2026-09-03, the maintainer. Goal 7. Valid while CI on `main` holds the npm
|
2026-08-23, converging 2026-09-03, the maintainer. Goal 8. Valid while CI on `main` holds the npm
|
||||||
token.
|
token.
|
||||||
|
|
||||||
`package.json` version on `main` is the source of truth. CI on `main`: tests green and the version
|
`package.json` version on `main` is the source of truth. CI on `main`: tests green and the version
|
||||||
@@ -418,7 +405,7 @@ depend on a store that the gate would then have to keep.
|
|||||||
|
|
||||||
## Docs describe the release being built
|
## Docs describe the release being built
|
||||||
|
|
||||||
2026-09-16, the maintainer. Goal 7. Valid while a bump on `main` publishes.
|
2026-09-16, the maintainer. Goal 8. Valid while a bump on `main` publishes.
|
||||||
|
|
||||||
Docs on `main` describe the release being built rather than the version npm holds, so they match it
|
Docs on `main` describe the release being built rather than the version npm holds, so they match it
|
||||||
the moment the bump publishes; add no interim note marking the gap.
|
the moment the bump publishes; add no interim note marking the gap.
|
||||||
@@ -434,7 +421,7 @@ nesting a spelling inside its own kind (`*(*a*)*`) names that mark once.
|
|||||||
|
|
||||||
## The gate runs on Deno and Bun
|
## The gate runs on Deno and Bun
|
||||||
|
|
||||||
2026-09-01, Deno's reason 2026-09-28, the maintainer. Goals 3 and 7. Valid while the library claims
|
2026-09-01, Deno's reason 2026-09-28, the maintainer. Goals 4 and 8. Valid while the library claims
|
||||||
any ES2022 engine.
|
any ES2022 engine.
|
||||||
|
|
||||||
The gate runs the suite under Deno and Bun as well as Node. Bun runs JavaScriptCore, the one engine
|
The gate runs the suite under Deno and Bun as well as Node. Bun runs JavaScriptCore, the one engine
|
||||||
@@ -446,7 +433,7 @@ over the corpus rather than over a smoke import.
|
|||||||
|
|
||||||
## The gate installs the tarball
|
## The gate installs the tarball
|
||||||
|
|
||||||
2026-09-03, the maintainer. Goal 7. Valid while consumers install the packed package.
|
2026-09-03, the maintainer. Goal 8. Valid while consumers install the packed package.
|
||||||
|
|
||||||
The gate packs the build and installs the tarball under `package-tests/`, so `files`, `exports`
|
The gate packs the build and installs the tarball under `package-tests/`, so `files`, `exports`
|
||||||
and `types` are proved on the artifact that ships rather than on the source tree a self-reference
|
and `types` are proved on the artifact that ships rather than on the source tree a self-reference
|
||||||
@@ -458,7 +445,7 @@ resolver maps them, under `NodeNext` alone; a `.d.ts` reader that is not `tsc` s
|
|||||||
|
|
||||||
## Firefox reads the build
|
## Firefox reads the build
|
||||||
|
|
||||||
2026-09-04, the maintainer. Goal 7. Valid while the library claims a browser and no other leg runs
|
2026-09-04, the maintainer. Goal 8. Valid while the library claims a browser and no other leg runs
|
||||||
SpiderMonkey.
|
SpiderMonkey.
|
||||||
|
|
||||||
A headless Firefox loads `dist/index.js` over HTTP and converts the round-trip, normalization and
|
A headless Firefox loads `dist/index.js` over HTTP and converts the round-trip, normalization and
|
||||||
@@ -501,6 +488,26 @@ config gone missing fails the leg instead of falling back to oxlint's own defaul
|
|||||||
`--deny-warnings`, since a rule from a category this config never names arrives as a warning it
|
`--deny-warnings`, since a rule from a category this config never names arrives as a warning it
|
||||||
exits 0 on.
|
exits 0 on.
|
||||||
|
|
||||||
|
## The project ships under the comprehension floor until items 59, 60, 61 and 62 land
|
||||||
|
|
||||||
|
2026-10-03, the maintainer. KISS, a technical principle, and its comprehension floor of 7. Valid
|
||||||
|
while `todo.md` items 59, 60, 61 and 62 are open.
|
||||||
|
|
||||||
|
A four-seat comprehension panel scored the project under the floor of 7. Its scoring run of
|
||||||
|
2026-10-04 is the baseline. Since 2026-10-04, a chunk whose panel scores any dimension lower
|
||||||
|
improves what the seats named and scores again, and after three such rounds asks the maintainer.
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Junior | 7 | 5.5 | 6.5 | 6 | 6 |
|
||||||
|
| Mid | 7 | 5.5 | 6.5 | 6 | 6 |
|
||||||
|
| Senior | 7 | 5.5 | 6.5 | 5.5 | 6 |
|
||||||
|
| Architect | 7 | 5.5 | 6 | 6 | 6 |
|
||||||
|
| Mean | 7.00 | 5.50 | 6.38 | 5.88 | 6.00 |
|
||||||
|
|
||||||
|
Three panels on near-identical code scored overall means of 5.88, 5.75 and 5.75, and a seat moves
|
||||||
|
±0.5 between runs.
|
||||||
|
|
||||||
## Properties on a fixed seed
|
## Properties on a fixed seed
|
||||||
|
|
||||||
2026-09-14, the maintainer. Goal 1. Valid while a red gate must reproduce.
|
2026-09-14, the maintainer. Goal 1. Valid while a red gate must reproduce.
|
||||||
@@ -510,15 +517,15 @@ markdown, on a fixed seed in the gate; a counterexample found becomes a round-tr
|
|||||||
|
|
||||||
## The CommonMark suite checks three ways
|
## The CommonMark suite checks three ways
|
||||||
|
|
||||||
2026-08-27, the maintainer. Goals 1 and 3. Valid while the suite's answers are HTML ADF cannot be
|
2026-08-27, the maintainer. Goals 1 and 4. Valid while the suite's answers are HTML ADF cannot be
|
||||||
compared against.
|
compared against.
|
||||||
|
|
||||||
Each example is a named error or markdown that parses and emits to itself byte for byte; its
|
Each example is a named error or markdown that parses and emits to itself byte for byte; its
|
||||||
reference HTML's text, tags stripped and entities decoded, equals the parsed document's; and its
|
reference HTML's text, tags stripped and entities decoded, equals the parsed document's; and its
|
||||||
elements count the marks and nodes they map to. The fixpoint alone passes a parser returning the
|
elements count the marks and nodes they map to. The fixpoint alone passes a parser returning the
|
||||||
empty document, the text alone one dropping every emphasis. An exception is the maintainer's to
|
empty document, the text alone one dropping every emphasis. An exception is the maintainer's to
|
||||||
add, and valid CommonMark parsing to a document `adfToMarkdown` refuses where a spelling could exist
|
add, and valid CommonMark parsing to a document `adfToMarkdown` refuses is a bug to fix, never an
|
||||||
is a bug to fix, never an exception.
|
exception.
|
||||||
|
|
||||||
## The flavour spec is read as a source
|
## The flavour spec is read as a source
|
||||||
|
|
||||||
@@ -555,6 +562,15 @@ format, as its parser does. A list giving way to the directive form refuses at z
|
|||||||
than walking again; counting every list twice halved the list limit, counting the directive form
|
than walking again; counting every list twice halved the list limit, counting the directive form
|
||||||
once doubled the parser's frames per level.
|
once doubled the parser's frames per level.
|
||||||
|
|
||||||
|
## No value `JSON.parse` or `structuredClone` builds loops a call
|
||||||
|
|
||||||
|
2026-10-04, the maintainer. Goals 1 and 9. Valid while every persona hands in plain data, as
|
||||||
|
`JSON.parse` or `structuredClone` builds it.
|
||||||
|
|
||||||
|
The guards walk a value by `Object.values` and refuse a cycle in it. An inherited or non-enumerable
|
||||||
|
key, a getter, a Proxy or a custom iterator is the caller's to rule out: no persona builds one.
|
||||||
|
Goal 9's size counts ADF as JSON, so a shared object costs once per place it is held.
|
||||||
|
|
||||||
## Nothing spreads an unbounded array
|
## Nothing spreads an unbounded array
|
||||||
|
|
||||||
2026-09-18, the maintainer. Goal 1. Valid while engines cap a call's arguments.
|
2026-09-18, the maintainer. Goal 1. Valid while engines cap a call's arguments.
|
||||||
@@ -573,7 +589,7 @@ termination is the loop's own check.
|
|||||||
|
|
||||||
## Readers scan by index
|
## Readers scan by index
|
||||||
|
|
||||||
2026-08-30, the kept scan 2026-09-18, the maintainer. Goal 8. Valid while the pipeline persona
|
2026-08-30, the kept scan 2026-09-18, the maintainer. Goal 9. Valid while the pipeline persona
|
||||||
feeds documents nobody typed.
|
feeds documents nobody typed.
|
||||||
|
|
||||||
A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the line
|
A reader takes the text and an index — a sticky regex whose `lastIndex` the caller sets on the line
|
||||||
@@ -585,7 +601,7 @@ two cannot disagree — which is what makes the kept value a memo rather than a
|
|||||||
|
|
||||||
## The spelling memo
|
## The spelling memo
|
||||||
|
|
||||||
2026-09-19, the maintainer. Goal 8. Valid while the `commonMarkSpelling` ask spells a node once
|
2026-09-19, the maintainer. Goal 9. Valid while the `commonMarkSpelling` ask spells a node once
|
||||||
per level above it otherwise.
|
per level above it otherwise.
|
||||||
|
|
||||||
The parse and the plain reduction keep each node's readable spelling in a memo, so the
|
The parse and the plain reduction keep each node's readable spelling in a memo, so the
|
||||||
@@ -597,7 +613,7 @@ one. Only what succeeded is kept, so no path minted at another position is ever
|
|||||||
|
|
||||||
## Cost fixes are measured, never timed
|
## Cost fixes are measured, never timed
|
||||||
|
|
||||||
2026-09-18, the maintainer and the stability-reviewer. Goal 8. Valid while Goal 8 promises growth
|
2026-09-18, the maintainer and the stability-reviewer. Goal 9. Valid while Goal 9 promises growth
|
||||||
rather than a figure.
|
rather than a figure.
|
||||||
|
|
||||||
A cost fix that changes no behaviour lands on the suite staying green with no fixture output
|
A cost fix that changes no behaviour lands on the suite staying green with no fixture output
|
||||||
@@ -616,7 +632,7 @@ refuses it — which is how the whitespace carry finds a line edge.
|
|||||||
|
|
||||||
## Emphasis follows CommonMark's matching
|
## Emphasis follows CommonMark's matching
|
||||||
|
|
||||||
2026-08-27, the maintainer. Goals 1 and 3. Valid while CommonMark's emphasis rules are the
|
2026-08-27, the maintainer. Goals 1 and 4. Valid while CommonMark's emphasis rules are the
|
||||||
reader's.
|
reader's.
|
||||||
|
|
||||||
Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text
|
Emphasis is spelled against CommonMark's matching, never flanking alone: a delimiter run in text
|
||||||
@@ -625,7 +641,7 @@ only ones in play, and a pair that matching hands to another delimiter rides the
|
|||||||
|
|
||||||
## Readable spellings take the `try` prefix
|
## Readable spellings take the `try` prefix
|
||||||
|
|
||||||
2026-09-21, the maintainer. Goals 1 and 5. Valid while a readable spelling's refusal would cost a
|
2026-09-21, the maintainer. Goals 1 and 6. Valid while a readable spelling's refusal would cost a
|
||||||
document the general form spells.
|
document the general form spells.
|
||||||
|
|
||||||
A readable spelling tried ahead of a general one takes the `try` prefix and fails only where the
|
A readable spelling tried ahead of a general one takes the `try` prefix and fails only where the
|
||||||
@@ -647,8 +663,8 @@ is the markdown flavour's choice, not ADF's.
|
|||||||
|
|
||||||
## The source parts by ADF and format
|
## The source parts by ADF and format
|
||||||
|
|
||||||
2026-08-27, placement 2026-09-18, `adf/`'s bar 2026-09-21, the maintainer. Goal 2. Valid while
|
2026-08-27, placement 2026-09-18, `adf/`'s bar 2026-09-21, `plain/` 2026-10-03, the maintainer.
|
||||||
each format has a reader and a writer through ADF.
|
Goal 2. Valid while each format has a reader and a writer through ADF.
|
||||||
|
|
||||||
`src/adf/` holds ADF's own knowledge, imports no format, and is where a construct both formats read
|
`src/adf/` holds ADF's own knowledge, imports no format, and is where a construct both formats read
|
||||||
lives: the question is answered in ADF's vocabulary — a node type, an attribute kind, a content
|
lives: the question is answered in ADF's vocabulary — a node type, an attribute kind, a content
|
||||||
@@ -661,13 +677,15 @@ them. A primitive knowing neither ADF nor a format stays at `src/` root. A const
|
|||||||
`spec/flavour.md` draws, and a placement nothing here settles goes beside its only reader, or in
|
`spec/flavour.md` draws, and a placement nothing here settles goes beside its only reader, or in
|
||||||
what both read where there are two.
|
what both read where there are two.
|
||||||
|
|
||||||
Each format directory parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest of it
|
A flavour's own code, both directions included, sits in its own directory: `markdown/plain/`;
|
||||||
holding what both directions read. A construct's reader lives there beside the regex the emitter
|
`todo.md` item 62 moves what still sits in `parse/` and `emit/`. Otherwise each format directory
|
||||||
escapes against, so the two cannot drift; a reader with no emit counterpart goes in `parse/`,
|
parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest of it holding what both
|
||||||
unless it is part of a construct that side already holds — a grammar stays in one file rather than
|
directions read. A construct's reader lives there beside the regex the emitter escapes against, so
|
||||||
splitting across the seam. A rule both directions must answer alike — whether a list marker
|
the two cannot drift; a reader with no emit counterpart goes in `parse/`, unless it is part of a
|
||||||
interrupts a paragraph — is one function there too, never a copy per direction, however
|
construct that side already holds — a grammar stays in one file rather than splitting across the
|
||||||
conservative the copy would be. Where the rule is the emitter's own choice, input consults it
|
seam. A rule both directions must answer alike — whether a list marker interrupts a paragraph — is
|
||||||
rather than restating it, and that is the only import `parse/` takes from `emit/` —
|
one function there too, never a copy per direction, however conservative the copy would be. Where
|
||||||
`commonMarkSpelling` and `openingLinkTakesDirective` — so no fixture the emitter writes can be
|
the rule is the emitter's own choice, input consults it rather than restating it, and that is the
|
||||||
refused, and a spelling the emitter refuses gives its own error rather than a second name for it.
|
only value import `parse/` takes from `emit/` — `commonMarkSpelling` and `openingLinkTakesDirective`
|
||||||
|
— so no fixture the emitter writes can be refused, and a spelling the emitter refuses gives its own
|
||||||
|
error rather than a second name for it.
|
||||||
|
|||||||
+36
-29
@@ -4,10 +4,11 @@ The grammar of the extended markdown `adfToMarkdown` emits and `markdownToAdf` p
|
|||||||
CommonMark is a subset apart from raw HTML (below), with four carve-outs: literal text that matches
|
CommonMark is a subset apart from raw HTML (below), with four carve-outs: literal text that matches
|
||||||
directive syntax below or reads as a pipe table is claimed by the flavour, a matched `~~` pair
|
directive syntax below or reads as a pipe table is claimed by the flavour, a matched `~~` pair
|
||||||
spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal), and a code fence whose info
|
spells `strike` (escape the `!adf:`, `|` or `~` to keep it literal), and a code fence whose info
|
||||||
string opens `adf:` is the opaque carry (The opaque carry says how to keep it code) — and one gap: a CommonMark
|
string opens `adf:` is the opaque carry (drop the info string and wrap the fence in
|
||||||
image fits only as its own title-less paragraph — mid-text and titled images are named errors. The
|
`!adf:codeBlock {language="adf:…"}` to keep it code) — and one gap: a CommonMark image fits only as
|
||||||
emitted form is contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this
|
its own title-less paragraph — mid-text and titled images are named errors. The emitted form is
|
||||||
grammar in the sections below.
|
contract (`docs/decisions.md` §The formats are API). Per-node syntaxes build on this grammar in the
|
||||||
|
sections below.
|
||||||
|
|
||||||
## Canonical form
|
## Canonical form
|
||||||
|
|
||||||
@@ -180,8 +181,9 @@ product). Block and inline positions canonicalize differently, each fitting wher
|
|||||||
- **Block position**: a fenced code block with info string `adf:` and the node's type, body = the
|
- **Block position**: a fenced code block with info string `adf:` and the node's type, body = the
|
||||||
node's JSON without its `type` — two-space indent, object keys sorted: ```` ```adf:blockCard ````.
|
node's JSON without its `type` — two-space indent, object keys sorted: ```` ```adf:blockCard ````.
|
||||||
A type no info string carries back, by the rule a `codeBlock`'s language follows, leaves the info
|
A type no info string carries back, by the rule a `codeBlock`'s language follows, leaves the info
|
||||||
string `adf:` and keeps `type` in the body. A body holding `type` under a named type, or an
|
string `adf:` and keeps `type` in the body. A body holding `type` under a named type, or a fence
|
||||||
`adf:` fence whose type an info string carries, is a named error.
|
whose info string is `adf:` alone while its body's `type` could be spelled in the info string, is
|
||||||
|
a named error.
|
||||||
- **Inline position**: `!adf:carry{json="…"}` — compact serialization (keys sorted, no whitespace),
|
- **Inline position**: `!adf:carry{json="…"}` — compact serialization (keys sorted, no whitespace),
|
||||||
JSON-string-escaped into the attribute.
|
JSON-string-escaped into the attribute.
|
||||||
|
|
||||||
@@ -243,13 +245,14 @@ form.
|
|||||||
leaves empty, or in both, is a named error, and so are fences whose info strings differ and an
|
leaves empty, or in both, is a named error, and so are fences whose info strings differ and an
|
||||||
empty fence beside another. Each fence is an ordinary code block, and its info string decodes
|
empty fence beside another. Each fence is an ordinary code block, and its info string decodes
|
||||||
escapes and entity references as any other does. A node holding a child no fence holds — any but
|
escapes and entity references as any other does. A node holding a child no fence holds — any but
|
||||||
a text node carrying no marks, `attrs` or `content` — rides the block carry.
|
a text node holding text free of carriage returns and null characters, and carrying no marks,
|
||||||
|
`attrs` or `content` — rides the block carry.
|
||||||
- `heading` — container, inline body. Attributes: `level` (number), `localId` (string). `level` is
|
- `heading` — container, inline body. Attributes: `level` (number), `localId` (string). `level` is
|
||||||
the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
|
the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
|
||||||
CommonMark spelling.
|
CommonMark spelling.
|
||||||
- `orderedList` — container of `listItem`, block body. Attributes: `localId` (string), `order`
|
- `orderedList` — container of `listItem`, block body. Attributes: `localId` (string), `order`
|
||||||
(number). `order` is the first marker, so a list carrying none, one that is no whole number
|
(number). `order` is the first marker, so a list carrying none, one that is `-0` or no whole
|
||||||
from 0, or one whose markers would run past 999999999, has no CommonMark spelling.
|
number from 0, or one whose markers would run past 999999999, has no CommonMark spelling.
|
||||||
- `paragraph` — container, inline body. Attributes: `localId` (string).
|
- `paragraph` — container, inline body. Attributes: `localId` (string).
|
||||||
- `rule` — leaf. Attributes: `color` (string, `#rrggbb`), `localId` (string), `style` (`dashed`
|
- `rule` — leaf. Attributes: `color` (string, `#rrggbb`), `localId` (string), `style` (`dashed`
|
||||||
`dotted` `fade` `sketch` `solid`), `weight` (number, 1–3).
|
`dotted` `fade` `sketch` `solid`), `weight` (number, 1–3).
|
||||||
@@ -446,8 +449,10 @@ Attributes and the carry fallback read as in the block sections, the carry in it
|
|||||||
the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot as
|
the nodes below, `emoji`, `mention` and `status` spell their `text` attribute in the content slot as
|
||||||
plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
|
plain text: `[]` is the empty string, absent content is the absent attribute, non-empty content
|
||||||
parsing to anything but one text node carrying neither marks, attributes nor content is a named
|
parsing to anything but one text node carrying neither marks, attributes nor content is a named
|
||||||
error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot. The rest take no content,
|
error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not reach into the slot.
|
||||||
`!adf:text` included; content on a node that takes none is a named error.
|
An `emoji`, `mention` or `status` whose `text` attribute holds a line ending or a null character
|
||||||
|
rides the inline carry; a content slot spelling one is a named error.
|
||||||
|
The rest take no content, `!adf:text` included; content on a node that takes none is a named error.
|
||||||
|
|
||||||
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
|
- `date` — Attributes: `localId` (string), `timestamp` (string, epoch milliseconds).
|
||||||
- `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text`
|
- `emoji` — Attributes: `id` (string), `localId` (string), `shortName` (string, `:name:`), `text`
|
||||||
@@ -469,14 +474,15 @@ error, and so is a `text` key in `{attrs}`. An enclosing mark spelling does not
|
|||||||
Shipped !adf:emoji[🎉]{shortName=":tada:"} on !adf:date{timestamp=1756080000000}.
|
Shipped !adf:emoji[🎉]{shortName=":tada:"} on !adf:date{timestamp=1756080000000}.
|
||||||
```
|
```
|
||||||
|
|
||||||
**Whitespace CommonMark cannot hold.** A newline inside a text node, and a space or tab where
|
**Text CommonMark cannot hold plainly.** A carriage return in text is ` `, which CommonMark
|
||||||
CommonMark strips or refuses one — a block's inline content edges, either side of a line break, an
|
reads back as the character. Three things are spelled `!adf:text{text="…"}`: a newline inside a text
|
||||||
em, strong or strike spelling's inner edges, a pipe cell's edges — is spelled `!adf:text{text="…"}`,
|
node; a null character, which CommonMark reads as U+FFFD; and a space or tab where CommonMark strips
|
||||||
the reserved key carrying the node's text, escaped by the attribute grammar and never literal: pipe
|
or refuses one — a block's inline content edges, either side of a line break, an em, strong or
|
||||||
cells trim and pad. The emitter wraps the whitespace run alone and leaves the rest plain text, which
|
strike spelling's inner edges, a pipe cell's edges. The reserved key carries the node's text,
|
||||||
the spelled run joins on reading. Input reads that spelling alone: the value is one run of spaces
|
escaped by the attribute grammar and never literal: pipe cells trim and pad. The emitter wraps the
|
||||||
and tabs, or one run of newlines, and anything else — a mixed run, or text CommonMark carries
|
run alone and leaves the rest plain text, which the spelled run joins on reading. Input reads that
|
||||||
plainly — is a named error.
|
spelling alone: the value is one run of spaces and tabs, one run of newlines, or one run of null
|
||||||
|
characters, and anything else — a mixed run, or text CommonMark carries plainly — is a named error.
|
||||||
|
|
||||||
```
|
```
|
||||||
!adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines.
|
!adf:text{text=" "}Two leading spaces held, and one text node split!adf:text{text="\n"}over two lines.
|
||||||
@@ -487,8 +493,8 @@ carried, neither holds `attrs` or an empty key, and their marks are identical, a
|
|||||||
The reserved leaf `!adf:textBreak{}` parts such a pair, inside every mark spelling the two share; a
|
The reserved leaf `!adf:textBreak{}` parts such a pair, inside every mark spelling the two share; a
|
||||||
code span holds no directive, so it closes and reopens. It builds no node and reads only between
|
code span holds no directive, so it closes and reopens. It builds no node and reads only between
|
||||||
two such nodes: elsewhere, or with `[content]` or `{attrs}`, it is a named error (`docs/decisions.md`
|
two such nodes: elsewhere, or with `[content]` or `{attrs}`, it is a named error (`docs/decisions.md`
|
||||||
§`!adf:textBreak{}` parts text CommonMark would join). A text node holding `attrs` or an empty key
|
§`!adf:textBreak{}` parts text CommonMark would join). A text node holding `attrs`, `content` or an
|
||||||
rides the inline carry.
|
empty key, or holding no text, rides the inline carry.
|
||||||
|
|
||||||
```
|
```
|
||||||
Hello, !adf:textBreak{}world — **Hello, !adf:textBreak{}world** — `a`!adf:textBreak{}`b`
|
Hello, !adf:textBreak{}world — **Hello, !adf:textBreak{}world** — `a`!adf:textBreak{}`b`
|
||||||
@@ -527,14 +533,15 @@ that depth: `attrs: {}` differs from no `attrs`, and a directive spells it `{att
|
|||||||
breaks at every node the emitter carries, so no emitted carry sits inside a mark spelling.
|
breaks at every node the emitter carries, so no emitted carry sits inside a mark spelling.
|
||||||
|
|
||||||
An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its
|
An inline node whose marks no nesting spells — a mark type not listed here, an attrs key its
|
||||||
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs
|
spelling does not list, a value that is not the spelling's type, an attribute the spelling needs and
|
||||||
and the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside another mark, `code` over anything but a
|
the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside
|
||||||
text node or over text holding a newline, or a spelling CommonMark's flanking rules cannot open or
|
another mark, `code` over anything but a text node holding text, or over text holding a line ending
|
||||||
close where the run sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the
|
or a null character, or a spelling CommonMark's flanking rules cannot open or close where the run
|
||||||
intra-word `*` runs together with a neighbouring `**`, and the multiple-of-3 rule can leave the
|
sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere — the intra-word `*` runs
|
||||||
merged run's pairing to another delimiter — rides the inline carry whole. An opaque carry inside a
|
together with a neighbouring `**`, and the multiple-of-3 rule can leave the merged run's pairing to
|
||||||
mark spelling is a named error in input: the carry restores its node exactly, marks included
|
another delimiter — rides the inline carry whole. An opaque carry inside a mark spelling is a named
|
||||||
(`docs/decisions.md` §Unknown nodes ride the carry).
|
error in input: the carry restores its node exactly, marks included (`docs/decisions.md` §Unknown
|
||||||
|
nodes ride the carry).
|
||||||
|
|
||||||
```
|
```
|
||||||
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
|
!adf:textColor[**Overdue**]{color="#ae2e24"}, H!adf:subsup[2]{type=sub}O, !adf:underline[signed].
|
||||||
|
|||||||
@@ -82,3 +82,23 @@ test("reads a node's siblings as a walk rather than as one call's arguments", ()
|
|||||||
assert.equal(isAdfDocument(wide), true)
|
assert.equal(isAdfDocument(wide), true)
|
||||||
assert.equal(fault(wide), 'accepted')
|
assert.equal(fault(wide), 'accepted')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('refuses an object holding itself, and accepts one shared', () => {
|
||||||
|
const holdsItself = 'an ADF document is a tree: an object in it holds itself'
|
||||||
|
const paragraph: { content: unknown[]; type: string } = { content: [], type: 'paragraph' }
|
||||||
|
paragraph.content.push(paragraph)
|
||||||
|
assert.equal(fault({ content: [paragraph], type: 'doc', version: 1 }), holdsItself)
|
||||||
|
const document: { content: unknown[]; type: string; version: number } = { content: [], type: 'doc', version: 1 }
|
||||||
|
document.content.push(document)
|
||||||
|
assert.equal(fault(document), holdsItself)
|
||||||
|
const attrs: Record<string, unknown> = {}
|
||||||
|
attrs['a'] = [attrs]
|
||||||
|
assert.equal(fault({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), holdsItself)
|
||||||
|
assert.equal(fault({ content: [{ marks: [{ attrs, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), holdsItself)
|
||||||
|
assert.equal(isAdfDocument({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), false)
|
||||||
|
const bold = { type: 'strong' }
|
||||||
|
const text = { marks: [bold], text: 'x', type: 'text' }
|
||||||
|
const shared = { content: [text, text], type: 'paragraph' }
|
||||||
|
const values = { b: 1 }
|
||||||
|
assert.equal(fault({ content: [shared, shared, { attrs: { a: [values, values] }, type: 'paragraph' }], type: 'doc', version: 1 }), 'accepted')
|
||||||
|
})
|
||||||
|
|||||||
+25
-4
@@ -43,6 +43,7 @@ export function adfDocumentFault(value: unknown): ConvertFault | undefined {
|
|||||||
const held: unknown = value['content']
|
const held: unknown = value['content']
|
||||||
if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(held)}`)
|
if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(held)}`)
|
||||||
const content: readonly unknown[] = held
|
const content: readonly unknown[] = held
|
||||||
|
if (holdsItself(content)) return notADocument('an ADF document is a tree: an object in it holds itself')
|
||||||
if (!isNodeArray(content)) return notADocument("an ADF document's content holds ADF nodes: one of them is not")
|
if (!isNodeArray(content)) return notADocument("an ADF document's content holds ADF nodes: one of them is not")
|
||||||
return nestingFault(content)
|
return nestingFault(content)
|
||||||
}
|
}
|
||||||
@@ -51,7 +52,7 @@ export function attributeNestingMessage(key: string, type: string): string {
|
|||||||
return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
|
return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
|
export function holdsOnlyAttributes(node: AdfNode, attributes: readonly string[]): boolean {
|
||||||
if (nodeMarks(node).length > 0 || node.text !== undefined || emptyKeys(node).length > 0) return false
|
if (nodeMarks(node).length > 0 || node.text !== undefined || emptyKeys(node).length > 0) return false
|
||||||
return holdsOnly(nodeAttrs(node), attributes)
|
return holdsOnly(nodeAttrs(node), attributes)
|
||||||
}
|
}
|
||||||
@@ -64,11 +65,15 @@ export function emptyKeys(held: { attrs?: AdfAttributes; content?: AdfNode[]; ma
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
// A text node holding its text and marks alone: a format spells it as text, where anything more rides a carry.
|
// No attrs, content or empty marks; a format may spell this node as text, and anything more rides a carry.
|
||||||
export function isPlainText(node: AdfNode): boolean {
|
export function isBareText(node: AdfNode): boolean {
|
||||||
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
|
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isUnmarkedBareText(node: AdfNode): boolean {
|
||||||
|
return isBareText(node) && node.marks === undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function identicalMark(left: AdfMark, right: AdfMark): boolean {
|
export function identicalMark(left: AdfMark, right: AdfMark): boolean {
|
||||||
return marksKey([left]) === marksKey([right])
|
return marksKey([left]) === marksKey([right])
|
||||||
}
|
}
|
||||||
@@ -77,7 +82,6 @@ export function identicalMarks(left: readonly AdfMark[], right: readonly AdfMark
|
|||||||
return marksKey(left) === marksKey(right)
|
return marksKey(left) === marksKey(right)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Callers say when two nodes join.
|
|
||||||
export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] {
|
export function mergeAdjacentText(nodes: readonly AdfNode[], joins: (previous: AdfNode, node: AdfNode) => boolean): AdfNode[] {
|
||||||
const merged: AdfNode[] = []
|
const merged: AdfNode[] = []
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
@@ -119,6 +123,23 @@ export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] {
|
|||||||
return node.marks ?? []
|
return node.marks ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tracks ancestors alone: a tree may share an object, but an object holding itself walks the guards forever.
|
||||||
|
function holdsItself(root: object): boolean {
|
||||||
|
const pending = [{ depth: 0, item: root }]
|
||||||
|
const ancestors: object[] = []
|
||||||
|
const onPath = new Set<unknown>()
|
||||||
|
while (pending.length > 0) {
|
||||||
|
const entry = pending.pop()
|
||||||
|
if (entry === undefined) continue
|
||||||
|
while (ancestors.length > entry.depth) onPath.delete(ancestors.pop())
|
||||||
|
if (onPath.has(entry.item)) return true
|
||||||
|
ancestors.push(entry.item)
|
||||||
|
onPath.add(entry.item)
|
||||||
|
for (const child of Object.values(entry.item)) if (typeof child === 'object' && child !== null) pending.push({ depth: entry.depth + 1, item: child })
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
|
function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] {
|
||||||
const pending: unknown[] = [...value]
|
const pending: unknown[] = [...value]
|
||||||
while (pending.length > 0) {
|
while (pending.length > 0) {
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import test from 'node:test'
|
|||||||
import type { AdfNode } from '../adf/document.ts'
|
import type { AdfNode } from '../adf/document.ts'
|
||||||
import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts'
|
import { adfDocument, propertyRuns, propertyTimeout } from './property-harness.ts'
|
||||||
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
import { adfToMarkdown } from '../markdown/emit/adf-to-markdown.ts'
|
||||||
import { adfToPlainMarkdown, reduceToPlain } from '../markdown/emit/plain-reduction.ts'
|
import { adfToPlainMarkdown, reduceToPlain } from '../markdown/plain/adf-to-plain-markdown.ts'
|
||||||
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
||||||
import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
import { markdownToAdf, plainMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts'
|
||||||
import { toEditorNormal } from '../adf/editor-normal.ts'
|
import { toEditorNormal } from '../markdown/plain/editor-normal.ts'
|
||||||
|
|
||||||
const gateRuns = 1600
|
const gateRuns = 1600
|
||||||
const renamedPrefix = '!adg:'
|
const renamedPrefix = '!adg:'
|
||||||
@@ -34,11 +34,11 @@ function shownText(nodes: readonly AdfNode[]): string[] {
|
|||||||
return shown.filter((text) => text !== '')
|
return shown.filter((text) => text !== '')
|
||||||
}
|
}
|
||||||
|
|
||||||
test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => {
|
test('a generated document emits markdown that reads back to it', { timeout: propertyTimeout }, () => {
|
||||||
fc.assert(
|
fc.assert(
|
||||||
fc.property(adfDocument, (document) => {
|
fc.property(adfDocument, (document) => {
|
||||||
const emitted = adfToMarkdown(document)
|
const emitted = adfToMarkdown(document)
|
||||||
if (!emitted.ok) return
|
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
||||||
const read = markdownToAdf(emitted.value)
|
const read = markdownToAdf(emitted.value)
|
||||||
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
|
assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`)
|
||||||
assert.deepEqual(read.value, document, `reading ${JSON.stringify(emitted.value)}`)
|
assert.deepEqual(read.value, document, `reading ${JSON.stringify(emitted.value)}`)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus',
|
|||||||
const checks = ['count', 'fixpoint', 'text'] as const
|
const checks = ['count', 'fixpoint', 'text'] as const
|
||||||
|
|
||||||
type Check = (typeof checks)[number]
|
type Check = (typeof checks)[number]
|
||||||
type ExceptionKind = 'mark-model' | 'pending' | 'unspellable'
|
type ExceptionKind = 'mark-model' | 'pending'
|
||||||
|
|
||||||
type SpecExample = { example: number; html: string; markdown: string; section: string }
|
type SpecExample = { example: number; html: string; markdown: string; section: string }
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ function isCheck(value: unknown): value is Check {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isKind(value: unknown): value is ExceptionKind {
|
function isKind(value: unknown): value is ExceptionKind {
|
||||||
return value === 'mark-model' || value === 'pending' || value === 'unspellable'
|
return value === 'mark-model' || value === 'pending'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSpecExample(value: unknown): value is SpecExample {
|
function isSpecExample(value: unknown): value is SpecExample {
|
||||||
@@ -77,12 +77,12 @@ test('the CommonMark spec suite is 0.31.2, vendored byte-exact', () => {
|
|||||||
assert.equal(digest, 'd431b29d97b6f73e69d547109cf5081578fac931e72afe95639ebe766c1b2a20')
|
assert.equal(digest, 'd431b29d97b6f73e69d547109cf5081578fac931e72afe95639ebe766c1b2a20')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('every exception is unique, names a parsing example, and files a fixpoint only as unspellable', () => {
|
test('every exception is unique, names a parsing example, and files no fixpoint divergence', () => {
|
||||||
assert.equal(exceptionIndex.size, exceptions.length, 'one exception repeats an example and check another holds')
|
assert.equal(exceptionIndex.size, exceptions.length, 'one exception repeats an example and check another holds')
|
||||||
for (const entry of exceptions) {
|
for (const entry of exceptions) {
|
||||||
assert.ok(spec.some((candidate) => candidate.example === entry.example), `exception ${entry.example} names no example in the suite`)
|
assert.ok(spec.some((candidate) => candidate.example === entry.example), `exception ${entry.example} names no example in the suite`)
|
||||||
assert.equal(exampleToRefusal.get(entry.example), undefined, `exception ${entry.example} is on the refusal list, not an exception`)
|
assert.equal(exampleToRefusal.get(entry.example), undefined, `exception ${entry.example} is on the refusal list, not an exception`)
|
||||||
if (entry.check === 'fixpoint') assert.equal(entry.kind, 'unspellable', `exception ${entry.example} files a fixpoint divergence as ${entry.kind}; a fixable hole is given the spelling instead`)
|
assert.notEqual(entry.check, 'fixpoint', `exception ${entry.example} files a fixpoint divergence; the emitter spells every document the parser builds`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ function holdsDirectiveShape(document: AdfDocument): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
test('generated markdown refuses, or what it parses to refuses to emit, or its spelling reads back and spells itself', { timeout: propertyTimeout }, () => {
|
test('generated markdown refuses, or its spelling reads back and spells itself', { timeout: propertyTimeout }, () => {
|
||||||
const parameters = propertyRuns(gateRuns)
|
const parameters = propertyRuns(gateRuns)
|
||||||
let directiveShaped = 0
|
let directiveShaped = 0
|
||||||
let fixpoints = 0
|
let fixpoints = 0
|
||||||
@@ -427,7 +427,7 @@ test('generated markdown refuses, or what it parses to refuses to emit, or its s
|
|||||||
const parsed = markdownToAdf(input)
|
const parsed = markdownToAdf(input)
|
||||||
if (!parsed.ok) return
|
if (!parsed.ok) return
|
||||||
const emitted = adfToMarkdown(parsed.value)
|
const emitted = adfToMarkdown(parsed.value)
|
||||||
if (!emitted.ok) return
|
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message} — spelling ${JSON.stringify(input)}`)
|
||||||
fixpoints += 1
|
fixpoints += 1
|
||||||
if (holdsDirectiveShape(parsed.value)) directiveShaped += 1
|
if (holdsDirectiveShape(parsed.value)) directiveShaped += 1
|
||||||
const read = markdownToAdf(emitted.value)
|
const read = markdownToAdf(emitted.value)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { blockNodes } from '../adf/block-nodes.ts'
|
|||||||
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
import { directivePrefix } from '../markdown/directive-syntax.ts'
|
||||||
import { emptyKeys, mergeAdjacentText } from '../adf/document.ts'
|
import { emptyKeys, mergeAdjacentText } from '../adf/document.ts'
|
||||||
import { inlineNodes } from '../adf/inline-nodes.ts'
|
import { inlineNodes } from '../adf/inline-nodes.ts'
|
||||||
import { joinsNormally } from '../adf/editor-normal.ts'
|
import { joinsWhenRead } from '../markdown/adjacent-text.ts'
|
||||||
import { markAttributes } from '../adf/mark-attributes.ts'
|
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||||
|
|
||||||
type Positions = { block: AdfNode; inline: AdfNode }
|
type Positions = { block: AdfNode; inline: AdfNode }
|
||||||
@@ -24,7 +24,7 @@ export const propertyTimeout = 600000
|
|||||||
const depthIdentifier = fc.createDepthIdentifier()
|
const depthIdentifier = fc.createDepthIdentifier()
|
||||||
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
|
const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' }
|
||||||
const flatCommonMarkShapeWeight = 4
|
const flatCommonMarkShapeWeight = 4
|
||||||
export const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉日ー한𠀀', '==', '[!NOTE]', '[x]', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
|
export const markdownPieces = fc.constantFrom(...'aZ09 \t\n\r\0!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉日ー한𠀀', '==', '[!NOTE]', '[x]', 'ab:', 'http://', directivePrefix, `${directivePrefix}a[`, `${directivePrefix}a{`)
|
||||||
const nestingCommonMarkShapeWeight = 21
|
const nestingCommonMarkShapeWeight = 21
|
||||||
const spelledTypes = new Set(['text', ...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes)])
|
const spelledTypes = new Set(['text', ...Object.keys(blockNodes), ...Object.keys(inlineNodes), ...Object.keys(markAttributes)])
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ function heldAttributes(held: Readonly<Record<string, JsonValue | undefined>>):
|
|||||||
return attrs
|
return attrs
|
||||||
}
|
}
|
||||||
|
|
||||||
// An empty attrs, content or marks key, and adjacent text a reader would merge, stay occasional: each takes a spelling outside CommonMark.
|
// An empty attrs, content or marks key, and adjacent text CommonMark would read back as one, stay occasional: each takes a spelling outside CommonMark.
|
||||||
// The copy gives fast-check's null-prototype records the prototype a parsed node has.
|
// The copy gives fast-check's null-prototype records the prototype a parsed node has.
|
||||||
function occasionallyEmpty<T extends AdfMark | AdfNode>(arbitrary: Arbitrary<T>): Arbitrary<T> {
|
function occasionallyEmpty<T extends AdfMark | AdfNode>(arbitrary: Arbitrary<T>): Arbitrary<T> {
|
||||||
return fc.tuple(arbitrary, fc.nat({ max: 9 })).map(([held, roll]) => (roll === 0 ? { ...held } : withoutEmptyKeys(held)))
|
return fc.tuple(arbitrary, fc.nat({ max: 9 })).map(([held, roll]) => (roll === 0 ? { ...held } : withoutEmptyKeys(held)))
|
||||||
@@ -91,7 +91,7 @@ function withoutEmptyKeys<T extends AdfMark | AdfNode>(held: T): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function occasionallyApart(arbitrary: Arbitrary<AdfNode[]>): Arbitrary<AdfNode[]> {
|
function occasionallyApart(arbitrary: Arbitrary<AdfNode[]>): Arbitrary<AdfNode[]> {
|
||||||
return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes, joinsNormally)))
|
return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes, joinsWhenRead)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
|
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
|
||||||
@@ -106,7 +106,7 @@ const mark: Arbitrary<AdfMark> = occasionallyEmpty(fc.oneof(
|
|||||||
))
|
))
|
||||||
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
|
const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type })
|
||||||
|
|
||||||
const textNode = occasionallyEmpty(fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' })))
|
const textNode = occasionallyEmpty(fc.record({ marks, text: fc.oneof({ arbitrary: text, weight: 19 }, { arbitrary: fc.constant(''), weight: 1 }) }).map((held): AdfNode => ({ ...held, type: 'text' })))
|
||||||
|
|
||||||
const backtickRunNode = fc
|
const backtickRunNode = fc
|
||||||
.record({ marks: fc.oneof(fc.constant<AdfMark[]>([]), fc.constant<AdfMark[]>([{ type: 'code' }]), marks), text: fc.string({ maxLength: 6, minLength: 1, unit: fc.constantFrom('`', '``', ' ', 'a') }) })
|
.record({ marks: fc.oneof(fc.constant<AdfMark[]>([]), fc.constant<AdfMark[]>([{ type: 'code' }]), marks), text: fc.string({ maxLength: 6, minLength: 1, unit: fc.constantFrom('`', '``', ' ', 'a') }) })
|
||||||
|
|||||||
+1
-1
@@ -3,6 +3,6 @@ export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/documen
|
|||||||
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
|
export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts'
|
||||||
export type { JsonValue } from './json-value.ts'
|
export type { JsonValue } from './json-value.ts'
|
||||||
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||||
export { adfToPlainMarkdown } from './markdown/emit/plain-reduction.ts'
|
export { adfToPlainMarkdown } from './markdown/plain/adf-to-plain-markdown.ts'
|
||||||
export { isAdfDocument } from './adf/document.ts'
|
export { isAdfDocument } from './adf/document.ts'
|
||||||
export { markdownToAdf, plainMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
export { markdownToAdf, plainMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { AdfNode } from '../adf/document.ts'
|
||||||
|
import { identicalMarks, isBareText, nodeMarks } from '../adf/document.ts'
|
||||||
|
import { spellInlineLeafDirective } from './directive-syntax.ts'
|
||||||
|
|
||||||
|
export const textBreakName = 'textBreak'
|
||||||
|
|
||||||
|
export const textBreakSpelling = spellInlineLeafDirective(textBreakName, '')
|
||||||
|
|
||||||
|
// The lossless reader's rule: CommonMark reads the pair back as one text run (spec/flavour.md, Inline nodes).
|
||||||
|
export function joinsWhenRead(previous: AdfNode, node: AdfNode): boolean {
|
||||||
|
return isBareText(previous) && isBareText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node))
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ const argumentByType = new Map(
|
|||||||
export const documentName = 'doc'
|
export const documentName = 'doc'
|
||||||
|
|
||||||
// spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell.
|
// spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell.
|
||||||
export const documentAttribute = { key: 'content', value: 'none' } as const
|
export const documentAttribute = { key: 'content', value: 'none' }
|
||||||
|
|
||||||
export const documentSpelling = spellDirectiveOpener(documentName, undefined, spellAttributes([[documentAttribute.key, documentAttribute.value]]))
|
export const documentSpelling = spellDirectiveOpener(documentName, undefined, spellAttributes([[documentAttribute.key, documentAttribute.value]]))
|
||||||
|
|
||||||
|
|||||||
@@ -83,10 +83,6 @@ export function backslashEscape(text: string, index: number): string | undefined
|
|||||||
return isAsciiPunctuation(escaped) ? escaped : undefined
|
return isAsciiPunctuation(escaped) ? escaped : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function claimsLine(line: string, position: LinePosition): boolean {
|
|
||||||
return escapesLineClaim(line, 0, position) || orderedListOpener.test(line)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function claimsPipeLine(line: string): boolean {
|
export function claimsPipeLine(line: string): boolean {
|
||||||
return pipeClaim.test(line)
|
return pipeClaim.test(line)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { backslashEscape, decodeTextEscapes, holdsControlCharacter } from './grammar.ts'
|
import { backslashEscape, decodeTextEscapes, holdsControlCharacter, holdsNullCharacter } from './grammar.ts'
|
||||||
import { holdsEntityReference } from './entity-references.ts'
|
import { holdsEntityReference } from './entity-references.ts'
|
||||||
|
|
||||||
export type LinkDefinition = { destination: string; title?: string }
|
export type LinkDefinition = { destination: string; title?: string }
|
||||||
@@ -117,5 +117,5 @@ export function escapeUnbalanced(spelling: string, opener: string, closer: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
function spellTitle(title: string): string | undefined {
|
function spellTitle(title: string): string | undefined {
|
||||||
return /[\n\r\\]/.test(title) || holdsEntityReference(title) ? undefined : ` "${title.replaceAll('"', '\\"')}"`
|
return /[\n\r\\]/.test(title) || holdsNullCharacter(title) || holdsEntityReference(title) ? undefined : ` "${title.replaceAll('"', '\\"')}"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts'
|
import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts'
|
||||||
import type { ConvertFault } from '../result.ts'
|
import type { ConvertFault } from '../result.ts'
|
||||||
import type { JsonValue } from '../json-value.ts'
|
import type { JsonValue } from '../json-value.ts'
|
||||||
import { backslashEscape } from './commonmark/grammar.ts'
|
import { backslashEscape, holdsNullCharacter } from './commonmark/grammar.ts'
|
||||||
import { backtickRun, closingBacktickRun } from './commonmark/backtick-runs.ts'
|
import { backtickRun, closingBacktickRun } from './commonmark/backtick-runs.ts'
|
||||||
import { isJsonValue, overNested } from '../json-value.ts'
|
import { isJsonValue, overNested } from '../json-value.ts'
|
||||||
import { largestNesting } from '../nesting.ts'
|
import { largestNesting } from '../nesting.ts'
|
||||||
@@ -100,10 +100,10 @@ export function readSoleStringAttribute(span: DirectiveSpan, key: string): Read<
|
|||||||
return { value: spelled.decoded }
|
return { value: spelled.decoded }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both directions answer alike: an inline directive never spans lines, so no content slot holds a line ending.
|
// An inline directive never spans lines, and no content slot holds a null character; the emitter carries such a node.
|
||||||
export function slotLineEndingFault(type: string, text: string): ConvertFault | undefined {
|
export function slotFault(type: string, text: string): ConvertFault | undefined {
|
||||||
if (!/[\n\r]/.test(text)) return undefined
|
if (/[\n\r]/.test(text)) return { code: 'unspellable-whitespace', message: `the ${type} content slot holds a newline no inline directive spans` }
|
||||||
return { code: 'unspellable-whitespace', message: `the ${type} content slot holds a newline no inline directive spans` }
|
return holdsNullCharacter(text) ? unsupportedNodeShape(`the ${type} content slot holds a null character: write the node as !adf:carry`) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
|
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ test('names the node a refusal came from, and no source the emitter never read',
|
|||||||
const unspellable: AdfNode = { text: 'x', type: 'paragraph' }
|
const unspellable: AdfNode = { text: 'x', type: 'paragraph' }
|
||||||
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' }
|
const list: AdfNode = { content: [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }, { content: [unspellable], type: 'listItem' }], type: 'bulletList' }
|
||||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0])
|
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), ['content', 1, 'content', 1, 'content', 0])
|
||||||
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { type: 'text' })))), ['content', 0, 'content', 1])
|
assert.deepEqual(path(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }, { text: 'x', type: 'status' })))), ['content', 0, 'content', 1])
|
||||||
assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), [])
|
assert.deepEqual(path(adfToMarkdown({ type: 'doc', version: 2 })), [])
|
||||||
assert.equal(position(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), undefined)
|
assert.equal(position(adfToMarkdown(document(paragraph({ text: 'x', type: 'text' }), list))), undefined)
|
||||||
})
|
})
|
||||||
@@ -147,8 +147,16 @@ test('carries a mark the canonical spellings cannot nest', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a line whose start block parsing would claim', () => {
|
function readsBack(doc: AdfDocument): string {
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '```', type: 'text' })))), 'unspellable-line-start')
|
const spelled = markdown(adfToMarkdown(doc))
|
||||||
|
assert.deepEqual(markdownToAdf(spelled), { ok: true, value: doc }, `reading ${JSON.stringify(spelled)}`)
|
||||||
|
return spelled
|
||||||
|
}
|
||||||
|
|
||||||
|
test('spells a code span opening a line, whose backticks open no fence', () => {
|
||||||
|
const fence: AdfNode = { marks: [{ type: 'code' }], text: '``', type: 'text' }
|
||||||
|
assert.equal(readsBack(document(paragraph(fence))), '``` `` ```\n')
|
||||||
|
assert.equal(readsBack(document(paragraph({ text: 'a', type: 'text' }, { type: 'hardBreak' }, fence))), 'a\\\n``` `` ```\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('escapes the delimiter row a hard break leaves opening a pipe table with no leading pipe', () => {
|
test('escapes the delimiter row a hard break leaves opening a pipe table with no leading pipe', () => {
|
||||||
@@ -456,28 +464,26 @@ test('spells a list item whose marker completes a thematic break as a directive'
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses the characters CommonMark rewrites', () => {
|
test('spells the characters CommonMark rewrites', () => {
|
||||||
assert.equal(
|
assert.equal(readsBack(document(paragraph({ text: 'a\r\r\nb', type: 'text' }))), 'a !adf:text{text="\\n"}b\n')
|
||||||
markdown(adfToMarkdown(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' }))),
|
assert.equal(readsBack(document(paragraph({ marks: [{ type: 'strong' }], text: '\u0000\u0000a\u0000', type: 'text' }))), '**!adf:text{text="\\u0000\\u0000"}a!adf:text{text="\\u0000"}**\n')
|
||||||
'unspellable-character: a codeBlock holds no carriage return CommonMark keeps: this text holds one',
|
assert.equal(readsBack(document(paragraph({ marks: [{ attrs: { href: 'u' }, type: 'link' }], text: 'a\rb\u0000', type: 'text' }))), '[a b!adf:text{text="\\u0000"}](u)\n')
|
||||||
)
|
assert.equal(readsBack(document({ attrs: { level: 1 }, content: [{ text: '\r', type: 'text' }], type: 'heading' })), '# \n')
|
||||||
assert.equal(
|
assert.equal(readsBack(document({ content: [{ text: 'a\rb', type: 'text' }], type: 'codeBlock' })), '```adf:codeBlock\n{\n "content": [\n {\n "text": "a\\rb",\n "type": "text"\n }\n ]\n}\n```\n')
|
||||||
markdown(adfToMarkdown(document(paragraph({ text: 'a\rb', type: 'text' })))),
|
readsBack(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))
|
||||||
'unspellable-character: a text node holds a carriage return CommonMark rewrites',
|
readsBack(document(paragraph({ marks: [{ type: 'code' }], text: 'a\u0000b', type: 'text' })))
|
||||||
)
|
|
||||||
assert.equal(code(adfToMarkdown(document(paragraph({ text: 'a\u0000b', type: 'text' })))), 'unspellable-character')
|
|
||||||
assert.equal(code(adfToMarkdown(document({ content: [{ text: 'a\u0000b', type: 'text' }], type: 'codeBlock' }))), 'unspellable-character')
|
|
||||||
assert.equal(markdown(adfToMarkdown(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))), 'unsupported-node-shape: a text node holds text: this one has none')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a text node the spelling would empty out', () => {
|
test('carries a text node holding no text, or holding content', () => {
|
||||||
const nested: AdfNode[] = [{ text: 'lost', type: 'text' }]
|
const nested: AdfNode[] = [{ text: 'lost', type: 'text' }]
|
||||||
const empty = 'unsupported-node-shape: a text node holds text: this one has none'
|
assert.equal(readsBack(document(paragraph({ text: '', type: 'text' }))), '!adf:carry{json="{\\"text\\":\\"\\",\\"type\\":\\"text\\"}"}\n')
|
||||||
const holding = 'unsupported-node-shape: a text node holds no content: this one holds some'
|
assert.equal(readsBack(document(paragraph({ type: 'text' }, { text: 'a', type: 'text' }))), '!adf:carry{json="{\\"type\\":\\"text\\"}"}a\n')
|
||||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ text: '', type: 'text' })))), empty)
|
readsBack(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))
|
||||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }], text: '', type: 'text' })))), empty)
|
readsBack(document(paragraph({ marks: [{ type: 'em' }], text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: '', type: 'text' })))
|
||||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ content: nested, text: 'x', type: 'text' })))), holding)
|
readsBack(document(paragraph({ content: nested, text: 'x', type: 'text' })))
|
||||||
assert.equal(markdown(adfToMarkdown(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))), holding)
|
readsBack(document(paragraph({ content: nested, marks: [{ type: 'code' }], text: 'x', type: 'text' })))
|
||||||
|
readsBack(document({ content: [{ text: '', type: 'text' }], type: 'codeBlock' }))
|
||||||
|
readsBack(document({ content: [{ text: 'a', type: 'text' }, { type: 'text' }], type: 'codeBlock' }))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => {
|
test('carries a mark run whose edge holds whitespace CommonMark flanking counts', () => {
|
||||||
@@ -688,8 +694,12 @@ test('refuses the content and slot an inline directive has no room for', () => {
|
|||||||
assert.equal(refused({ text: 'x', type: 'status' }), neither('status', 'text'))
|
assert.equal(refused({ text: 'x', type: 'status' }), neither('status', 'text'))
|
||||||
assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), neither('hardBreak', 'content'))
|
assert.equal(refused({ content: [{ text: 'x', type: 'text' }], type: 'hardBreak' }), neither('hardBreak', 'content'))
|
||||||
assert.equal(refused({ text: 'x', type: 'hardBreak' }), neither('hardBreak', 'text'))
|
assert.equal(refused({ text: 'x', type: 'hardBreak' }), neither('hardBreak', 'text'))
|
||||||
assert.equal(refused({ attrs: { text: 'a\nb' }, type: 'status' }), 'unspellable-whitespace: the status content slot holds a newline no inline directive spans')
|
})
|
||||||
assert.equal(refused({ attrs: { text: 'a\u0000b' }, type: 'status' }), 'unspellable-character: a status content slot holds a null character CommonMark replaces')
|
|
||||||
|
test('carries an inline node whose content slot holds a line ending or a null character', () => {
|
||||||
|
assert.equal(readsBack(document(paragraph({ attrs: { text: 'a\nb' }, type: 'status' }))), '!adf:carry{json="{\\"attrs\\":{\\"text\\":\\"a\\\\nb\\"},\\"type\\":\\"status\\"}"}\n')
|
||||||
|
readsBack(document(paragraph({ attrs: { text: 'a\rb' }, type: 'emoji' })))
|
||||||
|
readsBack(document(paragraph({ attrs: { id: 'x', text: 'a\u0000b' }, type: 'mention' })))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('spells the directive marks around the longest run they cover', () => {
|
test('spells the directive marks around the longest run they cover', () => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||||
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
||||||
import type { Flavour } from '../plain-conventions.ts'
|
import type { Flavour } from '../plain/conventions.ts'
|
||||||
import { adfDocumentFault, carriesOnly, isPlainText, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
import { adfDocumentFault, holdsOnlyAttributes, isUnmarkedBareText, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||||
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain-conventions.ts'
|
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain/conventions.ts'
|
||||||
import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts'
|
import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts'
|
||||||
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
|
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
|
||||||
import { carriedBlock } from '../opaque-carry.ts'
|
import { carriedBlock } from '../opaque-carry.ts'
|
||||||
@@ -22,10 +22,10 @@ type BlockSpelling = 'commonmark' | 'directive' | 'list'
|
|||||||
type EmittedBlock = { headroom: number; spelling: BlockSpelling; text: string }
|
type EmittedBlock = { headroom: number; spelling: BlockSpelling; text: string }
|
||||||
type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
|
type KeptSpelling = { block: EmittedBlock | undefined; depth: number }
|
||||||
type PlacedBlock = Omit<EmittedBlock, 'headroom'> & { node: AdfNode }
|
type PlacedBlock = Omit<EmittedBlock, 'headroom'> & { node: AdfNode }
|
||||||
|
type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
|
||||||
// Keyed by reference: only a caller building one object per position (the parse, the plain reduction) passes one; a consumer's document may share a node.
|
// Keyed by reference: only a caller building one object per position (the parse, the plain reduction) passes one; a consumer's document may share a node.
|
||||||
export type SpellingMemo = Map<AdfNode, KeptSpelling>
|
export type SpellingMemo = Map<AdfNode, KeptSpelling>
|
||||||
type Walk = { blocks: readonly PlacedBlock[]; headroom: number }
|
type WalkedItem = { node: AdfNode; walk: PlacedBlocks }
|
||||||
type WalkedItem = { node: AdfNode; walk: Walk }
|
|
||||||
export type Writing = { flavour: Flavour; memo: SpellingMemo | undefined }
|
export type Writing = { flavour: Flavour; memo: SpellingMemo | undefined }
|
||||||
|
|
||||||
export const largestListMarker = 999999999
|
export const largestListMarker = 999999999
|
||||||
@@ -48,7 +48,7 @@ export function writeMarkdown(document: AdfDocument, flavour: Flavour): Result<s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// headroom: the least slack any depth guard below the walk has.
|
// headroom: the least slack any depth guard below the walk has.
|
||||||
function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, writing: Writing): Result<Walk> {
|
function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
|
||||||
let headroom = largestNesting - depth
|
let headroom = largestNesting - depth
|
||||||
if (headroom < 0) return tooDeep(path)
|
if (headroom < 0) return tooDeep(path)
|
||||||
const blocks: PlacedBlock[] = []
|
const blocks: PlacedBlock[] = []
|
||||||
@@ -76,15 +76,15 @@ function joinBlocks(blocks: readonly PlacedBlock[], container: BlockContainer):
|
|||||||
}
|
}
|
||||||
|
|
||||||
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string {
|
function separationBetween(previous: PlacedBlock, next: PlacedBlock, container: BlockContainer): string {
|
||||||
const plainPair = previous.spelling !== 'directive' && next.spelling !== 'directive'
|
const bothCommonMark = previous.spelling !== 'directive' && next.spelling !== 'directive'
|
||||||
if (plainPair && next.spelling === 'list') {
|
if (bothCommonMark && next.spelling === 'list') {
|
||||||
if (previous.spelling === 'list' && previous.node.type === next.node.type) {
|
if (previous.spelling === 'list' && previous.node.type === next.node.type) {
|
||||||
const gap = container === 'directive' ? '\n' : '\n\n'
|
const gap = container === 'directive' ? '\n' : '\n\n'
|
||||||
return `${gap}${listBreakSpelling}${gap}`
|
return `${gap}${listBreakSpelling}${gap}`
|
||||||
}
|
}
|
||||||
if (container === 'list-item') return interruptsParagraph(next.node) ? '\n' : '\n\n'
|
if (container === 'list-item') return interruptsParagraph(next.node) ? '\n' : '\n\n'
|
||||||
}
|
}
|
||||||
return container === 'directive' && !plainPair ? '\n' : '\n\n'
|
return container === 'directive' && !bothCommonMark ? '\n' : '\n\n'
|
||||||
}
|
}
|
||||||
|
|
||||||
function interruptsParagraph(node: AdfNode): boolean {
|
function interruptsParagraph(node: AdfNode): boolean {
|
||||||
@@ -128,9 +128,9 @@ function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number
|
|||||||
if (plain !== undefined) return plain
|
if (plain !== undefined) return plain
|
||||||
if (node.type === 'blockquote') return tryBlockquote(node, path, depth, writing)
|
if (node.type === 'blockquote') return tryBlockquote(node, path, depth, writing)
|
||||||
if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, writing)
|
if (node.type === 'bulletList' || node.type === 'orderedList') return tryList(node, path, depth, writing)
|
||||||
if (node.type === 'codeBlock') return tryCodeBlock(node, path)
|
if (node.type === 'codeBlock') return readableText(tryCodeBlock(node))
|
||||||
if (node.type === 'heading') return tryHeading(node, path, writing.flavour)
|
if (node.type === 'heading') return tryHeading(node, path, writing.flavour)
|
||||||
if (node.type === 'mediaSingle') return readableText(tryImage(node, path))
|
if (node.type === 'mediaSingle') return readableText(tryImage(node))
|
||||||
if (node.type === 'paragraph') return tryParagraph(node, path, writing.flavour)
|
if (node.type === 'paragraph') return tryParagraph(node, path, writing.flavour)
|
||||||
if (node.type === 'rule') return readableText(tryRule(node))
|
if (node.type === 'rule') return readableText(tryRule(node))
|
||||||
if (node.type === 'table') return readableText(tryPipeTable(node, path, writing.flavour))
|
if (node.type === 'table') return readableText(tryPipeTable(node, path, writing.flavour))
|
||||||
@@ -181,13 +181,13 @@ function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writi
|
|||||||
return lines.includes(undefined) ? failure('unsupported-node-shape', 'a task holds blocks no list item spells', path) : success({ headroom, spelling: 'list', text: lines.join('\n') })
|
return lines.includes(undefined) ? failure('unsupported-node-shape', 'a task holds blocks no list item spells', path) : success({ headroom, spelling: 'list', text: lines.join('\n') })
|
||||||
}
|
}
|
||||||
|
|
||||||
function placedBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<Walk> {
|
function placedBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
|
||||||
const block = emitBlock(node, path, depth, writing)
|
const block = emitBlock(node, path, depth, writing)
|
||||||
return block.ok ? success({ blocks: [{ ...block.value, node }], headroom: block.value.headroom }) : block
|
return block.ok ? success({ blocks: [{ ...block.value, node }], headroom: block.value.headroom }) : block
|
||||||
}
|
}
|
||||||
|
|
||||||
// The marker leads the first paragraph, or stands as one where the blocks open with another.
|
// The marker leads the first paragraph, or stands as one where the blocks open with another.
|
||||||
function taskBlocks(task: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<Walk> {
|
function taskBlocks(task: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<PlacedBlocks> {
|
||||||
const marker = taskMarker(nodeAttrs(task)['state'])
|
const marker = taskMarker(nodeAttrs(task)['state'])
|
||||||
const markerBlock: PlacedBlock = { node: { type: 'paragraph' }, spelling: 'commonmark', text: marker }
|
const markerBlock: PlacedBlock = { node: { type: 'paragraph' }, spelling: 'commonmark', text: marker }
|
||||||
if (task.type === 'taskItem') {
|
if (task.type === 'taskItem') {
|
||||||
@@ -220,7 +220,7 @@ function directivePair(node: AdfNode, opener: string, body: string, headroom: nu
|
|||||||
return { headroom, spelling: 'directive', text: `${opener}\n${body === '' ? '' : `${body}\n`}${spellDirectiveCloser(node.type)}` }
|
return { headroom, spelling: 'directive', text: `${opener}\n${body === '' ? '' : `${body}\n`}${spellDirectiveCloser(node.type)}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number, walkBody: () => Result<Walk>): Result<EmittedBlock> {
|
function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number, walkBody: () => Result<PlacedBlocks>): Result<EmittedBlock> {
|
||||||
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
|
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
|
||||||
if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
|
if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
|
||||||
if (model.contentModel === 'code') return emitCodeDirective(node, model, path, depth)
|
if (model.contentModel === 'code') return emitCodeDirective(node, model, path, depth)
|
||||||
@@ -230,7 +230,7 @@ function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertE
|
|||||||
return emitDirectiveBody(node, model, opener.value, path, walkBody)
|
return emitDirectiveBody(node, model, opener.value, path, walkBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string, path: ConvertErrorPath, walkBody: () => Result<Walk>): Result<EmittedBlock> {
|
function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string, path: ConvertErrorPath, walkBody: () => Result<PlacedBlocks>): Result<EmittedBlock> {
|
||||||
if (blockDirectiveForm(node.type) === 'leaf') return success({ headroom: Number.POSITIVE_INFINITY, spelling: 'directive', text: opener })
|
if (blockDirectiveForm(node.type) === 'leaf') return success({ headroom: Number.POSITIVE_INFINITY, spelling: 'directive', text: opener })
|
||||||
if (model.contentModel === 'inline') {
|
if (model.contentModel === 'inline') {
|
||||||
const line = emitInlineLine(nodeContent(node), 'paragraph', path, 'lossless')
|
const line = emitInlineLine(nodeContent(node), 'paragraph', path, 'lossless')
|
||||||
@@ -243,7 +243,7 @@ function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||||
if (!carriesOnly(node, [])) return undefined
|
if (!holdsOnlyAttributes(node, [])) return undefined
|
||||||
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
|
const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
const text = joinBlocks(inner.value.blocks, 'document')
|
const text = joinBlocks(inner.value.blocks, 'document')
|
||||||
@@ -251,47 +251,40 @@ function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, wri
|
|||||||
return success(commonMarkText(quoted(alert ? `\\${text}` : text), inner.value.headroom))
|
return success(commonMarkText(quoted(alert ? `\\${text}` : text), inner.value.headroom))
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
function tryCodeBlock(node: AdfNode): string | undefined {
|
||||||
if (!carriesOnly(node, ['language'])) return undefined
|
if (!holdsOnlyAttributes(node, ['language'])) return undefined
|
||||||
const slot = languageSlot(nodeAttrs(node)['language'])
|
const slot = languageSlot(nodeAttrs(node)['language'])
|
||||||
if (slot.kind === 'attribute') return undefined
|
if (slot.kind === 'attribute') return undefined
|
||||||
const texts = fencedTexts(node, path)
|
const [only, ...others] = fencedTexts(node) ?? []
|
||||||
if (!texts.ok) return texts
|
|
||||||
const [only, ...others] = texts.value ?? []
|
|
||||||
if (only === undefined || others.length > 0) return undefined
|
if (only === undefined || others.length > 0) return undefined
|
||||||
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', only)))
|
return fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', only)
|
||||||
}
|
}
|
||||||
|
|
||||||
// spec/flavour.md, The CommonMark blocks: one fence per text node, the language on each; with no fence to carry it, the attribute does.
|
// spec/flavour.md, The CommonMark blocks: one fence per text node, the language on each; with no fence to carry it, the attribute does.
|
||||||
function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
|
||||||
const texts = fencedTexts(node, path)
|
const texts = fencedTexts(node)
|
||||||
if (!texts.ok) return texts
|
if (texts === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||||
if (texts.value === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
const slot: LanguageSlot = texts.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language'])
|
||||||
const slot: LanguageSlot = texts.value.length === 0 ? { kind: 'attribute' } : languageSlot(nodeAttrs(node)['language'])
|
|
||||||
const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
|
const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
|
||||||
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
|
||||||
if (!opener.ok) return opener
|
if (!opener.ok) return opener
|
||||||
const info = slot.kind === 'fence' ? slot.info : ''
|
const info = slot.kind === 'fence' ? slot.info : ''
|
||||||
return success(directivePair(node, opener.value, texts.value.map((text) => fencedCodeBlock(info, text)).join('\n')))
|
return success(directivePair(node, opener.value, texts.map((text) => fencedCodeBlock(info, text)).join('\n')))
|
||||||
}
|
}
|
||||||
|
|
||||||
// The text each fence holds, or `undefined` where a child is no plain text node, which the carry holds instead.
|
// The text of each fence, or `undefined` where a child holds what no fence keeps, so the block carry takes the node.
|
||||||
function fencedTexts(node: AdfNode, path: ConvertErrorPath): Result<string[] | undefined> {
|
function fencedTexts(node: AdfNode): string[] | undefined {
|
||||||
if (node.content === undefined) return success([''])
|
if (node.content === undefined) return ['']
|
||||||
if (node.content.some((child) => !isPlainText(child) || child.marks !== undefined)) return success(undefined)
|
|
||||||
const texts: string[] = []
|
const texts: string[] = []
|
||||||
for (const [index, child] of node.content.entries()) {
|
for (const child of node.content) {
|
||||||
const childPath = [...path, 'content', index]
|
if (!isUnmarkedBareText(child) || typeof child.text !== 'string' || child.text === '' || /\r/.test(child.text) || holdsNullCharacter(child.text)) return undefined
|
||||||
if (typeof child.text !== 'string' || child.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', childPath)
|
|
||||||
if (/\r/.test(child.text)) return failure('unspellable-character', 'a codeBlock holds no carriage return CommonMark keeps: this text holds one', childPath)
|
|
||||||
if (holdsNullCharacter(child.text)) return failure('unspellable-character', 'a codeBlock holds a null character CommonMark replaces', childPath)
|
|
||||||
texts.push(child.text)
|
texts.push(child.text)
|
||||||
}
|
}
|
||||||
return success(texts)
|
return texts
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
|
function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
|
||||||
if (!carriesOnly(node, ['level'])) return undefined
|
if (!holdsOnlyAttributes(node, ['level'])) return undefined
|
||||||
const level = nodeAttrs(node)['level']
|
const level = nodeAttrs(node)['level']
|
||||||
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
|
if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) return undefined
|
||||||
const hashes = '#'.repeat(level)
|
const hashes = '#'.repeat(level)
|
||||||
@@ -304,11 +297,11 @@ function tryHeading(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Re
|
|||||||
|
|
||||||
function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||||
const ordered = node.type === 'orderedList'
|
const ordered = node.type === 'orderedList'
|
||||||
if (!carriesOnly(node, ordered ? ['order'] : [])) return undefined
|
if (!holdsOnlyAttributes(node, ordered ? ['order'] : [])) return undefined
|
||||||
const items = nodeContent(node)
|
const items = nodeContent(node)
|
||||||
const start = listStart(node, items.length)
|
const start = listStart(node, items.length)
|
||||||
if (start === undefined || items.length === 0) return undefined
|
if (start === undefined || items.length === 0) return undefined
|
||||||
if (items.some((item) => item.type !== 'listItem' || !carriesOnly(item, []))) return undefined
|
if (items.some((item) => item.type !== 'listItem' || !holdsOnlyAttributes(item, []))) return undefined
|
||||||
const walked: WalkedItem[] = []
|
const walked: WalkedItem[] = []
|
||||||
let headroom = Number.POSITIVE_INFINITY
|
let headroom = Number.POSITIVE_INFINITY
|
||||||
for (const [offset, item] of items.entries()) {
|
for (const [offset, item] of items.entries()) {
|
||||||
@@ -356,13 +349,13 @@ function tryListItemLines(inner: string, marker: string): string | undefined {
|
|||||||
|
|
||||||
function tryParagraph(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
|
function tryParagraph(node: AdfNode, path: ConvertErrorPath, flavour: Flavour): Result<EmittedBlock> | undefined {
|
||||||
const content = nodeContent(node)
|
const content = nodeContent(node)
|
||||||
if (content.length === 0 || !carriesOnly(node, [])) return undefined
|
if (content.length === 0 || !holdsOnlyAttributes(node, [])) return undefined
|
||||||
const line = emitInlineLine(content, 'paragraph', path, flavour)
|
const line = emitInlineLine(content, 'paragraph', path, flavour)
|
||||||
if (!line.ok) return line
|
if (!line.ok) return line
|
||||||
return success(commonMarkText(line.value))
|
return success(commonMarkText(line.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryRule(node: AdfNode): string | undefined {
|
function tryRule(node: AdfNode): string | undefined {
|
||||||
if (!carriesOnly(node, []) || nodeContent(node).length > 0) return undefined
|
if (!holdsOnlyAttributes(node, []) || nodeContent(node).length > 0) return undefined
|
||||||
return '---'
|
return '---'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
import type { AdfNode } from '../../adf/document.ts'
|
import type { AdfNode } from '../../adf/document.ts'
|
||||||
import type { ConvertErrorPath } from '../../result.ts'
|
import { holdsOnlyAttributes, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||||
import { carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
|
||||||
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||||
import { tryImageLine } from './inline-line.ts'
|
import { tryImageLine } from './inline-line.ts'
|
||||||
|
|
||||||
const centeredMediaSingle = '{"layout":"center"}'
|
const centeredMediaSingle = '{"layout":"center"}'
|
||||||
const imageAttributes = ['alt', 'type', 'url']
|
const imageAttributes = ['alt', 'type', 'url']
|
||||||
|
|
||||||
export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefined {
|
export function tryImage(node: AdfNode): string | undefined {
|
||||||
const image = imageShape(node)
|
const image = imageShape(node)
|
||||||
if (image === undefined) return undefined
|
if (image === undefined) return undefined
|
||||||
return tryImageLine(image.alt, image.url, [...path, 'content', 0])
|
return tryImageLine(image.alt, image.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
|
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
|
||||||
const content = nodeContent(node)
|
const content = nodeContent(node)
|
||||||
const media = content[0]
|
const media = content[0]
|
||||||
if (!carriesOnly(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined
|
if (!holdsOnlyAttributes(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined
|
||||||
if (media === undefined || content.length !== 1 || media.type !== 'media' || !carriesOnly(media, imageAttributes) || nodeContent(media).length > 0) return undefined
|
if (media === undefined || content.length !== 1 || media.type !== 'media' || !holdsOnlyAttributes(media, imageAttributes) || nodeContent(media).length > 0) return undefined
|
||||||
const attrs = nodeAttrs(media)
|
const attrs = nodeAttrs(media)
|
||||||
const alt = attrs['alt']
|
const alt = attrs['alt']
|
||||||
const url = attrs['url']
|
const url = attrs['url']
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
import type { Flavour } from '../plain-conventions.ts'
|
import type { Flavour } from '../plain/conventions.ts'
|
||||||
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
|
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||||
import type { LineContainer } from '../line-container.ts'
|
import type { LineContainer } from '../line-container.ts'
|
||||||
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts'
|
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts'
|
||||||
import { carriedInline } from '../opaque-carry.ts'
|
import { carriedInline } from '../opaque-carry.ts'
|
||||||
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||||
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
||||||
import { identicalMark, isPlainText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
|
||||||
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
|
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
|
||||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
import { identicalMark, isBareText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||||
import { highlightDelimiter } from '../plain-conventions.ts'
|
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
|
import { highlightDelimiter } from '../plain/conventions.ts'
|
||||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||||
|
import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
|
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||||
import { readsAsOne, textBreakSpelling } from '../text-break.ts'
|
import { slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
|
||||||
import { slotLineEndingFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
|
|
||||||
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
|
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
|
||||||
import { spellTextDirective } from '../text-directive.ts'
|
import { spellTextDirective } from '../text-directive.ts'
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ type LineAttempt = { fallback: NodeRange | 'opening-link'; line?: undefined } |
|
|||||||
|
|
||||||
type LineFallbacks = { carried: Set<number>; flavour: Flavour; openingLinkAsDirective: boolean }
|
type LineFallbacks = { carried: Set<number>; flavour: Flavour; openingLinkAsDirective: boolean }
|
||||||
|
|
||||||
export type PlainLineFallback = { kind: 'claimed-line'; line: number; text: string } | { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
|
export type PlainLineFallback = { kind: 'opening-link' } | { kind: 'unspellable-run'; runs: [MarkRun, ...MarkRun[]] }
|
||||||
|
|
||||||
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<string> {
|
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<string> {
|
||||||
const emitted = emitLine(nodes, container, path, flavour)
|
const emitted = emitLine(nodes, container, path, flavour)
|
||||||
@@ -67,13 +67,12 @@ export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, f
|
|||||||
return emitted.value.line
|
return emitted.value.line
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tryImageLine(alt: string | undefined, href: string, path: ConvertErrorPath): string | undefined {
|
export function tryImageLine(alt: string | undefined, href: string): string | undefined {
|
||||||
if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined
|
if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined
|
||||||
const destination = spellDestination(href)
|
const destination = spellDestination(href)
|
||||||
if (destination === undefined) return undefined
|
if (destination === undefined) return undefined
|
||||||
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
|
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
|
||||||
const attempt = attemptLine([syntax('`)], 'paragraph', path, 'lossless')
|
return attemptLine([syntax('`)], 'paragraph', 'lossless').line
|
||||||
return attempt.ok ? attempt.value.line : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<EmittedLine> {
|
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<EmittedLine> {
|
||||||
@@ -87,12 +86,11 @@ function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: Con
|
|||||||
if (!taken.ok) return taken
|
if (!taken.ok) return taken
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const attempt = attemptLine(emission.value.segments, container, path, flavour)
|
const attempt = attemptLine(emission.value.segments, container, flavour)
|
||||||
if (!attempt.ok) return attempt
|
if (attempt.line !== undefined) {
|
||||||
if (attempt.value.line !== undefined) {
|
return success({ line: attempt.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
|
||||||
return success({ line: attempt.value.line, openingLinkAsDirective: fallbacks.openingLinkAsDirective, segments: emission.value.segments })
|
|
||||||
}
|
}
|
||||||
const taken = takeFallback(fallbacks, attempt.value.fallback, path)
|
const taken = takeFallback(fallbacks, attempt.fallback, path)
|
||||||
if (!taken.ok) return taken
|
if (!taken.ok) return taken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,15 +112,14 @@ function lineSegments(nodes: readonly AdfNode[], container: LineContainer, path:
|
|||||||
const emission = emitRun(nodes, 0, 0, context)
|
const emission = emitRun(nodes, 0, 0, context)
|
||||||
if (!emission.ok) return emission
|
if (!emission.ok) return emission
|
||||||
if (emission.value.carry !== undefined) return emission
|
if (emission.value.carry !== undefined) return emission
|
||||||
return success({ segments: carryStrippedWhitespace(emission.value.segments) })
|
return success({ segments: spellEdgeWhitespace(emission.value.segments) })
|
||||||
}
|
}
|
||||||
|
|
||||||
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, path: ConvertErrorPath, flavour: Flavour): Result<LineAttempt> {
|
function attemptLine(segments: readonly InlineSegment[], container: LineContainer, flavour: Flavour): LineAttempt {
|
||||||
const verdict = lineVerdict(segments, container, flavour)
|
const verdict = lineVerdict(segments, container, flavour)
|
||||||
if (verdict.kind === 'opening-link') return success({ fallback: 'opening-link' })
|
if (verdict.kind === 'opening-link') return { fallback: 'opening-link' }
|
||||||
if (verdict.kind === 'unspellable-run') return success({ fallback: verdict.runs[0] })
|
if (verdict.kind === 'unspellable-run') return { fallback: verdict.runs[0] }
|
||||||
if (verdict.kind === 'claimed-line') return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(verdict.text)}`, path)
|
return { line: verdict.text }
|
||||||
return success({ line: verdict.text })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The fallbacks in the order a line takes them, or the line where it takes none.
|
// The fallbacks in the order a line takes them, or the line where it takes none.
|
||||||
@@ -130,39 +127,36 @@ function lineVerdict(segments: readonly InlineSegment[], container: LineContaine
|
|||||||
const assembled = assembleInlineLine(segments, container, flavour)
|
const assembled = assembleInlineLine(segments, container, flavour)
|
||||||
if (assembled.openingLinkAsDirective) return { kind: 'opening-link' }
|
if (assembled.openingLinkAsDirective) return { kind: 'opening-link' }
|
||||||
const [run, ...others] = assembled.unspellableRuns
|
const [run, ...others] = assembled.unspellableRuns
|
||||||
if (run !== undefined) return { kind: 'unspellable-run', runs: [run, ...others] }
|
return run === undefined ? { kind: 'line', text: assembled.line } : { kind: 'unspellable-run', runs: [run, ...others] }
|
||||||
const lines = assembled.line.split('\n')
|
|
||||||
const claimed = container === 'paragraph' ? lines.findIndex((single, index) => claimsLine(single, index === 0 ? 'first' : 'later')) : -1
|
|
||||||
return claimed === -1 ? { kind: 'line', text: assembled.line } : { kind: 'claimed-line', line: claimed, text: lines[claimed] ?? '' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// spec/flavour.md, Inline nodes.
|
// spec/flavour.md, Inline nodes.
|
||||||
function carryStrippedWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
|
function spellEdgeWhitespace(segments: readonly InlineSegment[]): InlineSegment[] {
|
||||||
const carried: InlineSegment[] = []
|
const spelled: InlineSegment[] = []
|
||||||
for (const [index, segment] of segments.entries()) {
|
for (const [index, segment] of segments.entries()) {
|
||||||
const previous = segments[index - 1]
|
const previous = segments[index - 1]
|
||||||
const next = segments[index + 1]
|
const next = segments[index + 1]
|
||||||
const leading = previous === undefined || previous.text.includes('\n')
|
const leading = previous === undefined || previous.text.includes('\n')
|
||||||
const trailing = next === undefined || next.text.includes('\n')
|
const trailing = next === undefined || next.text.includes('\n')
|
||||||
carried.push(...carryEdges(segment, leading, trailing))
|
spelled.push(...edgeWhitespaceSegments(segment, leading, trailing))
|
||||||
}
|
}
|
||||||
return carried
|
return spelled
|
||||||
}
|
}
|
||||||
|
|
||||||
function carryEdges(segment: InlineSegment, leading: boolean, trailing: boolean): InlineSegment[] {
|
function edgeWhitespaceSegments(segment: InlineSegment, leading: boolean, trailing: boolean): InlineSegment[] {
|
||||||
if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment]
|
if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment]
|
||||||
const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : ''
|
const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : ''
|
||||||
const body = segment.text.slice(head.length)
|
const body = segment.text.slice(head.length)
|
||||||
const middle = trailing ? trimTrailingSpace(body) : body
|
const middle = trailing ? trimTrailingSpace(body) : body
|
||||||
const tail = body.slice(middle.length)
|
const tail = body.slice(middle.length)
|
||||||
const edges: InlineSegment[] = []
|
const edges: InlineSegment[] = []
|
||||||
if (head !== '') edges.push(carriedText(head))
|
if (head !== '') edges.push(textDirectiveSegment(head))
|
||||||
if (middle !== '') edges.push({ escaping: segment.escaping, text: middle })
|
if (middle !== '') edges.push({ escaping: segment.escaping, text: middle })
|
||||||
if (tail !== '') edges.push(carriedText(tail))
|
if (tail !== '') edges.push(textDirectiveSegment(tail))
|
||||||
return edges
|
return edges
|
||||||
}
|
}
|
||||||
|
|
||||||
function carriedText(text: string): InlineSegment {
|
function textDirectiveSegment(text: string): InlineSegment {
|
||||||
return syntax(spellTextDirective(text))
|
return syntax(spellTextDirective(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +180,7 @@ function emitRun(nodes: readonly AdfNode[], depth: number, firstIndex: number, c
|
|||||||
const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
|
const runs = inlineRuns(nodes, depth, firstIndex, context.carried)
|
||||||
const segments: InlineSegment[] = []
|
const segments: InlineSegment[] = []
|
||||||
for (const [offset, run] of runs.entries()) {
|
for (const [offset, run] of runs.entries()) {
|
||||||
if (partsText(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling))
|
if (takesTextBreak(runs[offset - 1], run, context.carried)) segments.push(syntax(textBreakSpelling))
|
||||||
const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 }
|
const runContext = { ...context, atBlockEnd: context.atBlockEnd && offset === runs.length - 1 }
|
||||||
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
|
const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext, run.index) : emitMarkedRun(run.nodes, run.mark, depth, run.index, runContext)
|
||||||
if (!emitted.ok) return emitted
|
if (!emitted.ok) return emitted
|
||||||
@@ -213,19 +207,24 @@ function inlineRuns(nodes: readonly AdfNode[], depth: number, firstIndex: number
|
|||||||
return runs
|
return runs
|
||||||
}
|
}
|
||||||
|
|
||||||
function partsText(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean {
|
function takesTextBreak(previous: InlineRun | undefined, run: InlineRun, carried: ReadonlySet<number>): boolean {
|
||||||
if (previous?.kind !== 'plain' || run.kind !== 'plain') return false
|
if (previous?.kind !== 'plain' || run.kind !== 'plain') return false
|
||||||
if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false
|
if (carries(previous.node, carried, previous.index) || carries(run.node, carried, run.index)) return false
|
||||||
return readsAsOne(previous.node, run.node)
|
return joinsWhenRead(previous.node, run.node)
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodePath(context: InlineContext, index: number): ConvertErrorPath {
|
function nodePath(context: InlineContext, index: number): ConvertErrorPath {
|
||||||
return [...context.path, 'content', index]
|
return [...context.path, 'content', index]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decides the text-node and slot-fault carries up front, so a line holding many of them is emitted once.
|
||||||
function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
|
function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
|
||||||
if (carried.has(index)) return true
|
if (carried.has(index)) return true
|
||||||
return node.type !== 'text' && inlineNodeModel(node.type) === undefined
|
if (node.type === 'text') return !isBareText(node) || typeof node.text !== 'string' || node.text === ''
|
||||||
|
const model = inlineNodeModel(node.type)
|
||||||
|
if (model === undefined) return true
|
||||||
|
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
|
||||||
|
return typeof slot === 'string' && slotFault(node.type, slot) !== undefined && nodeContent(node).length === 0 && node.text === undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> {
|
function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<Emission> {
|
||||||
@@ -238,7 +237,7 @@ function emitLeaf(node: AdfNode, context: InlineContext, index: number): Result<
|
|||||||
const types = nodeMarks(node).map((mark) => mark.type)
|
const types = nodeMarks(node).map((mark) => mark.type)
|
||||||
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
|
if (new Set(types).size !== types.length) return failure('unsupported-node-shape', `a ${node.type} node carries one mark type twice`, path)
|
||||||
const model = inlineNodeModel(node.type)
|
const model = inlineNodeModel(node.type)
|
||||||
if (model === undefined) return emitText(node, context, index, path)
|
if (model === undefined) return success(emitText(node, context, index))
|
||||||
if (node.type === 'hardBreak') return emitHardBreak(node, model, context, index, path)
|
if (node.type === 'hardBreak') return emitHardBreak(node, model, context, index, path)
|
||||||
return emitInlineDirective(node, model, index, path)
|
return emitInlineDirective(node, model, index, path)
|
||||||
}
|
}
|
||||||
@@ -259,34 +258,27 @@ function emitInlineDirective(node: AdfNode, model: InlineNodeModel, index: numbe
|
|||||||
if (attributes === undefined) return success({ carry: { first: index, last: index } })
|
if (attributes === undefined) return success({ carry: { first: index, last: index } })
|
||||||
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
|
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
|
||||||
if (slot === undefined) return success({ segments: [syntax(spellInlineLeafDirective(node.type, attributes))] })
|
if (slot === undefined) return success({ segments: [syntax(spellInlineLeafDirective(node.type, attributes))] })
|
||||||
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } })
|
if (typeof slot !== 'string' || slotFault(node.type, slot) !== undefined) return success({ carry: { first: index, last: index } })
|
||||||
const spans = slotLineEndingFault(node.type, slot)
|
|
||||||
if (spans !== undefined) return faulted(spans, path)
|
|
||||||
if (holdsNullCharacter(slot)) return failure('unspellable-character', `a ${node.type} content slot holds a null character CommonMark replaces`, path)
|
|
||||||
const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }]
|
const content: InlineSegment[] = slot === '' ? [] : [{ escaping: 'bracketed', text: slot }]
|
||||||
return success({ segments: [syntax(spellInlineDirectiveOpener(node.type)), ...content, syntax(`]${attributes}`)] })
|
return success({ segments: [syntax(spellInlineDirectiveOpener(node.type)), ...content, syntax(`]${attributes}`)] })
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
|
// spec/flavour.md, Text CommonMark cannot hold plainly.
|
||||||
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
|
function emitText(node: AdfNode, context: InlineContext, index: number): Emission {
|
||||||
if (!isPlainText(node)) return success({ carry: { first: index, last: index } })
|
if (!isBareText(node) || typeof node.text !== 'string' || node.text === '') return { carry: { first: index, last: index } }
|
||||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
|
||||||
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
|
|
||||||
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
|
||||||
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
|
const escaping: InlineEscaping = context.bracketed ? 'bracketed' : 'backslash'
|
||||||
const parts = node.text.split(/(\n+)/).filter((part) => part !== '')
|
const parts = node.text.split(/(\n+|\0+|\r)/).filter((part) => part !== '')
|
||||||
return success({ segments: parts.map((part) => (part.startsWith('\n') ? carriedText(part) : { escaping, text: part })) })
|
return { segments: parts.map((part) => (part === '\r' ? syntax(' ') : /^[\n\0]/.test(part) ? textDirectiveSegment(part) : { escaping, text: part })) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
|
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<Emission> {
|
||||||
const path = nodePath(context, index)
|
|
||||||
const range: NodeRange = { first: index, last: index + nodes.length - 1 }
|
const range: NodeRange = { first: index, last: index + nodes.length - 1 }
|
||||||
if (mark.type === 'backgroundColor' && context.flavour === 'plain') return emitHighlight(nodes, depth, range, context)
|
if (mark.type === 'backgroundColor' && context.flavour === 'plain') return emitHighlight(nodes, depth, range, context)
|
||||||
const spelling = markSpelling(mark.type)
|
const spelling = markSpelling(mark.type)
|
||||||
if (spelling === undefined) return success({ carry: range })
|
if (spelling === undefined) return success({ carry: range })
|
||||||
const attributes = spellMarkAttributes(mark, spelling)
|
const attributes = spellMarkAttributes(mark, spelling)
|
||||||
if (attributes === undefined) return success({ carry: range })
|
if (attributes === undefined) return success({ carry: range })
|
||||||
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, range, path)
|
if (spelling.kind === 'code') return success(emitCodeSpan(nodes, depth, range))
|
||||||
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
|
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, spelling.spelling, depth, range, context)
|
||||||
const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined
|
const link = spelling.kind === 'link' ? tryLink(nodes, mark, depth, range, context) : undefined
|
||||||
if (link !== undefined) return link
|
if (link !== undefined) return link
|
||||||
@@ -300,11 +292,11 @@ function emitEmphasis(nodes: readonly AdfNode[], spelling: string, depth: number
|
|||||||
const inner = emitRun(nodes, depth + 1, range.first, context)
|
const inner = emitRun(nodes, depth + 1, range.first, context)
|
||||||
if (!inner.ok) return inner
|
if (!inner.ok) return inner
|
||||||
if (inner.value.carry !== undefined) return inner
|
if (inner.value.carry !== undefined) return inner
|
||||||
const carried = carryStrippedWhitespace(inner.value.segments)
|
const spelled = spellEdgeWhitespace(inner.value.segments)
|
||||||
return success({
|
return success({
|
||||||
segments: [
|
segments: [
|
||||||
{ emphasis: 'open', escaping: 'none', nodes: { ...range, depth }, text: spelling },
|
{ emphasis: 'open', escaping: 'none', nodes: { ...range, depth }, text: spelling },
|
||||||
...carried,
|
...spelled,
|
||||||
{ emphasis: 'close', escaping: 'none', nodes: { ...range, depth }, text: spelling },
|
{ emphasis: 'close', escaping: 'none', nodes: { ...range, depth }, text: spelling },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -320,19 +312,15 @@ function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRang
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Each node its own span: CommonMark reads two adjacent text nodes in one span back as one.
|
// Each node its own span: CommonMark reads two adjacent text nodes in one span back as one.
|
||||||
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
|
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange): Emission {
|
||||||
const spans: string[] = []
|
const spans: string[] = []
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.type === 'text' && nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
|
|
||||||
if (!isPlainText(node) || nodeMarks(node).length !== depth + 1) return success({ carry: range })
|
|
||||||
const { text } = node
|
const { text } = node
|
||||||
if (typeof text !== 'string' || text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
if (!isBareText(node) || nodeMarks(node).length !== depth + 1 || typeof text !== 'string' || text === '' || /[\n\r]/.test(text) || holdsNullCharacter(text)) return { carry: range }
|
||||||
if (/[\n\r]/.test(text)) return success({ carry: range })
|
|
||||||
if (holdsNullCharacter(text)) return failure('unspellable-character', 'a code span holds a null character CommonMark replaces', path)
|
|
||||||
const fence = '`'.repeat(longestBacktickRun(text) + 1)
|
const fence = '`'.repeat(longestBacktickRun(text) + 1)
|
||||||
spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`)
|
spans.push(`${fence}${needsPadding(text) ? ` ${text} ` : text}${fence}`)
|
||||||
}
|
}
|
||||||
return success({ segments: [syntax(spans.join(textBreakSpelling))] })
|
return { segments: [syntax(spans.join(textBreakSpelling))] }
|
||||||
}
|
}
|
||||||
|
|
||||||
function needsPadding(text: string): boolean {
|
function needsPadding(text: string): boolean {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { Flavour } from '../plain-conventions.ts'
|
import type { Flavour } from '../plain/conventions.ts'
|
||||||
import type { LineContainer } from '../line-container.ts'
|
import type { LineContainer } from '../line-container.ts'
|
||||||
import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark/grammar.ts'
|
import { backslashEscape, escapesLineClaim, inlineHtmlConstruct, opensBracketedAutolink, opensEmailAutolink, type LinePosition } from '../commonmark/grammar.ts'
|
||||||
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
|
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||||
import { claimsDirectivePrefix } from '../directive-syntax.ts'
|
import { claimsDirectivePrefix } from '../directive-syntax.ts'
|
||||||
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
|
import { delimiterFlags, isWordCharacter, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
|
||||||
import { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts'
|
import { highlightDelimiter, highlightFlanking } from '../plain/conventions.ts'
|
||||||
import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
|
import { isBareDelimiterRow } from '../pipe-table-syntax.ts'
|
||||||
import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
|
import { opensLinkDefinition } from '../commonmark/link-reference-definitions.ts'
|
||||||
import { readEntityReference } from '../commonmark/entity-references.ts'
|
import { readEntityReference } from '../commonmark/entity-references.ts'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AdfNode } from '../../adf/document.ts'
|
import type { AdfNode } from '../../adf/document.ts'
|
||||||
import type { ConvertErrorPath } from '../../result.ts'
|
import type { ConvertErrorPath } from '../../result.ts'
|
||||||
import type { Flavour } from '../plain-conventions.ts'
|
import type { Flavour } from '../plain/conventions.ts'
|
||||||
import { carriesOnly, nodeContent } from '../../adf/document.ts'
|
import { holdsOnlyAttributes, nodeContent } from '../../adf/document.ts'
|
||||||
import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
|
import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
|
||||||
import { tryPipeCell } from './inline-line.ts'
|
import { tryPipeCell } from './inline-line.ts'
|
||||||
|
|
||||||
@@ -26,16 +26,16 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath, flavour: Fla
|
|||||||
function pipeRows(node: AdfNode): AdfNode[][] | undefined {
|
function pipeRows(node: AdfNode): AdfNode[][] | undefined {
|
||||||
const rows = nodeContent(node)
|
const rows = nodeContent(node)
|
||||||
const columns = rows[0] === undefined ? 0 : nodeContent(rows[0]).length
|
const columns = rows[0] === undefined ? 0 : nodeContent(rows[0]).length
|
||||||
if (!carriesOnly(node, []) || columns === 0) return undefined
|
if (!holdsOnlyAttributes(node, []) || columns === 0) return undefined
|
||||||
const grid: AdfNode[][] = []
|
const grid: AdfNode[][] = []
|
||||||
for (const [index, row] of rows.entries()) {
|
for (const [index, row] of rows.entries()) {
|
||||||
const cells = nodeContent(row)
|
const cells = nodeContent(row)
|
||||||
if (row.type !== 'tableRow' || !carriesOnly(row, []) || cells.length !== columns) return undefined
|
if (row.type !== 'tableRow' || !holdsOnlyAttributes(row, []) || cells.length !== columns) return undefined
|
||||||
const wanted = index === 0 ? 'tableHeader' : 'tableCell'
|
const wanted = index === 0 ? 'tableHeader' : 'tableCell'
|
||||||
const paragraphs: AdfNode[] = []
|
const paragraphs: AdfNode[] = []
|
||||||
for (const cell of cells) {
|
for (const cell of cells) {
|
||||||
const paragraph = plainParagraph(cell)
|
const paragraph = plainParagraph(cell)
|
||||||
if (paragraph === undefined || cell.type !== wanted || !carriesOnly(cell, [])) return undefined
|
if (paragraph === undefined || cell.type !== wanted || !holdsOnlyAttributes(cell, [])) return undefined
|
||||||
paragraphs.push(paragraph)
|
paragraphs.push(paragraph)
|
||||||
}
|
}
|
||||||
grid.push(paragraphs)
|
grid.push(paragraphs)
|
||||||
@@ -46,6 +46,6 @@ function pipeRows(node: AdfNode): AdfNode[][] | undefined {
|
|||||||
function plainParagraph(cell: AdfNode): AdfNode | undefined {
|
function plainParagraph(cell: AdfNode): AdfNode | undefined {
|
||||||
const content = nodeContent(cell)
|
const content = nodeContent(cell)
|
||||||
const paragraph = content[0]
|
const paragraph = content[0]
|
||||||
if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !carriesOnly(paragraph, [])) return undefined
|
if (paragraph === undefined || content.length !== 1 || paragraph.type !== 'paragraph' || !holdsOnlyAttributes(paragraph, [])) return undefined
|
||||||
return paragraph
|
return paragraph
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export function readEmptyKeys(type: string, attributes: DirectiveAttributes, key
|
|||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const spelled = rest.get(key)
|
const spelled = rest.get(key)
|
||||||
if (spelled === undefined) continue
|
if (spelled === undefined) continue
|
||||||
if (!spellsEmpty(spelled)) return { fault: unsupportedNodeShape(`the reserved key ${key} reads ${key}=${emptyValue} alone: this one spells ${key}=${spelled.spelling}`) }
|
if (!spellsEmpty(spelled)) return { fault: unsupportedNodeShape(`the reserved key ${key} takes only the value ${emptyValue}: this one spells ${key}=${spelled.spelling}`) }
|
||||||
empty.add(key)
|
empty.add(key)
|
||||||
rest.delete(key)
|
rest.delete(key)
|
||||||
}
|
}
|
||||||
if (empty.has('attrs') && (held || [...rest.keys()].some((key) => key !== 'marks'))) return { fault: unsupportedNodeShape(`${type} spells attrs=empty beside an attribute it holds`) }
|
if (empty.has('attrs') && (held || [...rest.keys()].some((key) => key !== 'marks'))) return { fault: unsupportedNodeShape(`${type} spells attrs=empty beside another attribute, an argument or a [content] slot: drop attrs=empty, or the rest`) }
|
||||||
return { value: { empty, rest } }
|
return { value: { empty, rest } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ export function carryFenceType(info: string): string | undefined {
|
|||||||
|
|
||||||
export function readCarriedBlock(type: string, body: string, depth: number): Read<AdfNode> {
|
export function readCarriedBlock(type: string, body: string, depth: number): Read<AdfNode> {
|
||||||
if (type !== '' && !infoStringCarries(type)) {
|
if (type !== '' && !infoStringCarries(type)) {
|
||||||
return { fault: unsupportedNodeShape(`the carry fence names a type no info string carries back: spell it ${carryFencePrefix} with the type in the JSON`) }
|
return { fault: unsupportedNodeShape(`the carry fence names a type no info string carries back: spell it ${carryFencePrefix} with the type in the JSON; ${fenceEscape(type)}`) }
|
||||||
}
|
}
|
||||||
const read = readCarriedJson(body, 'two-space', largestNesting - depth, type)
|
const read = readCarriedJson(body, 'two-space', largestNesting - depth, type)
|
||||||
if (read.fault !== undefined || type !== '' || !infoStringCarries(read.value.type)) return read
|
if (read.fault !== undefined || type !== '' || !infoStringCarries(read.value.type)) return read
|
||||||
return { fault: unsupportedNodeShape(`the carry fence names a type its info string carries: spell it ${carryFencePrefix}${read.value.type}`) }
|
return { fault: unsupportedNodeShape(`the ${carryFencePrefix} fence holds a type its info string can carry: spell the fence ${carryFencePrefix}${read.value.type} and drop type from the JSON`) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefined {
|
export function readCarriedInline(span: DirectiveSpan): Read<AdfNode> | undefined {
|
||||||
@@ -89,7 +89,7 @@ function fenceEscape(type: string): string {
|
|||||||
|
|
||||||
function typedValue(value: JsonValue, type: string): Read<JsonValue> {
|
function typedValue(value: JsonValue, type: string): Read<JsonValue> {
|
||||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { value }
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { value }
|
||||||
if ('type' in value) return { fault: unsupportedNodeShape(`the ${carryFencePrefix}${type} fence names its node's type: this JSON holds a type as well`) }
|
if ('type' in value) return { fault: unsupportedNodeShape(`the ${carryFencePrefix}${type} fence names its node's type: this JSON holds a type as well; ${fenceEscape(type)}`) }
|
||||||
return { value: { ...value, type } }
|
return { value: { ...value, type } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
|||||||
import type { ConvertFault } from '../../result.ts'
|
import type { ConvertFault } from '../../result.ts'
|
||||||
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
|
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
|
||||||
import type { Elsewhere } from './directive-attributes.ts'
|
import type { Elsewhere } from './directive-attributes.ts'
|
||||||
import { attributeNestingMessage, isPlainText } from '../../adf/document.ts'
|
import { attributeNestingMessage, isUnmarkedBareText } from '../../adf/document.ts'
|
||||||
import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
|
import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
|
||||||
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts'
|
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts'
|
||||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||||
@@ -13,8 +13,8 @@ import { inlineMarkSpellingFault } from './directive-marks.ts'
|
|||||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||||
import { readEmptyKeys, spellsEmpty } from '../empty-keys.ts'
|
import { readEmptyKeys, spellsEmpty } from '../empty-keys.ts'
|
||||||
import { readVocabulary } from './directive-attributes.ts'
|
import { readVocabulary } from './directive-attributes.ts'
|
||||||
import { slotLineEndingFault } from '../directive-syntax.ts'
|
import { slotFault } from '../directive-syntax.ts'
|
||||||
import { textBreakName } from '../text-break.ts'
|
import { textBreakName } from '../adjacent-text.ts'
|
||||||
import { textDirectiveName } from '../text-directive.ts'
|
import { textDirectiveName } from '../text-directive.ts'
|
||||||
|
|
||||||
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; node: AdfNode }
|
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; node: AdfNode }
|
||||||
@@ -68,8 +68,8 @@ export function readInlineDirectiveNode(
|
|||||||
if (text === undefined) {
|
if (text === undefined) {
|
||||||
return failure('unsupported-node-shape', `the ${name} content slot holds one text node carrying neither marks, attributes nor content: this one holds something else`, path)
|
return failure('unsupported-node-shape', `the ${name} content slot holds one text node carrying neither marks, attributes nor content: this one holds something else`, path)
|
||||||
}
|
}
|
||||||
const spans = slotLineEndingFault(name, text)
|
const fault = slotFault(name, text)
|
||||||
if (spans !== undefined) return faulted(spans, path)
|
if (fault !== undefined) return faulted(fault, path)
|
||||||
attrs.value[slot] = text
|
attrs.value[slot] = text
|
||||||
}
|
}
|
||||||
return success(namedNode(name, attrs.value, undefined, empty.value.empty))
|
return success(namedNode(name, attrs.value, undefined, empty.value.empty))
|
||||||
@@ -92,7 +92,7 @@ function blockSpellingFault(name: string): ConvertFault | undefined {
|
|||||||
function slotText(content: readonly AdfNode[]): string | undefined {
|
function slotText(content: readonly AdfNode[]): string | undefined {
|
||||||
if (content.length === 0) return ''
|
if (content.length === 0) return ''
|
||||||
const only = content.length === 1 ? content[0] : undefined
|
const only = content.length === 1 ? content[0] : undefined
|
||||||
if (only === undefined || !isPlainText(only) || only.marks !== undefined || typeof only.text !== 'string') return undefined
|
if (only === undefined || !isUnmarkedBareText(only) || typeof only.text !== 'string') return undefined
|
||||||
return only.text
|
return only.text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts'
|
import type { DirectiveSpan, NestedSpans } from '../directive-syntax.ts'
|
||||||
import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
|
import type { EmphasisPairing } from '../commonmark/emphasis-matching.ts'
|
||||||
import type { Flavour } from '../plain-conventions.ts'
|
import type { Flavour } from '../plain/conventions.ts'
|
||||||
import type { LineContainer } from '../line-container.ts'
|
import type { LineContainer } from '../line-container.ts'
|
||||||
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
|
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
|
||||||
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark/grammar.ts'
|
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||||
@@ -9,9 +9,10 @@ import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
|
|||||||
import { commonMarkLink, linkHref } from '../mark-spellings.ts'
|
import { commonMarkLink, linkHref } from '../mark-spellings.ts'
|
||||||
import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
|
import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
|
||||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
import { highlightDelimiter, highlightFlanking } from '../plain-conventions.ts'
|
import { highlightDelimiter, highlightFlanking } from '../plain/conventions.ts'
|
||||||
import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||||
|
import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts'
|
||||||
import { noSpans, readInlineDirective } from '../directive-syntax.ts'
|
import { noSpans, readInlineDirective } from '../directive-syntax.ts'
|
||||||
import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts'
|
import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts'
|
||||||
import { openingLinkTakesDirective } from '../emit/inline-line.ts'
|
import { openingLinkTakesDirective } from '../emit/inline-line.ts'
|
||||||
@@ -19,19 +20,18 @@ import { readCarriedInline } from '../opaque-carry.ts'
|
|||||||
import { readDirectiveMark } from './directive-marks.ts'
|
import { readDirectiveMark } from './directive-marks.ts'
|
||||||
import { readInlineDirectiveNode } from './directive-nodes.ts'
|
import { readInlineDirectiveNode } from './directive-nodes.ts'
|
||||||
import { readTextDirective } from '../text-directive.ts'
|
import { readTextDirective } from '../text-directive.ts'
|
||||||
import { readsAsOne, textBreakName, textBreakSpelling } from '../text-break.ts'
|
|
||||||
|
|
||||||
export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] }
|
export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] }
|
||||||
|
|
||||||
// The text break leaf: it holds no marks, and stands between the nodes it parts until the outermost content checks and drops it.
|
// The text break leaf: it holds no marks, and stands between the nodes it parts until the outermost content checks and drops it.
|
||||||
type TextBreak = { kind: 'textBreak' }
|
type TextBreak = { kind: 'textBreak' }
|
||||||
|
|
||||||
// A node whose marks are its own — an opaque carry, or an inline node spelling marks=empty: no mark spelling wraps it, and it joins no neighbour.
|
// A node whose marks are its own — an opaque carry, or an inline node spelling marks=empty: no mark spelling wraps it, and it joins no neighbour.
|
||||||
type Carried = { kind: 'carry'; node: AdfNode }
|
type OwnMarks = { kind: 'ownMarks'; node: AdfNode }
|
||||||
|
|
||||||
type Inline = AdfNode | Carried | TextBreak
|
type Inline = AdfNode | OwnMarks | TextBreak
|
||||||
|
|
||||||
type Scanned = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: Inline[] }
|
type Scanned = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: Inline[] }
|
||||||
|
|
||||||
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
|
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ type Pairing = EmphasisPairing<Run>
|
|||||||
|
|
||||||
type Piece =
|
type Piece =
|
||||||
| Bracket
|
| Bracket
|
||||||
| Carried
|
| OwnMarks
|
||||||
| { alt: string; kind: 'image'; node: AdfNode }
|
| { alt: string; kind: 'image'; node: AdfNode }
|
||||||
| { kind: 'nodes'; nodes: Inline[] }
|
| { kind: 'nodes'; nodes: Inline[] }
|
||||||
| TextBreak
|
| TextBreak
|
||||||
@@ -70,9 +70,9 @@ type Scan = {
|
|||||||
// What a directive's content slot inherits from the content holding it.
|
// What a directive's content slot inherits from the content holding it.
|
||||||
type SharedScan = Pick<Scan, 'definitions' | 'path'>
|
type SharedScan = Pick<Scan, 'definitions' | 'path'>
|
||||||
|
|
||||||
type SlotContent = { carry: boolean; nodes: Inline[] }
|
type SlotContent = { nodes: Inline[] }
|
||||||
|
|
||||||
const carriedInMark = 'no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own'
|
const ownMarksInMark = 'move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks'
|
||||||
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
|
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
|
||||||
const hreflessLink = 'the link mark spells its href: this one spells none'
|
const hreflessLink = 'the link mark spells its href: this one spells none'
|
||||||
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
|
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
|
||||||
@@ -93,7 +93,7 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions,
|
|||||||
if (!takesDirective.ok) return takesDirective
|
if (!takesDirective.ok) return takesDirective
|
||||||
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path)
|
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path)
|
||||||
}
|
}
|
||||||
return success({ carry: scanned.value.carry, nodes: nodes.value })
|
return success({ nodes: nodes.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'container' | 'highlights' | 'spans'>): Scan {
|
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'container' | 'highlights' | 'spans'>): Scan {
|
||||||
@@ -236,7 +236,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
|
|||||||
const carried = readCarriedInline(span)
|
const carried = readCarriedInline(span)
|
||||||
if (carried !== undefined) {
|
if (carried !== undefined) {
|
||||||
if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
|
if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
|
||||||
return success({ kind: 'carry', node: carried.value })
|
return success({ kind: 'ownMarks', node: carried.value })
|
||||||
}
|
}
|
||||||
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success(textBreak) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.path)
|
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success(textBreak) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.path)
|
||||||
const text = readTextDirective(span)
|
const text = readTextDirective(span)
|
||||||
@@ -247,17 +247,17 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
|
|||||||
const mark = readDirectiveMark(span.name, span.attributes, scan.path)
|
const mark = readDirectiveMark(span.name, span.attributes, scan.path)
|
||||||
if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark
|
if (mark !== undefined) return mark.ok ? directiveMarkPiece(scan, span.name, mark.value, slot.value, index) : mark
|
||||||
const content = slot.value?.nodes
|
const content = slot.value?.nodes
|
||||||
if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: a content slot holds plain text`, scan.path)
|
if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this content slot: a slot holds one text node`, scan.path)
|
||||||
const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.path)
|
const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.path)
|
||||||
if (!node.ok) return node
|
if (!node.ok) return node
|
||||||
return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : { kind: 'carry', node: node.value })
|
return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : { kind: 'ownMarks', node: node.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result<Piece> {
|
function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result<Piece> {
|
||||||
if (slot === undefined || slot.nodes.length === 0) {
|
if (slot === undefined || slot.nodes.length === 0) {
|
||||||
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path)
|
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path)
|
||||||
}
|
}
|
||||||
if (slot.carry) return failure('unsupported-node-shape', carriedInMark, scan.path)
|
if (slot.nodes.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
|
||||||
const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, slot.nodes, index) : undefined
|
const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, slot.nodes, index) : undefined
|
||||||
if (refused !== undefined) return refused
|
if (refused !== undefined) return refused
|
||||||
return success({ kind: 'nodes', nodes: applyMark(slot.nodes, mark) })
|
return success({ kind: 'nodes', nodes: applyMark(slot.nodes, mark) })
|
||||||
@@ -299,7 +299,7 @@ function assemble(scan: Scan): Result<Scanned> {
|
|||||||
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
|
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
|
||||||
const nodes = resolveNodes(scan.pieces, scan, true)
|
const nodes = resolveNodes(scan.pieces, scan, true)
|
||||||
if (!nodes.ok) return nodes
|
if (!nodes.ok) return nodes
|
||||||
return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value })
|
return success({ nodes: nodes.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
// spec/flavour.md, Inline nodes: the leaf builds no node, so only the pair it parts spells it.
|
// spec/flavour.md, Inline nodes: the leaf builds no node, so only the pair it parts spells it.
|
||||||
@@ -312,8 +312,8 @@ function partText(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
|
|||||||
}
|
}
|
||||||
const previous = items[index - 1]
|
const previous = items[index - 1]
|
||||||
const next = items[index + 1]
|
const next = items[index + 1]
|
||||||
if (previous === undefined || next === undefined || !isNode(previous) || !isNode(next) || !readsAsOne(previous, next)) {
|
if (previous === undefined || next === undefined || !isNode(previous) || !isNode(next) || !joinsWhenRead(previous, next)) {
|
||||||
return failure('unsupported-node-shape', `${textBreakName} parts two text nodes CommonMark reads back as one: this one parts something else`, scan.path)
|
return failure('unsupported-node-shape', `delete ${textBreakSpelling} here: it stands only between two runs of text with the same formatting, which would otherwise read as one`, scan.path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return success(parted)
|
return success(parted)
|
||||||
@@ -327,7 +327,7 @@ function isTextBreak(item: Inline): item is TextBreak {
|
|||||||
return 'kind' in item && item.kind === 'textBreak'
|
return 'kind' in item && item.kind === 'textBreak'
|
||||||
}
|
}
|
||||||
|
|
||||||
// The nodes the items hold, carried ones unwrapped and text breaks dropped.
|
// The nodes the items hold, with text breaks dropped.
|
||||||
function adfNodes(items: readonly Inline[]): AdfNode[] {
|
function adfNodes(items: readonly Inline[]): AdfNode[] {
|
||||||
const nodes: AdfNode[] = []
|
const nodes: AdfNode[] = []
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -337,15 +337,15 @@ function adfNodes(items: readonly Inline[]): AdfNode[] {
|
|||||||
return nodes
|
return nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
function holdsCarry(pieces: readonly Piece[]): boolean {
|
function holdsOwnMarks(item: Inline | Piece): boolean {
|
||||||
return pieces.some((piece) => piece.kind === 'carry')
|
return 'kind' in item && item.kind === 'ownMarks'
|
||||||
}
|
}
|
||||||
|
|
||||||
function holdsImage(pieces: readonly Piece[]): boolean {
|
function holdsImage(pieces: readonly Piece[]): boolean {
|
||||||
return pieces.some((piece) => piece.kind === 'image')
|
return pieces.some((piece) => piece.kind === 'image')
|
||||||
}
|
}
|
||||||
|
|
||||||
// A carry rides its own piece: the carried node's marks restore with it rather than riding a spelling, so the guard below answers for it.
|
// A node whose marks are its own rides its own piece, its marks restoring with it rather than riding a spelling, so the guard below answers for it.
|
||||||
function holdsLink(pieces: readonly Piece[]): boolean {
|
function holdsLink(pieces: readonly Piece[]): boolean {
|
||||||
return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes))
|
return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes))
|
||||||
}
|
}
|
||||||
@@ -447,7 +447,7 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition:
|
|||||||
return success(false)
|
return success(false)
|
||||||
}
|
}
|
||||||
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
|
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
|
||||||
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path)
|
if (inner.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
|
||||||
const resolved = resolveNodes(inner, scan, true)
|
const resolved = resolveNodes(inner, scan, true)
|
||||||
if (!resolved.ok) return resolved
|
if (!resolved.ok) return resolved
|
||||||
const nodes = resolved.value
|
const nodes = resolved.value
|
||||||
@@ -488,7 +488,7 @@ function closeImage(scan: Scan, at: number, inner: readonly Piece[], definition:
|
|||||||
function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> {
|
function imageAlt(inner: readonly Piece[], scan: Scan): Result<string> {
|
||||||
const nodes = resolveNodes(inner, scan, false)
|
const nodes = resolveNodes(inner, scan, false)
|
||||||
if (!nodes.ok) return nodes
|
if (!nodes.ok) return nodes
|
||||||
if (nodes.value.some(isTextBreak)) return failure('unsupported-node-shape', `${textBreakName} parts two text nodes: an image description holds plain text`, scan.path)
|
if (nodes.value.some(isTextBreak)) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this image description: the description reads as plain alt text`, scan.path)
|
||||||
return success(adfNodes(nodes.value).map(altText).join(''))
|
return success(adfNodes(nodes.value).map(altText).join(''))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,13 +506,13 @@ function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean)
|
|||||||
const runs = delimiterRuns(pieces)
|
const runs = delimiterRuns(pieces)
|
||||||
const pairings = matchEmphasis(runs)
|
const pairings = matchEmphasis(runs)
|
||||||
writeUnpaired(nodes, runs, pairings)
|
writeUnpaired(nodes, runs, pairings)
|
||||||
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, scan.path)
|
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
|
||||||
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
|
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
|
||||||
return success(mergeText(nodes.flat()))
|
return success(mergeReadText(nodes.flat()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// A text break and a carried node are walls: the text on either side joins only its own side.
|
// A text break and a node whose marks are its own are walls: the text on either side joins only its own side.
|
||||||
function mergeText(items: readonly Inline[]): Inline[] {
|
function mergeReadText(items: readonly Inline[]): Inline[] {
|
||||||
const merged: Inline[] = []
|
const merged: Inline[] = []
|
||||||
let run: AdfNode[] = []
|
let run: AdfNode[] = []
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -520,11 +520,11 @@ function mergeText(items: readonly Inline[]): Inline[] {
|
|||||||
run.push(item)
|
run.push(item)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for (const node of mergeAdjacentText(run, readsAsOne)) merged.push(node)
|
for (const node of mergeAdjacentText(run, joinsWhenRead)) merged.push(node)
|
||||||
merged.push(item)
|
merged.push(item)
|
||||||
run = []
|
run = []
|
||||||
}
|
}
|
||||||
for (const node of mergeAdjacentText(run, readsAsOne)) merged.push(node)
|
for (const node of mergeAdjacentText(run, joinsWhenRead)) merged.push(node)
|
||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,7 +532,7 @@ function mergeText(items: readonly Inline[]): Inline[] {
|
|||||||
// A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it.
|
// A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it.
|
||||||
function pieceNodes(piece: Piece): Inline[] {
|
function pieceNodes(piece: Piece): Inline[] {
|
||||||
switch (piece.kind) {
|
switch (piece.kind) {
|
||||||
case 'carry':
|
case 'ownMarks':
|
||||||
return [piece]
|
return [piece]
|
||||||
case 'highlight':
|
case 'highlight':
|
||||||
return [{ text: '', type: 'text' }]
|
return [{ text: '', type: 'text' }]
|
||||||
@@ -577,7 +577,7 @@ function markPairings(pieces: readonly Piece[], nodes: Inline[][], pairings: rea
|
|||||||
for (const pairing of pairings) {
|
for (const pairing of pairings) {
|
||||||
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
|
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
|
||||||
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) {
|
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) {
|
||||||
if (pieces[index]?.kind === 'carry') return false
|
if (pieces[index]?.kind === 'ownMarks') return false
|
||||||
nodes[index] = applyMark(nodes[index] ?? [], mark)
|
nodes[index] = applyMark(nodes[index] ?? [], mark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,9 +92,12 @@ test('builds an empty document from input holding no block, and one holding no c
|
|||||||
const form = 'unsupported-node-shape: doc spells the one form !adf:doc {content=none}: this one spells another'
|
const form = 'unsupported-node-shape: doc spells the one form !adf:doc {content=none}: this one spells another'
|
||||||
assert.equal(content(markdownToAdf('!adf:doc\n')), form)
|
assert.equal(content(markdownToAdf('!adf:doc\n')), form)
|
||||||
assert.equal(content(markdownToAdf('!adf:doc x {content=none}\n')), form)
|
assert.equal(content(markdownToAdf('!adf:doc x {content=none}\n')), form)
|
||||||
assert.equal(content(markdownToAdf('!adf:doc {content=empty}\n')), form)
|
assert.equal(
|
||||||
|
content(markdownToAdf('!adf:doc {content=empty}\n')),
|
||||||
|
'unsupported-node-shape: an empty document is empty markdown, and !adf:doc {content=none} spells a document holding no content key: this one spells content=empty',
|
||||||
|
)
|
||||||
assert.equal(content(markdownToAdf('!adf:doc {content="none"}\n')), form)
|
assert.equal(content(markdownToAdf('!adf:doc {content="none"}\n')), form)
|
||||||
const alone = 'unsupported-node-shape: !adf:doc {content=none} spells a whole document holding no content key, alone: this one stands among other blocks'
|
const alone = 'unsupported-node-shape: delete the !adf:doc {content=none} line to give the document content: it stands only as the whole document'
|
||||||
assert.equal(content(markdownToAdf('x\n\n!adf:doc {content=none}\n')), alone)
|
assert.equal(content(markdownToAdf('x\n\n!adf:doc {content=none}\n')), alone)
|
||||||
assert.equal(content(markdownToAdf('!adf:panel info\n!adf:doc {content=none}\n!adf:/panel\n')), alone)
|
assert.equal(content(markdownToAdf('!adf:panel info\n!adf:doc {content=none}\n!adf:/panel\n')), alone)
|
||||||
assert.equal(content(markdownToAdf('!adf:doc\n!adf:/doc\n')), 'malformed-directive: doc takes no body, so no !adf:/doc closes it; \\!adf: keeps the prefix literal')
|
assert.equal(content(markdownToAdf('!adf:doc\n!adf:/doc\n')), 'malformed-directive: doc takes no body, so no !adf:/doc closes it; \\!adf: keeps the prefix literal')
|
||||||
@@ -163,7 +166,11 @@ test('reads the codeBlock directive body as the node content, the info string it
|
|||||||
|
|
||||||
test('names the slot a codeBlock spells its language outside of', () => {
|
test('names the slot a codeBlock spells its language outside of', () => {
|
||||||
const slot = 'unsupported-node-shape: codeBlock spells its language in the fence info string, or in the attribute where no info string carries it back'
|
const slot = 'unsupported-node-shape: codeBlock spells its language in the fence info string, or in the attribute where no info string carries it back'
|
||||||
assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust wrap=true}\n```\nx\n```\n!adf:/codeBlock\n')), slot)
|
assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust wrap=true}\n```\nx\n```\n!adf:/codeBlock\n')), "unsupported-node-shape: move language=rust to the fences' info strings: they can spell this language")
|
||||||
|
assert.equal(
|
||||||
|
content(markdownToAdf('!adf:codeBlock {language="c sharp"}\n```\nx\n```\n```\ny\n```\n!adf:/codeBlock\n')),
|
||||||
|
"unsupported-node-shape: move language=\"c sharp\" to the fences' info strings: they can spell this language",
|
||||||
|
)
|
||||||
assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust}\n```sql\nx\n```\n!adf:/codeBlock\n')), slot)
|
assert.equal(content(markdownToAdf('!adf:codeBlock {language=rust}\n```sql\nx\n```\n!adf:/codeBlock\n')), slot)
|
||||||
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```adf:x\nx\n```\n!adf:/codeBlock\n')), slot)
|
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```adf:x\nx\n```\n!adf:/codeBlock\n')), slot)
|
||||||
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```a\\b\nx\n```\n!adf:/codeBlock\n')), slot)
|
assert.equal(content(markdownToAdf('!adf:codeBlock {wrap=true}\n```a\\b\nx\n```\n!adf:/codeBlock\n')), slot)
|
||||||
@@ -297,10 +304,8 @@ test('names the directive form a node CommonMark spells refuses', () => {
|
|||||||
assert.deepEqual(content(markdownToAdf('!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')), [bulletList(item({ type: 'rule' }))])
|
assert.deepEqual(content(markdownToAdf('!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')), [bulletList(item({ type: 'rule' }))])
|
||||||
})
|
})
|
||||||
|
|
||||||
// The spelling the emitter refuses gives the emitter's own error, never a second name for it.
|
test('refuses the directive form of a blockquote opening with a code span', () => {
|
||||||
test('gives back the refusal the CommonMark spelling itself raises', () => {
|
assert.equal(content(markdownToAdf('!adf:blockquote\n` `` `\n!adf:/blockquote\n')), 'unsupported-node-shape: blockquote takes the CommonMark spelling, not the directive form')
|
||||||
const lineStart = '!adf:blockquote\n` `` `\n!adf:/blockquote\n'
|
|
||||||
assert.equal(content(markdownToAdf(lineStart)), 'unspellable-line-start: block parsing would claim the emitted line "``` `` ```"')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('names the directive name no node reads back to', () => {
|
test('names the directive name no node reads back to', () => {
|
||||||
@@ -371,11 +376,11 @@ test('reads the carry fence back to the node its info string names and its JSON
|
|||||||
assert.deepEqual(content(markdownToAdf('```adf:\n{\n "type": "a`b"\n}\n```\n')), [{ type: 'a`b' }])
|
assert.deepEqual(content(markdownToAdf('```adf:\n{\n "type": "a`b"\n}\n```\n')), [{ type: 'a`b' }])
|
||||||
assert.equal(
|
assert.equal(
|
||||||
content(markdownToAdf('```adf:blockCard\n{\n "type": "blockCard"\n}\n```\n')),
|
content(markdownToAdf('```adf:blockCard\n{\n "type": "blockCard"\n}\n```\n')),
|
||||||
"unsupported-node-shape: the adf:blockCard fence names its node's type: this JSON holds a type as well",
|
'unsupported-node-shape: the adf:blockCard fence names its node\'s type: this JSON holds a type as well; !adf:codeBlock {language="adf:blockCard"} around a bare fence keeps it a code block',
|
||||||
)
|
)
|
||||||
assert.equal(content(markdownToAdf('```adf:\n{\n "type": "blockCard"\n}\n```\n')), 'unsupported-node-shape: the carry fence names a type its info string carries: spell it adf:blockCard')
|
assert.equal(content(markdownToAdf('```adf:\n{\n "type": "blockCard"\n}\n```\n')), 'unsupported-node-shape: the adf: fence holds a type its info string can carry: spell the fence adf:blockCard and drop type from the JSON')
|
||||||
assert.equal(content(markdownToAdf('```adf:\n{}\n```\n')), "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node; !adf:codeBlock {language=\"adf:\"} around a bare fence keeps it a code block")
|
assert.equal(content(markdownToAdf('```adf:\n{}\n```\n')), "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node; !adf:codeBlock {language=\"adf:\"} around a bare fence keeps it a code block")
|
||||||
const unnamed = 'unsupported-node-shape: the carry fence names a type no info string carries back: spell it adf: with the type in the JSON'
|
const unnamed = 'unsupported-node-shape: the carry fence names a type no info string carries back: spell it adf: with the type in the JSON; !adf:codeBlock {language="adf:\\\\"} around a bare fence keeps it a code block'
|
||||||
assert.equal(content(markdownToAdf('```adf:\\\\\n{}\n```\n')), unnamed)
|
assert.equal(content(markdownToAdf('```adf:\\\\\n{}\n```\n')), unnamed)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -438,7 +443,7 @@ test('names the number no JSON spelling carries in an opaque carry', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('names the mark spelling no opaque carry sits inside', () => {
|
test('names the mark spelling no opaque carry sits inside', () => {
|
||||||
const named = 'unsupported-node-shape: no mark spelling wraps an opaque carry or an inline node spelling marks=empty: its marks are its own'
|
const named = 'unsupported-node-shape: move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks'
|
||||||
assert.equal(content(markdownToAdf(`_a ${carried} b_\n`)), named)
|
assert.equal(content(markdownToAdf(`_a ${carried} b_\n`)), named)
|
||||||
assert.equal(content(markdownToAdf(`**${carried}**\n`)), named)
|
assert.equal(content(markdownToAdf(`**${carried}**\n`)), named)
|
||||||
assert.equal(content(markdownToAdf(`~~a ${carried}~~\n`)), named)
|
assert.equal(content(markdownToAdf(`~~a ${carried}~~\n`)), named)
|
||||||
@@ -1000,9 +1005,15 @@ test('names the content slot no lone plain text node reads back from', () => {
|
|||||||
assert.equal(content(markdownToAdf('!adf:status[!adf:text{text="\\n"}]{color=yellow}\n')), spans)
|
assert.equal(content(markdownToAdf('!adf:status[!adf:text{text="\\n"}]{color=yellow}\n')), spans)
|
||||||
assert.equal(content(markdownToAdf('!adf:status[a b]{color=yellow}\n')), spans)
|
assert.equal(content(markdownToAdf('!adf:status[a b]{color=yellow}\n')), spans)
|
||||||
assert.equal(content(markdownToAdf('!adf:status[a b]{color=yellow}\n')), spans)
|
assert.equal(content(markdownToAdf('!adf:status[a b]{color=yellow}\n')), spans)
|
||||||
|
assert.equal(content(markdownToAdf('!adf:status[!adf:text{text="\\u0000"}]{color=yellow}\n')), 'unsupported-node-shape: the status content slot holds a null character: write the node as !adf:carry')
|
||||||
assert.equal(content(markdownToAdf('Part !adf:mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}')
|
assert.equal(content(markdownToAdf('Part !adf:mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('reads the characters CommonMark rewrites from their spellings', () => {
|
||||||
|
assert.deepEqual(content(markdownToAdf('a b!adf:text{text="\\u0000\\u0000"}\n')), [paragraph('a\rb\u0000\u0000')])
|
||||||
|
assert.equal(content(markdownToAdf('!adf:text{text="\\u0000a"}\n')), 'unsupported-node-shape: text spells one run of spaces and tabs, one run of newlines, or one run of null characters: this one spells none of them')
|
||||||
|
})
|
||||||
|
|
||||||
test('reads the whitespace the reserved text directive carries', () => {
|
test('reads the whitespace the reserved text directive carries', () => {
|
||||||
assert.deepEqual(content(markdownToAdf('!adf:text{text=" "}a\n')), [paragraph(' a')])
|
assert.deepEqual(content(markdownToAdf('!adf:text{text=" "}a\n')), [paragraph(' a')])
|
||||||
assert.deepEqual(content(markdownToAdf('a!adf:text{text="\\n"}b\n')), [paragraph('a\nb')])
|
assert.deepEqual(content(markdownToAdf('a!adf:text{text="\\n"}b\n')), [paragraph('a\nb')])
|
||||||
@@ -1011,7 +1022,7 @@ test('reads the whitespace the reserved text directive carries', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('names the text directive spelling no whitespace run reads back from', () => {
|
test('names the text directive spelling no whitespace run reads back from', () => {
|
||||||
const named = 'unsupported-node-shape: text spells one run of spaces and tabs, or one run of newlines: this one spells neither'
|
const named = 'unsupported-node-shape: text spells one run of spaces and tabs, one run of newlines, or one run of null characters: this one spells none of them'
|
||||||
assert.equal(content(markdownToAdf('!adf:text{text=hi}\n')), named)
|
assert.equal(content(markdownToAdf('!adf:text{text=hi}\n')), named)
|
||||||
assert.equal(content(markdownToAdf('!adf:text{text=" \\n"}\n')), named)
|
assert.equal(content(markdownToAdf('!adf:text{text=" \\n"}\n')), named)
|
||||||
assert.equal(content(markdownToAdf('!adf:text{text=""}\n')), named)
|
assert.equal(content(markdownToAdf('!adf:text{text=""}\n')), named)
|
||||||
@@ -1087,13 +1098,32 @@ test('names the directive mark left without the content it wraps', () => {
|
|||||||
test('reads the text break only between two text nodes CommonMark joins, building no node', () => {
|
test('reads the text break only between two text nodes CommonMark joins, building no node', () => {
|
||||||
assert.deepEqual(content(markdownToAdf('a!adf:textBreak{}b\n')), [{ content: [text('a'), text('b')], type: 'paragraph' }])
|
assert.deepEqual(content(markdownToAdf('a!adf:textBreak{}b\n')), [{ content: [text('a'), text('b')], type: 'paragraph' }])
|
||||||
assert.deepEqual(content(markdownToAdf('==a!adf:textBreak{}b==\n')), [{ content: [text('==a'), text('b==')], type: 'paragraph' }])
|
assert.deepEqual(content(markdownToAdf('==a!adf:textBreak{}b==\n')), [{ content: [text('==a'), text('b==')], type: 'paragraph' }])
|
||||||
const parts = 'unsupported-node-shape: textBreak parts two text nodes CommonMark reads back as one: this one parts something else'
|
const parts = 'unsupported-node-shape: delete !adf:textBreak{} here: it stands only between two runs of text with the same formatting, which would otherwise read as one'
|
||||||
const carried = '!adf:carry{json="{\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}'
|
const carried = '!adf:carry{json="{\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}'
|
||||||
for (const markdown of ['!adf:textBreak{}a\n', 'a!adf:textBreak{}\n', 'a!adf:textBreak{}!adf:textBreak{}b\n', '**a**!adf:textBreak{}b\n', '[!adf:textBreak{}](/u)\n', `a!adf:textBreak{}${carried}\n`]) {
|
for (const markdown of ['!adf:textBreak{}a\n', 'a!adf:textBreak{}\n', 'a!adf:textBreak{}!adf:textBreak{}b\n', '**a**!adf:textBreak{}b\n', '[!adf:textBreak{}](/u)\n', `a!adf:textBreak{}${carried}\n`]) {
|
||||||
assert.equal(content(markdownToAdf(markdown)), parts, markdown)
|
assert.equal(content(markdownToAdf(markdown)), parts, markdown)
|
||||||
}
|
}
|
||||||
assert.equal(content(markdownToAdf('!adf:status[a!adf:textBreak{}b]\n')), 'unsupported-node-shape: textBreak parts two text nodes: a content slot holds plain text')
|
assert.equal(content(markdownToAdf('!adf:status[a!adf:textBreak{}b]\n')), 'unsupported-node-shape: delete !adf:textBreak{} from this content slot: a slot holds one text node')
|
||||||
assert.equal(content(markdownToAdf('\n')), 'unsupported-node-shape: textBreak parts two text nodes: an image description holds plain text')
|
assert.equal(content(markdownToAdf('\n')), 'unsupported-node-shape: delete !adf:textBreak{} from this image description: the description reads as plain alt text')
|
||||||
assert.equal(content(markdownToAdf('a!adf:textBreak{x=y}b\n')), 'unsupported-node-shape: textBreak spells the bare leaf form, !adf:textBreak{}: this one spells more')
|
assert.equal(content(markdownToAdf('a!adf:textBreak{x=y}b\n')), 'unsupported-node-shape: textBreak spells the bare leaf form, !adf:textBreak{}: this one spells more')
|
||||||
assert.deepEqual(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"textBreak\\"}"}\n')), [{ content: [{ type: 'textBreak' }], type: 'paragraph' }])
|
assert.deepEqual(content(markdownToAdf('!adf:carry{json="{\\"type\\":\\"textBreak\\"}"}\n')), [{ content: [{ type: 'textBreak' }], type: 'paragraph' }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('leads a refusal ordinary editing meets with the edit that fixes it', () => {
|
||||||
|
assert.equal(
|
||||||
|
content(markdownToAdf('!adf:paragraph {content=empty}\nText.\n!adf:/paragraph\n')),
|
||||||
|
'unsupported-node-shape: remove content=empty to give the paragraph a body: content=empty holds none, and this one holds one',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
content(markdownToAdf('!adf:codeBlock\n```\na\n```\n```\n```\n!adf:/codeBlock\n')),
|
||||||
|
'unsupported-node-shape: delete the empty fence: a fence beside another holds code, and this one holds none',
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
content(markdownToAdf('!adf:codeBlock {attrs=empty}\n```js\na\n```\n```js\nb\n```\n!adf:/codeBlock\n')),
|
||||||
|
"unsupported-node-shape: remove attrs=empty to give the codeBlock the fence's language: attrs=empty holds no language, and this fence names one",
|
||||||
|
)
|
||||||
|
assert.equal(
|
||||||
|
content(markdownToAdf('!adf:panel info {attrs=empty}\nText.\n!adf:/panel\n')),
|
||||||
|
'unsupported-node-shape: panel spells attrs=empty beside another attribute, an argument or a [content] slot: drop attrs=empty, or the rest',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,23 +2,24 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
|||||||
import type { Block, DirectiveBlock } from './blocks.ts'
|
import type { Block, DirectiveBlock } from './blocks.ts'
|
||||||
import type { BlockDirectiveNode } from './directive-nodes.ts'
|
import type { BlockDirectiveNode } from './directive-nodes.ts'
|
||||||
import type { ConvertFault } from '../../result.ts'
|
import type { ConvertFault } from '../../result.ts'
|
||||||
import type { Flavour } from '../plain-conventions.ts'
|
import type { Flavour } from '../plain/conventions.ts'
|
||||||
import type { LineContainer } from '../line-container.ts'
|
import type { LineContainer } from '../line-container.ts'
|
||||||
import type { LinkDefinitions } from './inline-content.ts'
|
import type { LinkDefinitions } from './inline-content.ts'
|
||||||
import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts'
|
import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts'
|
||||||
import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts'
|
import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.ts'
|
||||||
import { documentAttribute, documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts'
|
import { documentAttribute, documentName, documentSpelling, listBreakName, listBreakSpelling } from '../block-directive.ts'
|
||||||
|
import { emptyKeys, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||||
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
|
import { failure, faulted, positioned, success, type ConvertErrorPath, type ParseError, type Result, type SourcePosition } from '../../result.ts'
|
||||||
import { inlineLeaves } from '../emit/plain-inline.ts'
|
import { inlineLeaves } from '../plain/inline-reduction.ts'
|
||||||
import { languageSlot } from '../code-language.ts'
|
import { languageSlot } from '../code-language.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain-conventions.ts'
|
import { leadingMarker, readAlertMarker, readTaskMarker } from '../plain/conventions.ts'
|
||||||
import { mintTaskIds } from './task-ids.ts'
|
import { mintTaskIds } from '../plain/task-ids.ts'
|
||||||
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
|
|
||||||
import { parseBlocks } from './blocks.ts'
|
import { parseBlocks } from './blocks.ts'
|
||||||
import { parseInlineContent } from './inline-content.ts'
|
import { parseInlineContent } from './inline-content.ts'
|
||||||
import { readBlockDirectiveNode } from './directive-nodes.ts'
|
import { readBlockDirectiveNode } from './directive-nodes.ts'
|
||||||
import { unsupportedNodeShape } from '../directive-syntax.ts'
|
import { spellStringAttribute, unsupportedNodeShape } from '../directive-syntax.ts'
|
||||||
|
import { spellsEmpty } from '../empty-keys.ts'
|
||||||
|
|
||||||
type Paragraph = Extract<Block, { kind: 'paragraph' }>
|
type Paragraph = Extract<Block, { kind: 'paragraph' }>
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ function readBlocks(blocks: readonly Block[], reading: Reading, path: ConvertErr
|
|||||||
for (const [index, block] of blocks.entries()) {
|
for (const [index, block] of blocks.entries()) {
|
||||||
const nodePath = [...path, 'content', content.length]
|
const nodePath = [...path, 'content', content.length]
|
||||||
if (block.kind === 'directive' && block.name === documentName) {
|
if (block.kind === 'directive' && block.name === documentName) {
|
||||||
const fault = documentFault(block) ?? unsupportedNodeShape(`${documentSpelling} spells a whole document holding no content key, alone: this one stands among other blocks`)
|
const fault = documentFault(block) ?? unsupportedNodeShape(`delete the ${documentSpelling} line to give the document content: it stands only as the whole document`)
|
||||||
return positioned(faulted(fault, nodePath), block.position)
|
return positioned(faulted(fault, nodePath), block.position)
|
||||||
}
|
}
|
||||||
if (block.kind === 'directive' && block.name === listBreakName) {
|
if (block.kind === 'directive' && block.name === listBreakName) {
|
||||||
@@ -85,6 +86,9 @@ function listBreakFault(block: DirectiveBlock, previous: Block | undefined, next
|
|||||||
function documentFault(block: DirectiveBlock): ConvertFault | undefined {
|
function documentFault(block: DirectiveBlock): ConvertFault | undefined {
|
||||||
const spelled = block.attributes.get(documentAttribute.key)
|
const spelled = block.attributes.get(documentAttribute.key)
|
||||||
if (block.argument === undefined && block.attributes.size === 1 && spelled?.spelling === documentAttribute.value) return undefined
|
if (block.argument === undefined && block.attributes.size === 1 && spelled?.spelling === documentAttribute.value) return undefined
|
||||||
|
if (block.argument === undefined && block.attributes.size === 1 && spellsEmpty(spelled)) {
|
||||||
|
return unsupportedNodeShape(`an empty document is empty markdown, and ${documentSpelling} spells a document holding no content key: this one spells ${documentAttribute.key}=empty`)
|
||||||
|
}
|
||||||
return unsupportedNodeShape(`${documentName} spells the one form ${documentSpelling}: this one spells another`)
|
return unsupportedNodeShape(`${documentName} spells the one form ${documentSpelling}: this one spells another`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +236,7 @@ function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, re
|
|||||||
const { contentModel, node } = read
|
const { contentModel, node } = read
|
||||||
if (blocks === undefined) return success(node)
|
if (blocks === undefined) return success(node)
|
||||||
// A directive builds a content key only from content=empty, which holds no body.
|
// A directive builds a content key only from content=empty, which holds no body.
|
||||||
if (node.content !== undefined) return blocks.length === 0 ? success(node) : failure('unsupported-node-shape', `${node.type} spells content=empty, which holds no body: this one holds one`, path)
|
if (node.content !== undefined) return blocks.length === 0 ? success(node) : failure('unsupported-node-shape', `remove content=empty to give the ${node.type} a body: content=empty holds none, and this one holds one`, path)
|
||||||
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
|
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
|
||||||
if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path)
|
if (contentModel === 'inline') return inlineBodyNode(node, blocks, reading, path)
|
||||||
return containerNode(node, blocks, reading, path, depth)
|
return containerNode(node, blocks, reading, path, depth)
|
||||||
@@ -248,13 +252,19 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver
|
|||||||
const [first] = fences
|
const [first] = fences
|
||||||
if (first === undefined) return failure('unsupported-node-shape', `${node.type} takes code blocks as its body: this body holds none`, path)
|
if (first === undefined) return failure('unsupported-node-shape', `${node.type} takes code blocks as its body: this body holds none`, path)
|
||||||
if (fences.some((fence) => fence.language !== first.language)) return failure('unsupported-node-shape', `${node.type} holds one language, so its fences carry one info string: these differ`, path)
|
if (fences.some((fence) => fence.language !== first.language)) return failure('unsupported-node-shape', `${node.type} holds one language, so its fences carry one info string: these differ`, path)
|
||||||
if (fences.length > 1 && fences.some((fence) => fence.text === '')) return failure('unsupported-node-shape', `a fence beside another spells a text node, which holds text: this one is empty`, path)
|
if (fences.length > 1 && fences.some((fence) => fence.text === '')) return failure('unsupported-node-shape', `delete the empty fence: a fence beside another holds code, and this one holds none`, path)
|
||||||
const attribute = nodeAttrs(node)['language']
|
const attribute = nodeAttrs(node)['language']
|
||||||
const fromFence = first.language !== ''
|
const fromFence = first.language !== ''
|
||||||
const slot = languageSlot(fromFence ? first.language : attribute)
|
const slot = languageSlot(fromFence ? first.language : attribute)
|
||||||
|
if (!fromFence && slot.kind === 'fence') {
|
||||||
|
return failure('unsupported-node-shape', `move language=${spellStringAttribute(slot.info)} to the fences' info strings: they can spell this language`, path)
|
||||||
|
}
|
||||||
if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) {
|
if ((slot.kind === 'fence') !== fromFence || (fromFence && attribute !== undefined)) {
|
||||||
return failure('unsupported-node-shape', `${node.type} spells its language in the fence info string, or in the attribute where no info string carries it back`, path)
|
return failure('unsupported-node-shape', `${node.type} spells its language in the fence info string, or in the attribute where no info string carries it back`, path)
|
||||||
}
|
}
|
||||||
|
if (fromFence && emptyKeys(node).includes('attrs')) {
|
||||||
|
return failure('unsupported-node-shape', `remove attrs=empty to give the ${node.type} the fence's language: attrs=empty holds no language, and this fence names one`, path)
|
||||||
|
}
|
||||||
const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: first.language } } : node
|
const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: first.language } } : node
|
||||||
return success(withContent(spelled, first.text === '' ? [] : fences.map((fence): AdfNode => ({ text: fence.text, type: 'text' }))))
|
return success(withContent(spelled, first.text === '' ? [] : fences.map((fence): AdfNode => ({ text: fence.text, type: 'text' }))))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import test from 'node:test'
|
|||||||
|
|
||||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
import { adfToMarkdown } from '../emit/adf-to-markdown.ts'
|
import { adfToMarkdown } from '../emit/adf-to-markdown.ts'
|
||||||
import { adfToPlainMarkdown } from '../emit/plain-reduction.ts'
|
import { adfToPlainMarkdown } from '../plain/adf-to-plain-markdown.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
|
import { markdownToAdf, plainMarkdownToAdf } from './markdown-to-adf.ts'
|
||||||
|
|
||||||
@@ -30,10 +30,6 @@ function read(markdown: string): readonly AdfNode[] | string {
|
|||||||
return blocks
|
return blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
function blocks(...content: AdfNode[]): readonly AdfNode[] {
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
function document(...content: AdfNode[]): AdfDocument {
|
function document(...content: AdfNode[]): AdfDocument {
|
||||||
return { content, type: 'doc', version: 1 }
|
return { content, type: 'doc', version: 1 }
|
||||||
}
|
}
|
||||||
@@ -100,7 +96,7 @@ test('reads the rest of an alert marker line as the panel first body paragraph,
|
|||||||
assert.deepEqual(read('> [!tip] Title\n> body\n'), [panel('tip', said('Title'), said('body'))])
|
assert.deepEqual(read('> [!tip] Title\n> body\n'), [panel('tip', said('Title'), said('body'))])
|
||||||
assert.deepEqual(read('> [!tip] Title\\\n> body\n'), [panel('tip', said('Title'), said('body'))])
|
assert.deepEqual(read('> [!tip] Title\\\n> body\n'), [panel('tip', said('Title'), said('body'))])
|
||||||
assert.deepEqual(read('> [!NOTE]\\\n> Broken.\n'), [panel('info', said('Broken.'))])
|
assert.deepEqual(read('> [!NOTE]\\\n> Broken.\n'), [panel('info', said('Broken.'))])
|
||||||
assert.deepEqual(read('> [!NOTE]\n'), blocks(panel('info', paragraph())))
|
assert.deepEqual(read('> [!NOTE]\n'), [panel('info', paragraph())])
|
||||||
assert.deepEqual(read('> > [!WARNING]\n> > Inner.\n'), [bare('blockquote', panel('warning', said('Inner.')))])
|
assert.deepEqual(read('> > [!WARNING]\n> > Inner.\n'), [bare('blockquote', panel('warning', said('Inner.')))])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -119,27 +115,27 @@ test('reads a folded callout to an expand titled by the rest of its marker line,
|
|||||||
node('expand', { title: 'Why?' }, paragraph(text('See '), text('the docs', link), text(', '), text('now', strong), text('.'))),
|
node('expand', { title: 'Why?' }, paragraph(text('See '), text('the docs', link), text(', '), text('now', strong), text('.'))),
|
||||||
])
|
])
|
||||||
assert.deepEqual(read('> [!NOTE]- Two\\\n> lines\n'), [node('expand', { title: 'Two' }, said('lines'))])
|
assert.deepEqual(read('> [!NOTE]- Two\\\n> lines\n'), [node('expand', { title: 'Two' }, said('lines'))])
|
||||||
assert.deepEqual(read('> [!faq]- See [x](http://y)\n'), blocks(node('expand', { title: 'See x (http://y)' }, paragraph())))
|
assert.deepEqual(read('> [!faq]- See [x](http://y)\n'), [node('expand', { title: 'See x (http://y)' }, paragraph())])
|
||||||
assert.deepEqual(read('> [!faq]- [a **b**](u "t")[c](u) and [d](v)\n'), blocks(node('expand', { title: 'a bc (u) and d (v)' }, paragraph())))
|
assert.deepEqual(read('> [!faq]- [a **b**](u "t")[c](u) and [d](v)\n'), [node('expand', { title: 'a bc (u) and d (v)' }, paragraph())])
|
||||||
assert.deepEqual(read('> [!faq]- <http://y> or <a@b.c> or <http://a\\b>\n'), blocks(node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph())))
|
assert.deepEqual(read('> [!faq]- <http://y> or <a@b.c> or <http://a\\b>\n'), [node('expand', { title: 'http://y or a@b.c or http://a\\b' }, paragraph())])
|
||||||
assert.deepEqual(read('> [!faq]- [a b](u)\n'), blocks(node('expand', { title: 'a\nb (u)' }, paragraph())))
|
assert.deepEqual(read('> [!faq]- [a b](u)\n'), [node('expand', { title: 'a\nb (u)' }, paragraph())])
|
||||||
assert.deepEqual(read('> [!faq]- [!adf:mention[@M]{id=5}](u) !adf:inlineCard{url="http://y"}\n'), blocks(node('expand', { title: '@M (u) http://y' }, paragraph())))
|
assert.deepEqual(read('> [!faq]- [!adf:mention[@M]{id=5}](u) !adf:inlineCard{url="http://y"}\n'), [node('expand', { title: '@M (u) http://y' }, paragraph())])
|
||||||
assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), blocks(node('expand', { title: 'Set ==x== here' }, paragraph())))
|
assert.deepEqual(read('> [!NOTE]- Set ==x== here\n'), [node('expand', { title: 'Set ==x== here' }, paragraph())])
|
||||||
assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))])
|
assert.deepEqual(read('> [!NOTE]-\n>\n> Line.\n'), [bare('expand', said('Line.'))])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('reads a folded callout inside an expand to a nested expand', () => {
|
test('reads a folded callout inside an expand to a nested expand', () => {
|
||||||
const markdown = '> [!NOTE]- Outer\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n>\n> > [!TIP]\n> >\n> > > [!NOTE]-\n'
|
const markdown = '> [!NOTE]- Outer\n>\n> > [!NOTE]- Inner\n> >\n> > Deep.\n>\n> > [!TIP]\n> >\n> > > [!NOTE]-\n'
|
||||||
assert.deepEqual(read(markdown), blocks(node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph())))))
|
assert.deepEqual(read(markdown), [node('expand', { title: 'Outer' }, node('nestedExpand', { title: 'Inner' }, said('Deep.')), panel('tip', bare('nestedExpand', paragraph())))])
|
||||||
assert.deepEqual(read('- > [!NOTE]-\n'), blocks(bare('bulletList', bare('listItem', bare('expand', paragraph())))))
|
assert.deepEqual(read('- > [!NOTE]-\n'), [bare('bulletList', bare('listItem', bare('expand', paragraph())))])
|
||||||
assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), blocks(bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph()))))
|
assert.deepEqual(read('!adf:expand\n> [!NOTE]- Inner\n!adf:/expand\n'), [bare('expand', node('nestedExpand', { title: 'Inner' }, paragraph()))])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('reads a bullet list whose every item leads with a task marker to a task list', () => {
|
test('reads a bullet list whose every item leads with a task marker to a task list', () => {
|
||||||
assert.deepEqual(read('- [x] Write the spec\n- [ ] Ship **it**\n- [X] Tell\n'), [
|
assert.deepEqual(read('- [x] Write the spec\n- [ ] Ship **it**\n- [X] Tell\n'), [
|
||||||
bare('taskList', task('DONE', text('Write the spec')), task('TODO', text('Ship '), text('it', strong)), task('DONE', text('Tell'))),
|
bare('taskList', task('DONE', text('Write the spec')), task('TODO', text('Ship '), text('it', strong)), task('DONE', text('Tell'))),
|
||||||
])
|
])
|
||||||
assert.deepEqual(read('- [x]\n- [ ]\\\n after\n'), blocks(bare('taskList', task('DONE'), task('TODO', text('after')))))
|
assert.deepEqual(read('- [x]\n- [ ]\\\n after\n'), [bare('taskList', task('DONE'), task('TODO', text('after')))])
|
||||||
const minted = plainMarkdownToAdf('- [x] Parent\n - [ ] Child\n')
|
const minted = plainMarkdownToAdf('- [x] Parent\n - [ ] Child\n')
|
||||||
assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [
|
assert.deepEqual(minted.ok ? minted.value.content : minted.error.code, [
|
||||||
node(
|
node(
|
||||||
@@ -201,7 +197,7 @@ test('reads what the reduction wrote back to the node it reduced, less the attri
|
|||||||
assert.deepEqual(roundTripped(node('panel', { localId, panelType }, said('Check.'))), [panel(panelType, said('Check.'))], panelType)
|
assert.deepEqual(roundTripped(node('panel', { localId, panelType }, said('Check.'))), [panel(panelType, said('Check.'))], panelType)
|
||||||
}
|
}
|
||||||
const expand = node('expand', { localId, title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))
|
const expand = node('expand', { localId, title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))
|
||||||
assert.deepEqual(roundTripped(node('panel', { panelType: 'tip' }, paragraph()), node('expand', { title: 'Empty' }, paragraph())), blocks(panel('tip', paragraph()), node('expand', { title: 'Empty' }, paragraph())))
|
assert.deepEqual(roundTripped(node('panel', { panelType: 'tip' }, paragraph()), node('expand', { title: 'Empty' }, paragraph())), [panel('tip', paragraph()), node('expand', { title: 'Empty' }, paragraph())])
|
||||||
assert.deepEqual(roundTripped(expand), [node('expand', { title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))])
|
assert.deepEqual(roundTripped(expand), [node('expand', { title: 'Log' }, said('Line.'), node('nestedExpand', { title: 'Inner' }, said('Deep.')))])
|
||||||
const tasks = bare(
|
const tasks = bare(
|
||||||
'taskList',
|
'taskList',
|
||||||
@@ -268,5 +264,5 @@ test('keeps what markdownToAdf reads that no row reads, and refuses only what it
|
|||||||
|
|
||||||
test('joins a carried text node to no neighbour, inside a highlight too', () => {
|
test('joins a carried text node to no neighbour, inside a highlight too', () => {
|
||||||
const carried = '!adf:carry{json="{\\"marks\\":[],\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}'
|
const carried = '!adf:carry{json="{\\"marks\\":[],\\"text\\":\\"b\\",\\"type\\":\\"text\\"}"}'
|
||||||
assert.deepEqual(read(`==a${carried}==\n`), blocks(paragraph(text('a', highlight), { marks: [], text: 'b', type: 'text' })))
|
assert.deepEqual(read(`==a${carried}==\n`), [paragraph(text('a', highlight), { marks: [], text: 'b', type: 'text' })])
|
||||||
})
|
})
|
||||||
|
|||||||
+12
-5
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
import { adfToPlainMarkdown, reduceToPlain } from './plain-reduction.ts'
|
import { adfToPlainMarkdown, reduceToPlain } from './adf-to-plain-markdown.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
|
|
||||||
const code: AdfMark = { type: 'code' }
|
const code: AdfMark = { type: 'code' }
|
||||||
@@ -313,12 +313,13 @@ test('breaks a line at a newline and trims whitespace at every edge CommonMark s
|
|||||||
assert.equal(plain(paragraph(text(' x ', code))), '` x `\n')
|
assert.equal(plain(paragraph(text(' x ', code))), '` x `\n')
|
||||||
assert.equal(plain(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n')
|
assert.equal(plain(node('heading', { level: 1 }, text(' h\ni '))), '# h i\n')
|
||||||
assert.equal(plain(paragraph(text('a\r\u0000b'))), 'ab\n')
|
assert.equal(plain(paragraph(text('a\r\u0000b'))), 'ab\n')
|
||||||
|
assert.equal(plain(paragraph(text('x', link('/u', 't\r\u0000')))), '[x](/u "t")\n')
|
||||||
|
assert.equal(plain(paragraph({ content: [text('dropped')], text: 'a', type: 'text' }, text('b'))), 'ab\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('drops the code mark of a span opening a line with backticks that read as a fence', () => {
|
test('keeps a code span opening a line, whose backticks open no fence', () => {
|
||||||
assert.equal(plain(paragraph(text('``` x', code))), '\\`\\`\\` x\n')
|
assert.equal(plain(paragraph(text('``` x', code))), '```` ``` x ````\n')
|
||||||
assert.equal(plain(paragraph(text('a\n'), text('``` x', code))), 'a\\\n\\`\\`\\` x\n')
|
assert.equal(plain(paragraph(text('a\n'), text('``` x', code))), 'a\\\n```` ``` x ````\n')
|
||||||
assert.equal(plain(paragraph(text('a '), text('``` x', code))), 'a ```` ``` x ````\n')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('drops an empty paragraph and merges adjacent lists of one type', () => {
|
test('drops an empty paragraph and merges adjacent lists of one type', () => {
|
||||||
@@ -344,3 +345,9 @@ test('writes what the editor-normal form of the document writes', () => {
|
|||||||
assert.equal(plain({ content: [], text: 'hi', type: 'futureInline' }), 'hi\n')
|
assert.equal(plain({ content: [], text: 'hi', type: 'futureInline' }), 'hi\n')
|
||||||
assert.equal(plain(paragraph({ marks: [{ attrs: {}, type: 'code' }], text: 'a', type: 'text' }, { marks: [{ type: 'code' }], text: '|b', type: 'text' })), '`a|b`\n')
|
assert.equal(plain(paragraph({ marks: [{ attrs: {}, type: 'code' }], text: 'a', type: 'text' }, { marks: [{ type: 'code' }], text: '|b', type: 'text' })), '`a|b`\n')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('writes a text node holding content the same in either order beside its neighbour', () => {
|
||||||
|
const holding: AdfNode = { content: [text('inner')], text: 'a', type: 'text' }
|
||||||
|
assert.equal(plain(paragraph(holding, text('b'))), 'ab\n')
|
||||||
|
assert.equal(plain(paragraph(text('b'), holding)), 'ba\n')
|
||||||
|
})
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||||
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||||
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from './adf-to-markdown.ts'
|
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
|
||||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './plain-inline.ts'
|
import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './inline-reduction.ts'
|
||||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||||
import { languageSlot } from '../code-language.ts'
|
import { languageSlot } from '../code-language.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { taskMarker } from '../plain-conventions.ts'
|
import { taskMarker } from './conventions.ts'
|
||||||
import { toEditorNormal } from '../../adf/editor-normal.ts'
|
import { toEditorNormal } from './editor-normal.ts'
|
||||||
|
|
||||||
// depth: the level the node reduced stands at, counted as the emitter counts it.
|
// depth: the level the node reduced stands at, counted as the emitter counts it.
|
||||||
type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
|
type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath }
|
||||||
@@ -54,11 +54,11 @@ export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
|
|||||||
// docs/decisions.md §Equality is deep: the reduction reads and writes editor-normal ADF.
|
// docs/decisions.md §Equality is deep: the reduction reads and writes editor-normal ADF.
|
||||||
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
|
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
|
||||||
if (!blocks.ok) {
|
if (!blocks.ok) {
|
||||||
// Merging text renumbers siblings, so the caller's document names the path of the refusal the editor-normal one meets.
|
// Merging text renumbers siblings, so a refusal's path comes from the caller's document.
|
||||||
const raw = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
|
const raw = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
|
||||||
return !raw.ok && raw.error.code === blocks.error.code ? raw : blocks
|
return !raw.ok && raw.error.code === blocks.error.code ? raw : blocks
|
||||||
}
|
}
|
||||||
return success({ content: nodeContent(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 })).slice(), type: 'doc', version: 1 })
|
return success(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
|
function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isWordCharacter } from './commonmark/emphasis-matching.ts'
|
import { isWordCharacter } from '../commonmark/emphasis-matching.ts'
|
||||||
|
|
||||||
export type Flavour = 'lossless' | 'plain'
|
export type Flavour = 'lossless' | 'plain'
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
import type { AdfNode } from './document.ts'
|
import type { AdfNode } from '../../adf/document.ts'
|
||||||
import type { JsonValue } from '../json-value.ts'
|
import type { JsonValue } from '../../json-value.ts'
|
||||||
import { toEditorNormal } from './editor-normal.ts'
|
import { toEditorNormal } from './editor-normal.ts'
|
||||||
|
|
||||||
test('merges adjacent text nodes carrying identical marks, at every level', () => {
|
test('merges adjacent text nodes carrying identical marks, at every level', () => {
|
||||||
@@ -50,14 +50,15 @@ test('reads negative zero as zero, as JSON does', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('reads an empty attrs object, marks array or content array as the absent key', () => {
|
test('reads an empty attrs object, marks array or content array as the absent key, except on doc', () => {
|
||||||
const paragraph: AdfNode = { attrs: {}, content: [{ attrs: {}, marks: [], text: 'a', type: 'text' }, { marks: [{ attrs: {}, type: 'em' }], text: 'b', type: 'text' }], marks: [], type: 'paragraph' }
|
const paragraph: AdfNode = { attrs: {}, content: [{ attrs: {}, marks: [], text: 'a', type: 'text' }, { marks: [{ attrs: {}, type: 'em' }], text: 'b', type: 'text' }], marks: [], type: 'paragraph' }
|
||||||
assert.deepEqual(toEditorNormal({ content: [paragraph, { content: [], type: 'rule' }], type: 'doc', version: 1 }), {
|
assert.deepEqual(toEditorNormal({ content: [paragraph, { content: [], type: 'rule' }], type: 'doc', version: 1 }), {
|
||||||
content: [{ content: [{ text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: 'b', type: 'text' }], type: 'paragraph' }, { type: 'rule' }],
|
content: [{ content: [{ text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: 'b', type: 'text' }], type: 'paragraph' }, { type: 'rule' }],
|
||||||
type: 'doc',
|
type: 'doc',
|
||||||
version: 1,
|
version: 1,
|
||||||
})
|
})
|
||||||
assert.deepEqual(toEditorNormal({ content: [], type: 'doc', version: 1 }), { type: 'doc', version: 1 })
|
assert.deepEqual(toEditorNormal({ content: [], type: 'doc', version: 1 }), { content: [], type: 'doc', version: 1 })
|
||||||
|
assert.deepEqual(toEditorNormal({ type: 'doc', version: 1 }), { type: 'doc', version: 1 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('normalizes blocks and mark attributes nesting far past the levels a recursive walk survives', () => {
|
test('normalizes blocks and mark attributes nesting far past the levels a recursive walk survives', () => {
|
||||||
@@ -75,3 +76,10 @@ test('normalizes blocks and mark attributes nesting far past the levels a recurs
|
|||||||
const merged = toEditorNormal({ content: [{ content: [{ marks, text: 'a', type: 'text' }, { marks, text: 'b', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 })
|
const merged = toEditorNormal({ content: [{ content: [{ marks, text: 'a', type: 'text' }, { marks, text: 'b', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 })
|
||||||
assert.deepEqual(merged.content?.[0]?.content?.map((text) => text.text), ['ab'])
|
assert.deepEqual(merged.content?.[0]?.content?.map((text) => text.text), ['ab'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('drops the content a text node holds, and joins the node as any other', () => {
|
||||||
|
const holding: AdfNode = { content: [{ text: 'dropped', type: 'text' }], text: 'a', type: 'text' }
|
||||||
|
const normal = (content: AdfNode[]): AdfNode[] | undefined => toEditorNormal({ content: [{ content, type: 'paragraph' }], type: 'doc', version: 1 }).content
|
||||||
|
assert.deepEqual(normal([holding, { text: 'b', type: 'text' }]), [{ content: [{ text: 'ab', type: 'text' }], type: 'paragraph' }])
|
||||||
|
assert.deepEqual(normal([{ text: 'b', type: 'text' }, holding]), [{ content: [{ text: 'ba', type: 'text' }], type: 'paragraph' }])
|
||||||
|
})
|
||||||
@@ -1,32 +1,37 @@
|
|||||||
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './document.ts'
|
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
import type { JsonValue } from '../json-value.ts'
|
import type { JsonValue } from '../../json-value.ts'
|
||||||
import { identicalMark, identicalMarks, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from './document.ts'
|
import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||||
|
import { joinsWhenRead } from '../adjacent-text.ts'
|
||||||
|
|
||||||
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
|
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
|
||||||
|
|
||||||
type NodeHolder = { content?: AdfNode[] }
|
type NodeHolder = { content?: AdfNode[] }
|
||||||
|
|
||||||
export function sameMark(left: AdfMark, right: AdfMark): boolean {
|
// The editor joins two text nodes where the reader would join their editor-normal forms.
|
||||||
return identicalMark(normalMark(left), normalMark(right))
|
export function joinsWhenEditorNormal(previous: AdfNode, node: AdfNode): boolean {
|
||||||
|
if (previous.type !== 'text' || node.type !== 'text') return false
|
||||||
|
return joinsWhenRead(normalNode(previous), normalNode(node))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function joinsNormally(previous: AdfNode, node: AdfNode): boolean {
|
export function sameMarkWhenEditorNormal(left: AdfMark, right: AdfMark): boolean {
|
||||||
if (!mergesText(previous) || !mergesText(node)) return false
|
return identicalMark(normalMark(left), normalMark(right))
|
||||||
return identicalMarks(nodeMarks(previous).map(normalMark), nodeMarks(node).map(normalMark))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toEditorNormal(document: AdfDocument): AdfDocument {
|
export function toEditorNormal(document: AdfDocument): AdfDocument {
|
||||||
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
|
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
|
||||||
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
|
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
|
||||||
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
|
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
|
||||||
const content = mergeAdjacentText(nodeContent(entry.source), joinsNormally)
|
const content = mergeAdjacentText(nodeContent(entry.source), joinsWhenEditorNormal)
|
||||||
if (content.length === 0) continue
|
if (content.length === 0) continue
|
||||||
entry.holder.content = content.map((source) => {
|
entry.holder.content = content.map((source) => {
|
||||||
const holder = normalNode(source)
|
const holder = normalNode(source)
|
||||||
pending.push({ holder, source })
|
// Atlassian's schema forbids content on a text node, so the editor drops it.
|
||||||
|
if (source.type !== 'text') pending.push({ holder, source })
|
||||||
return holder
|
return holder
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// ADF's schema requires content on doc, so an empty one stays.
|
||||||
|
if (document.content !== undefined && normal.content === undefined) normal.content = []
|
||||||
return normal
|
return normal
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +68,3 @@ function normalValue(value: JsonValue, pending: JsonContainer[]): JsonValue {
|
|||||||
pending.push(copy)
|
pending.push(copy)
|
||||||
return copy
|
return copy
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergesText(node: AdfNode): boolean {
|
|
||||||
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
|
import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
import type { LineContainer } from '../line-container.ts'
|
import type { LineContainer } from '../line-container.ts'
|
||||||
import type { MarkRun } from './line-escaping.ts'
|
import type { MarkRun } from '../emit/line-escaping.ts'
|
||||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
import { joinsNormally, sameMark } from '../../adf/editor-normal.ts'
|
import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||||
import { plainLineFallback, type PlainLineFallback } from './inline-line.ts'
|
import { plainLineFallback, type PlainLineFallback } from '../emit/inline-line.ts'
|
||||||
import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
|
import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
|
||||||
|
|
||||||
const highlight = 'backgroundColor'
|
const highlight = 'backgroundColor'
|
||||||
@@ -149,7 +149,7 @@ function plainMarks(marks: readonly AdfMark[], container: LineContainer, text: s
|
|||||||
function plainLink(mark: AdfMark, container: LineContainer): AdfMark | undefined {
|
function plainLink(mark: AdfMark, container: LineContainer): AdfMark | undefined {
|
||||||
const attrs = nodeAttrs(mark)
|
const attrs = nodeAttrs(mark)
|
||||||
const held = attrs['href']
|
const held = attrs['href']
|
||||||
const title = typeof attrs['title'] === 'string' ? attrs['title'].replace(/\r/g, '').replace(/\n/g, ' ') : undefined
|
const title = typeof attrs['title'] === 'string' ? attrs['title'].replace(/[\r\0]/g, '').replace(/\n/g, ' ') : undefined
|
||||||
if (typeof held !== 'string') return undefined
|
if (typeof held !== 'string') return undefined
|
||||||
const href = writableHref(container === 'table-cell' ? held.replaceAll('|', '%7C') : held)
|
const href = writableHref(container === 'table-cell' ? held.replaceAll('|', '%7C') : held)
|
||||||
if (title === undefined || spellLinkTarget(href, title) === undefined || (container === 'table-cell' && title.includes('|'))) return { attrs: { href }, type: 'link' }
|
if (title === undefined || spellLinkTarget(href, title) === undefined || (container === 'table-cell' && title.includes('|'))) return { attrs: { href }, type: 'link' }
|
||||||
@@ -175,11 +175,11 @@ function highlighted(leaves: readonly AdfNode[]): AdfNode[] {
|
|||||||
const marks = nodeMarks(leaf)
|
const marks = nodeMarks(leaf)
|
||||||
if (marks[0]?.type === highlight) {
|
if (marks[0]?.type === highlight) {
|
||||||
const held = marks.slice(1)
|
const held = marks.slice(1)
|
||||||
shared = run.length === 0 ? held : shared.filter((mark) => held.some((other) => sameMark(other, mark)))
|
shared = run.length === 0 ? held : shared.filter((mark) => held.some((other) => sameMarkWhenEditorNormal(other, mark)))
|
||||||
run.push(leaf)
|
run.push(leaf)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for (const held of run) spelled.push(withMarks(held, [...shared, highlightMark, ...nodeMarks(held).slice(1).filter((mark) => !shared.some((other) => sameMark(other, mark)))]))
|
for (const held of run) spelled.push(withMarks(held, [...shared, highlightMark, ...nodeMarks(held).slice(1).filter((mark) => !shared.some((other) => sameMarkWhenEditorNormal(other, mark)))]))
|
||||||
run = []
|
run = []
|
||||||
spelled.push(leaf)
|
spelled.push(leaf)
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,7 @@ function withMarks(leaf: AdfNode, marks: readonly AdfMark[]): AdfNode {
|
|||||||
|
|
||||||
function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] {
|
function trimmedEdges(leaves: readonly AdfNode[]): AdfNode[] {
|
||||||
for (let current = leaves; ; ) {
|
for (let current = leaves; ; ) {
|
||||||
const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsNormally))
|
const merged = withoutEdgeBreaks(mergeAdjacentText(current, joinsWhenEditorNormal))
|
||||||
let changed = false
|
let changed = false
|
||||||
const trimmed: AdfNode[] = []
|
const trimmed: AdfNode[] = []
|
||||||
for (const [index, leaf] of merged.entries()) {
|
for (const [index, leaf] of merged.entries()) {
|
||||||
@@ -251,7 +251,7 @@ function edgeDepth(marks: readonly AdfMark[], neighbour: AdfNode | undefined, wh
|
|||||||
function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index: number): boolean {
|
function sameMarkAt(marks: readonly AdfMark[], others: readonly AdfMark[], index: number): boolean {
|
||||||
const mark = marks[index]
|
const mark = marks[index]
|
||||||
const other = others[index]
|
const other = others[index]
|
||||||
return mark !== undefined && other !== undefined && sameMark(mark, other)
|
return mark !== undefined && other !== undefined && sameMarkWhenEditorNormal(mark, other)
|
||||||
}
|
}
|
||||||
|
|
||||||
function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<AdfNode[]> {
|
function spellableLine(leaves: AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<AdfNode[]> {
|
||||||
@@ -267,21 +267,13 @@ function spellableLine(leaves: AdfNode[], container: LineContainer, path: Conver
|
|||||||
|
|
||||||
function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback): AdfNode[] | undefined {
|
function withoutFallback(leaves: readonly AdfNode[], fallback: PlainLineFallback): AdfNode[] | undefined {
|
||||||
if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, fallback.runs)
|
if (fallback.kind === 'unspellable-run') return withoutMarks(leaves, fallback.runs)
|
||||||
const first = fallback.kind === 'opening-link' ? 0 : lineStart(leaves, fallback.line)
|
const mark = nodeMarks(leaves[0] ?? {})[0]
|
||||||
const mark = nodeMarks(leaves[first] ?? {})[0]
|
if (mark === undefined || mark.type !== 'link') return undefined
|
||||||
if (mark === undefined || mark.type !== (fallback.kind === 'opening-link' ? 'link' : 'code')) return undefined
|
let last = 0
|
||||||
let last = first
|
|
||||||
while (sameMarkAt(nodeMarks(leaves[last + 1] ?? {}), [mark], 0)) last += 1
|
while (sameMarkAt(nodeMarks(leaves[last + 1] ?? {}), [mark], 0)) last += 1
|
||||||
// A code span is what binds the `]` a link definition reads, and dropping it keeps the link target.
|
// A code span is what binds the `]` a link definition reads, and dropping it keeps the link target.
|
||||||
const spans = leaves.slice(first, last + 1).some((leaf) => nodeMarks(leaf).length > 1 && nodeMarks(leaf).at(-1)?.type === 'code')
|
const spans = leaves.slice(0, last + 1).some((leaf) => nodeMarks(leaf).length > 1 && nodeMarks(leaf).at(-1)?.type === 'code')
|
||||||
if (mark.type === 'link' && spans) return leaves.map((leaf, index) => (index < first || index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code'))))
|
return spans ? leaves.map((leaf, index) => (index > last ? leaf : withMarks(leaf, nodeMarks(leaf).filter((held) => held.type !== 'code')))) : undefined
|
||||||
return withoutMarks(leaves, [{ depth: 0, first, last }])
|
|
||||||
}
|
|
||||||
|
|
||||||
function lineStart(leaves: readonly AdfNode[], line: number): number {
|
|
||||||
let index = 0
|
|
||||||
for (let breaks = 0; breaks < line && index < leaves.length; index += 1) if (leaves[index]?.type === 'hardBreak') breaks += 1
|
|
||||||
return index
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The runs cover disjoint leaves, so one pass drops them all.
|
// The runs cover disjoint leaves, so one pass drops them all.
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { AdfNode } from '../adf/document.ts'
|
|
||||||
import { identicalMarks, isPlainText, nodeMarks } from '../adf/document.ts'
|
|
||||||
import { spellInlineLeafDirective } from './directive-syntax.ts'
|
|
||||||
|
|
||||||
export const textBreakName = 'textBreak'
|
|
||||||
|
|
||||||
export const textBreakSpelling = spellInlineLeafDirective(textBreakName, '')
|
|
||||||
|
|
||||||
// Whether CommonMark reads the pair back as one text node, where neither rides the carry (spec/flavour.md, Inline nodes).
|
|
||||||
export function readsAsOne(previous: AdfNode, node: AdfNode): boolean {
|
|
||||||
return isPlainText(previous) && isPlainText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node))
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import type { DirectiveSpan, Read } from './directive-syntax.ts'
|
|||||||
import { readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
|
import { readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
|
||||||
|
|
||||||
const name = 'text'
|
const name = 'text'
|
||||||
const whitespaceRun = /^(?:[ \t]+|\n+)$/
|
const spelledRun = /^(?:[ \t]+|\n+|\0+)$/
|
||||||
|
|
||||||
export const textDirectiveName = name
|
export const textDirectiveName = name
|
||||||
|
|
||||||
@@ -14,6 +14,6 @@ export function readTextDirective(span: DirectiveSpan): Read<string> | undefined
|
|||||||
if (span.name !== name) return undefined
|
if (span.name !== name) return undefined
|
||||||
const spelled = readSoleStringAttribute(span, name)
|
const spelled = readSoleStringAttribute(span, name)
|
||||||
if (spelled.fault !== undefined) return spelled
|
if (spelled.fault !== undefined) return spelled
|
||||||
if (!whitespaceRun.test(spelled.value)) return { fault: unsupportedNodeShape(`${name} spells one run of spaces and tabs, or one run of newlines: this one spells neither`) }
|
if (!spelledRun.test(spelled.value)) return { fault: unsupportedNodeShape(`${name} spells one run of spaces and tabs, one run of newlines, or one run of null characters: this one spells none of them`) }
|
||||||
return spelled
|
return spelled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ export type ConvertErrorCode =
|
|||||||
| 'unknown-directive-name'
|
| 'unknown-directive-name'
|
||||||
| 'unmappable-html'
|
| 'unmappable-html'
|
||||||
| 'unmappable-image'
|
| 'unmappable-image'
|
||||||
| 'unspellable-character'
|
|
||||||
| 'unspellable-line-start'
|
|
||||||
| 'unspellable-whitespace'
|
| 'unspellable-whitespace'
|
||||||
| 'unsupported-document-version'
|
| 'unsupported-document-version'
|
||||||
| 'unsupported-nesting-depth'
|
| 'unsupported-nesting-depth'
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
`Bar = 9`
|
`Bar = 9`
|
||||||
|
|
||||||
`Next ID = 55`
|
`Next ID = 74`
|
||||||
|
|
||||||
| Goal | W |
|
| Goal | W |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -24,34 +24,56 @@
|
|||||||
|
|
||||||
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 3 | 25.8 |
|
| 47 | 0.2.0 | | **Open the README with what the package is, what it does and for whom.** | 1 | 4 | 7 | 9 | 3 | 26.0 |
|
||||||
|
| 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 4 | 25.8 |
|
||||||
| 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
|
| 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
|
||||||
| 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 3 | 24.7 |
|
| 67 | 0.2.0 | defect | **Push an expand title's leaves one at a time, so a title carrying many text nodes returns a result.** | 1 | 1 | 4 | 9 | 1 | 24.8 |
|
||||||
| 43 | 0.2.0 | | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 3, 4 | 22.3 |
|
| 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 4 | 24.7 |
|
||||||
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.** | 4 | 5 | 5 | 8 | 3, 4 | 17.2 |
|
| 64 | 0.2.0 | defect | **Read back unchanged on V8 every JSON key the emitter writes, with a fixture whose key holds `\`, `"` or a control character.** | 4 | 4 | 6 | 8 | 1, 8 | 23.0 |
|
||||||
| 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 5 | 13.0 |
|
| 66 | 0.2.0 | defect | **Refuse an attribute value that is not a plain object, array or JSON primitive.** | 3 | 2 | 5 | 8 | 1 | 22.5 |
|
||||||
| 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 3, 4 | 12.4 |
|
| 71 | 0.2.0 | | **Add a README table comparing the package with the other ADF converters, each cell checked against that package's own docs or behaviour.** | 1 | 3 | 6 | 7 | 3 | 21.2 |
|
||||||
| 54 | 0.2.0 | | **Make `docs/decisions.md` §The source parts by ADF and format name every import `parse/` takes from `emit/`.** | 2 | 2 | 2 | 5 | 1 | 11.5 |
|
| 43 | 0.2.0 | decision | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 4, 5 | 20.3 |
|
||||||
| 53 | 0.2.0 | | **Bench a block's `marks` spelling with the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 5 | 11.5 |
|
| 68 | 0.2.0 | | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 3 | 3 | 4 | 7 | 1 | 18.2 |
|
||||||
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 3, 4, 6 | 10.4 |
|
| 65 | 0.2.0 | defect | **Read an image not alone in its paragraph as its alt text linked to its URL, and a titled image alone in its paragraph as the image captioned with its title.** | 5 | 5 | 7 | 7 | 4, 5, 7 | 17.1 |
|
||||||
|
| 70 | 0.2.0 | | **Give `package.json` the `keywords` someone searching npm for an ADF converter types.** | 1 | 1 | 4 | 6 | 3 | 16.1 |
|
||||||
|
| 72 | 0.2.0 | | **Make GitHub the canonical repository, with `gitea.larvit.se/larvit/adf-codec` a read-only pull mirror of it.** | 5 | 6 | 5 | 8 | 3 | 16.0 |
|
||||||
|
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`.** | 4 | 5 | 5 | 8 | 4, 5 | 15.5 |
|
||||||
|
| 46 | 0.2.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 3, 8 | 14.8 |
|
||||||
|
| 61 | 0.2.0 | decision | **Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.** | 6 | 8 | 3 | 8 | 1 | 14.0 |
|
||||||
|
| 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 6 | 11.5 |
|
||||||
|
| 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 4, 5 | 10.9 |
|
||||||
|
| 60 | 0.2.0 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 2 | 6 | 2 | 10.7 |
|
||||||
|
| 59 | 0.2.0 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` to `markdown/flavour.ts`.** | 3 | 5 | 2 | 6 | 2 | 10.4 |
|
||||||
|
| 53 | 0.2.0 | | **Put a block's `marks` spelling to the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 6 | 10.0 |
|
||||||
|
| 62 | 0.2.0 | decision | **Move the plain flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/plain/`, so `emit/` no longer imports `plain/`.** | 4 | 5 | 2 | 6 | 2 | 9.4 |
|
||||||
|
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 4, 5, 7 | 9.0 |
|
||||||
|
| 73 | 0.3.0 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 6 | 8 | 3 | 23.0 |
|
||||||
|
| 9 | 0.3.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 6 | 8 | 3 | 21.0 |
|
||||||
| 38 | 0.3.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 4 | 7 | 1 | 19.5 |
|
| 38 | 0.3.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 4 | 7 | 1 | 19.5 |
|
||||||
| 47 | 0.3.0 | | **Open the README with what the package is, what it does and for whom.** | 1 | 4 | 7 | 9 | 9 | 14.0 |
|
| 34 | 0.3.0 | | **Read emphasis flanking by the whole character beside an astral symbol.** | 2 | 3 | 3 | 6 | 4, 5 | 11.3 |
|
||||||
| 34 | 0.3.0 | | **Read emphasis flanking by the whole character beside an astral symbol.** | 2 | 3 | 3 | 6 | 3, 4 | 12.6 |
|
|
||||||
| 42 | 0.3.0 | | **Trim a text leaf's trailing blanks in linear time.** | 1 | 2 | 5 | 9 | 8 | 12.5 |
|
|
||||||
| 48 | 0.3.0 | | **Keep the release path publishing past npm's bypass-2FA token retirement.** | 4 | 4 | 8 | 3 | 9 | 11.7 |
|
|
||||||
| 31 | 0.3.0 | | **Make the branch-coverage figure repeat across runs of an unchanged tree.** | 2 | 3 | 3 | 4 | 1 | 11.2 |
|
| 31 | 0.3.0 | | **Make the branch-coverage figure repeat across runs of an unchanged tree.** | 2 | 3 | 3 | 4 | 1 | 11.2 |
|
||||||
| 33 | 0.3.0 | | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 8 | 10.7 |
|
| 42 | 0.3.0 | defect | **Trim a text leaf's trailing blanks in linear time.** | 1 | 2 | 5 | 9 | 9 | 10.5 |
|
||||||
| 46 | 0.3.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 7, 9 | 10.3 |
|
| 33 | 0.3.0 | defect | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 9 | 8.7 |
|
||||||
| 9 | 0.3.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 6 | 8 | 9 | 10.3 |
|
| 56 | 0.3.0 | principle | **Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.** | 4 | 5 | 2 | 4 | 1 | 6.8 |
|
||||||
| 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 9 | 10.6 |
|
| 57 | 0.3.0 | principle | **Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.** | 2 | 3 | 1 | 3 | 8 | 0.6 |
|
||||||
|
| 58 | 0.3.0 | principle | **Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.** | 4 | 6 | 1 | 2 | 8 | -2.6 |
|
||||||
|
| 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 3 | 18.6 |
|
||||||
|
|
||||||
## Details
|
## Details
|
||||||
|
|
||||||
|
### 47. Open the README with what the package is, what it does and for whom.
|
||||||
|
|
||||||
|
Lands after item 72: the badges are npm's version and the GitHub Actions status. Today the README
|
||||||
|
opens with the pre-launch rationale — Atlassian's REST APIs, `pf-editor-service/convert` being
|
||||||
|
decommissioned, a link to JRACLOUD-77436. The background goes entirely, no endpoint, ticket or "why"
|
||||||
|
note left. The README names the lossy pair, `adfToPlainMarkdown` and `plainMarkdownToAdf`, and the
|
||||||
|
flavours it writes and reads — GitHub Flavored Markdown's alerts and task lists, Obsidian Flavored
|
||||||
|
Markdown's callouts — so a search for any of these names finds the package.
|
||||||
|
|
||||||
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.
|
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.
|
||||||
|
|
||||||
Lands after item 6. The CommonMark spec suite also runs against `markdownToHtml`. The README
|
Lands after items 6, 59, 60, 61 and 62. The CommonMark spec suite also runs against
|
||||||
documents HTML as it documents markdown, and its tagline and `package.json`'s `description` regain
|
`markdownToHtml`. The README documents HTML as it documents markdown.
|
||||||
HTML.
|
|
||||||
|
|
||||||
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
|
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
|
||||||
|
|
||||||
@@ -59,12 +81,44 @@ Goal 1 has every call return a result; the boolean guard is the one export that
|
|||||||
cannot say which branch refused, where `not-an-adf-document`'s message already does. Breaking:
|
cannot say which branch refused, where `not-an-adf-document`'s message already does. Breaking:
|
||||||
`MIGRATION.md` shows the guard's replacement.
|
`MIGRATION.md` shows the guard's replacement.
|
||||||
|
|
||||||
|
### 67. Push an expand title's leaves one at a time, so a title carrying many text nodes returns a result.
|
||||||
|
|
||||||
|
`quoteNode`'s title loop (`parse/markdown-to-adf.ts`) spreads `inlineLeaves`'s result into
|
||||||
|
`push`, against `docs/decisions.md` §Nothing spreads an unbounded array: an inline carry in a
|
||||||
|
folded callout's title holding an unknown node with ~125k text children throws a `RangeError` from
|
||||||
|
`plainMarkdownToAdf`. Found by the README-goals audit, 2026-10-04.
|
||||||
|
|
||||||
### 6. Specify the HTML dialect.
|
### 6. Specify the HTML dialect.
|
||||||
|
|
||||||
Element-by-element mapping, the `data-*` fidelity scheme, the opaque-carry form, and the documented
|
Element-by-element mapping, the `data-*` fidelity scheme, the opaque-carry form, and the documented
|
||||||
foreign-element set `htmlToAdf` accepts — the set `markdownToAdf` shares (`spec/flavour.md` §Raw
|
foreign-element set `htmlToAdf` accepts — the set `markdownToAdf` shares (`spec/flavour.md` §Raw
|
||||||
HTML in input). The set sorts per `docs/decisions.md` §Foreign HTML sorts three ways.
|
HTML in input). The set sorts per `docs/decisions.md` §Foreign HTML sorts three ways.
|
||||||
|
|
||||||
|
### 64. Read back unchanged on V8 every JSON key the emitter writes, with a fixture whose key holds `\`, `"` or a control character.
|
||||||
|
|
||||||
|
`property-harness.ts` strips `\`, `"` and control characters from generated JSON keys, citing V8's
|
||||||
|
`JSON.parse` returning a wrong key for an escaped backslash. The library reads `json` attributes
|
||||||
|
(`directive-syntax.ts`) and both carries (`opaque-carry.ts`) through that `JSON.parse`, so on Node,
|
||||||
|
Deno and Chrome the round-trip would refuse its own output or read back a different key; the fixture
|
||||||
|
tells which. Confirm with a fixture first; then read JSON with our own parser or record the gap with
|
||||||
|
an ending item. Found by the README-goals audit, 2026-10-03.
|
||||||
|
|
||||||
|
### 66. Refuse an attribute value that is not a plain object, array or JSON primitive.
|
||||||
|
|
||||||
|
`isJsonValue` (`json-value.ts`) walks any object by `Object.values`, so a `Date`, `Map`, `Set` or
|
||||||
|
typed array in `attrs`, as `structuredClone` keeps it, passes the guard and is written as `{}` or a
|
||||||
|
key per element: `ok: true` with content lost. Accept an object whose prototype is
|
||||||
|
`Object.prototype` or `null`, and an array. Found by the stability review of item 63, 2026-10-04.
|
||||||
|
|
||||||
|
### 71. Add a README table comparing the package with the other ADF converters, each cell checked against that package's own docs or behaviour.
|
||||||
|
|
||||||
|
Lands after items 7, 46 and 47: the table names HTML, compares item 46's size figure, and sits in
|
||||||
|
the README item 47 reshapes. Its columns: which directions each converts, whether the round-trip
|
||||||
|
holds, what an unknown node does, the formats, runtime dependencies and size. Candidates are the
|
||||||
|
packages an npm search for ADF and markdown returns, marklassian among them. Every cell cites that
|
||||||
|
package's README or a run against its published version, and the table names each version and the
|
||||||
|
date it was checked. Found while planning discoverability, 2026-10-04.
|
||||||
|
|
||||||
### 43. Give each markdown input its own reader, strict to its own standard.
|
### 43. Give each markdown input its own reader, strict to its own standard.
|
||||||
|
|
||||||
Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a
|
Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a
|
||||||
@@ -73,17 +127,78 @@ a code fence whose info string opens `adf:` becomes the block carry where Common
|
|||||||
caller names the markdown it hands in: CommonMark, read as its spec says, or the lossless flavour,
|
caller names the markdown it hands in: CommonMark, read as its spec says, or the lossless flavour,
|
||||||
read as `spec/flavour.md` says. Breaking: `MIGRATION.md` says which call a caller takes.
|
read as `spec/flavour.md` says. Breaking: `MIGRATION.md` says which call a caller takes.
|
||||||
|
|
||||||
### 49. Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.
|
### 68. Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.
|
||||||
|
|
||||||
|
`refuseContentAndText` (`emit/inline-line.ts`) and `emitDirectiveBlock` (`emit/adf-to-markdown.ts`)
|
||||||
|
refuse these with `unsupported-node-shape`, though `isAdfDocument` accepts them and the carry
|
||||||
|
round-trips them; `docs/decisions.md` §The code list gives a cause the carry answers no code. Drop
|
||||||
|
them from the emitting half of the README's `unsupported-node-shape` row. Found by the README-goals
|
||||||
|
audit, 2026-10-04.
|
||||||
|
|
||||||
|
### 65. Read an image not alone in its paragraph as its alt text linked to its URL, and a titled image alone in its paragraph as the image captioned with its title.
|
||||||
|
|
||||||
|
Both are refused today with `unmappable-image`: 14 examples in
|
||||||
|
`corpus/commonmark-spec/refusals.json`, a bot's `See  here` among them. `mediaInline`
|
||||||
|
takes only Atlassian media ids, never a URL. An image not alone in its paragraph reads as its alt
|
||||||
|
text, linked to its URL. Its title becomes the link's `title`. An empty alt text reads as the URL,
|
||||||
|
as item 50 reads an empty link. A titled image alone in its paragraph reads as the image, with its
|
||||||
|
title as a `caption` (the maintainer, 2026-10-04). By Goal 5, the lossless flavour reads both as
|
||||||
|
CommonMark does. An image inside a link: `[](/uri)` (spec example 517). Alone in
|
||||||
|
its paragraph, it reads as the image with a `link` mark to `/uri`, which ADF's `media` takes. Not
|
||||||
|
alone in its paragraph, the image reads as its alt text linked to `/uri`, and its URL drops. A
|
||||||
|
writer panel chose this reading 3 of 3 (2026-10-04), by Goal 6, which outranks Goal 7.
|
||||||
|
|
||||||
|
### 70. Give `package.json` the `keywords` someone searching npm for an ADF converter types.
|
||||||
|
|
||||||
|
`package.json` has none, and npm's search ranks on them. Take the terms the other ADF converters on
|
||||||
|
npm list and the names the README uses — ADF, Atlassian Document Format, markdown, CommonMark, HTML
|
||||||
|
— so a search for any of them can reach the package. Found while planning discoverability,
|
||||||
|
2026-10-04.
|
||||||
|
|
||||||
|
### 72. Make GitHub the canonical repository, with `gitea.larvit.se/larvit/adf-codec` a read-only pull mirror of it.
|
||||||
|
|
||||||
|
The maintainer's call, 2026-10-04: GitHub is where an ADF converter is searched for, and the Gitea
|
||||||
|
copy keeps the move back open. The workflows move to `.github/workflows/` with `ci.sh` unchanged.
|
||||||
|
Publishing moves to npm's trusted publishing over OIDC, which GitHub-hosted Actions support, and
|
||||||
|
which outlives the bypass-2FA token npm retires around January 2027: `publish.sh` drops `NPM_TOKEN`
|
||||||
|
for the Actions OIDC variables, and AGENTS.md §7 reserves the trusted-publisher setup in place of
|
||||||
|
the secret. `package.json`'s `repository` moves and it gains `bugs` and `homepage`. The links in
|
||||||
|
README, CHANGELOG and MIGRATION follow, as do AGENTS.md's forge commands and merge style.
|
||||||
|
`docs/decisions.md` gains the entry "GitHub is canonical, and Gitea a pull mirror of it" (Goal 3),
|
||||||
|
and §Public on npm and §Publish on a version bump are revised: the source is public on GitHub, and
|
||||||
|
CI publishes through npm's trusted publisher. Gitea's open issues and its releases move to GitHub
|
||||||
|
first, since a mirror carries only git refs; the mirror disables issues and its description points
|
||||||
|
at GitHub. Creating the GitHub repository, configuring npm's trusted publisher, and re-creating the
|
||||||
|
Gitea copy as a mirror (Gitea cannot convert an existing repository) are the maintainer's.
|
||||||
|
|
||||||
|
### 49. Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`.
|
||||||
|
|
||||||
Lands after item 43. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads
|
Lands after item 43. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads
|
||||||
two (spec examples 301 and 302), and so must the CommonMark reader. `spec/flavour.md` merges them in
|
two (spec examples 301 and 302). Goal 5 settles it for the lossless flavour too: CommonMark spells
|
||||||
the lossless flavour on purpose and parts two adjacent lists with `!adf:listBreak`. The chunk
|
adjacent lists by changing the marker, so `adfToMarkdown` and `adfToPlainMarkdown` alternate `-` and
|
||||||
settles by Goals 3 and 4 whether the flavour follows, and asks where the Goals do not decide. That
|
`*` between adjacent bullet lists and `.` and `)` between adjacent ordered lists, and
|
||||||
answer also settles what `adfToMarkdown` and `adfToPlainMarkdown` write for two adjacent lists, and
|
`!adf:listBreak` retires. `*` is the maintainer's pick (2026-10-04). Breaking, so it ships beside
|
||||||
whether `!adf:listBreak` still reads. Breaking, so it ships beside item 43: `MIGRATION.md`'s
|
item 43: `MIGRATION.md`'s Readings and Spellings tables gain their rows. Examples 301 and 302 lose
|
||||||
Readings table gains its row, and its Spellings table one if `!adf:listBreak` retires. Examples 301
|
their `pending` exceptions, and the spelling leaves the README's "Four CommonMark spellings" bullet,
|
||||||
and 302 lose their `pending` exceptions, and the spelling leaves the README's "Four CommonMark
|
which counts one fewer.
|
||||||
spellings" bullet, which counts one fewer.
|
|
||||||
|
### 46. Publish the bundle size in the README, failing the release pipeline when it drifts.
|
||||||
|
|
||||||
|
Lands after item 7, which changes it. The quantity is what a consumer downloads and loads: the
|
||||||
|
tarball `npm pack` produces, its unpacked `dist`, and the built JavaScript minified + gzipped — the
|
||||||
|
figure the competitors advertise (marklassian's "12kb") and the only apples-to-apples one, since
|
||||||
|
ours ships tsc's unminified output and no minifier yet (decide here whether to minify for the build
|
||||||
|
or report the unminified gzip). The figure lands in README §The package beside the "no runtime
|
||||||
|
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6
|
||||||
|
kB.
|
||||||
|
|
||||||
|
### 61. Have the emitter ask the inline reader how a line reads back, in place of `line-escaping.ts` predicting it.
|
||||||
|
|
||||||
|
The comprehension panel's worst place: `escapeClaims` and `escapeClosedRuns` re-implement the
|
||||||
|
reader's view — flanking, code-span closers, link-definition openings, highlight flanking — and
|
||||||
|
only the property tests catch drift. It caps the panel's Locality score. The replacement runs in
|
||||||
|
time linear in the line: today `mergesWithSyntax`, `touchesSyntax` and `closesHeading` rescan a run
|
||||||
|
of one character from each of its characters.
|
||||||
|
|
||||||
### 52. Spell `colwidth` as a comma list, `colwidth="340,420"`.
|
### 52. Spell `colwidth` as a comma list, `colwidth="340,420"`.
|
||||||
|
|
||||||
@@ -97,67 +212,58 @@ example 540); lowercasing and then uppercasing folds it. Breaking, so it ships b
|
|||||||
`MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go, and its spelling leaves
|
`MIGRATION.md`'s Readings table gains its row. Its `pending` exceptions go, and its spelling leaves
|
||||||
the README's "Four CommonMark spellings" bullet, which counts one fewer.
|
the README's "Four CommonMark spellings" bullet, which counts one fewer.
|
||||||
|
|
||||||
### 54. Make `docs/decisions.md` §The source parts by ADF and format name every import `parse/` takes from `emit/`.
|
### 60. Collect the questions `parse/` asks `emit/` into one named module.
|
||||||
|
|
||||||
The entry says `parse/` imports only `commonMarkSpelling` and `openingLinkTakesDirective` from
|
`parse/` asks `emit/` through `commonMarkSpelling` and `openingLinkTakesDirective`, each imported
|
||||||
`emit/`; `parse/markdown-to-adf.ts` also imports `inlineLeaves` from `emit/plain-inline.ts`. Either
|
from where it happens to live. One module naming the questions keeps `docs/decisions.md` §The source
|
||||||
the entry names it with its reason, or the reader stops importing it.
|
parts by ADF and format true as they grow.
|
||||||
|
|
||||||
### 53. Bench a block's `marks` spelling with the writer panel and adopt its pick.
|
### 59. Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` to `markdown/flavour.ts`.
|
||||||
|
|
||||||
|
Eight directive files sit across three directories, and the `markdown/` root holds 14 entries. HTML
|
||||||
|
needs `Read<T>` and `Flavour` out of the plain flavour and `markdown/`; it needs the carry and the
|
||||||
|
mark spellings too, which item 7 moves where it learns what HTML shares.
|
||||||
|
|
||||||
|
### 53. Put a block's `marks` spelling to the writer panel and adopt its pick.
|
||||||
|
|
||||||
Today `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"`, the marks array as
|
Today `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"`, the marks array as
|
||||||
escaped JSON. Breaking where the panel picks another spelling: `MIGRATION.md`'s Spellings table
|
escaped JSON. Breaking where the panel picks another spelling: `MIGRATION.md`'s Spellings table
|
||||||
gains its row.
|
gains its row.
|
||||||
|
|
||||||
|
### 62. Move the plain flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/plain/`, so `emit/` no longer imports `plain/`.
|
||||||
|
|
||||||
|
Reading: the alert and task-marker reads in `parse/markdown-to-adf.ts`, the `mintTaskIds` call and
|
||||||
|
the `inlineLeaves` use. Writing: `spellPlainBlock`, `quotedUnder`, `tryTaskList` and `taskBlocks` in
|
||||||
|
`emit/adf-to-markdown.ts`. And `highlightDelimiter` and `highlightFlanking` move out of
|
||||||
|
`plain/conventions.ts`, which `emit/` imports them from; item 59 moves `Flavour`. Every
|
||||||
|
comprehension reader on 2026-10-03 named the plain flavour's spread across three directories.
|
||||||
|
|
||||||
### 50. Read `[](/url)` and `[]()` as CommonMark's empty link.
|
### 50. Read `[](/url)` and `[]()` as CommonMark's empty link.
|
||||||
|
|
||||||
Both stay literal text today (spec examples 484 and 487). ADF holds no empty text node to carry a
|
Both stay literal text today (spec examples 484 and 487). ADF holds no empty text node to carry a
|
||||||
link mark, so the chunk settles what the empty link builds by Goals 3 and 6, and asks where they do
|
link mark, so a link whose text is empty takes its URL as its text (the maintainer, 2026-10-04):
|
||||||
not decide; whatever it builds, both still parse, since a bot relies on plain CommonMark being valid
|
`[](/url)` reads as `/url` linked to `/url`. `[]()` has no URL to show; the chunk settles it by Goals
|
||||||
input. Breaking, so it ships beside item 43: `MIGRATION.md`'s Readings table gains its row. Its
|
4 and 7. Breaking, so it ships beside item 43: `MIGRATION.md`'s Readings table gains its row. Its
|
||||||
`pending` exceptions go, and its spelling leaves the README's "Four CommonMark spellings" bullet,
|
`pending` exceptions go, and its spelling leaves the README's "Four CommonMark spellings" bullet,
|
||||||
which counts one fewer.
|
which counts one fewer.
|
||||||
|
|
||||||
|
### 73. Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.
|
||||||
|
|
||||||
|
Lands after `0.2.0` is published with items 47, 71 and 72, so each post links the GitHub repository,
|
||||||
|
the comparison and a README that opens with what the package is. We draft each post for the place it
|
||||||
|
goes; the maintainer posts them. Found while planning discoverability, 2026-10-04.
|
||||||
|
|
||||||
### 38. Spell a lone surrogate in a text node so it survives a UTF-8 encode.
|
### 38. Spell a lone surrogate in a text node so it survives a UTF-8 encode.
|
||||||
|
|
||||||
`adfToMarkdown` emits it verbatim, so markdown stored as UTF-8 reads back U+FFFD; attribute values
|
`adfToMarkdown` emits it verbatim, so markdown stored as UTF-8 reads back U+FFFD; attribute values
|
||||||
already escape it.
|
already escape it.
|
||||||
|
|
||||||
### 47. Open the README with what the package is, what it does and for whom.
|
|
||||||
|
|
||||||
It opens with the pre-launch rationale — Atlassian's REST APIs, `pf-editor-service/convert` being
|
|
||||||
decommissioned, a link to JRACLOUD-77436. The background goes entirely, no endpoint, ticket or "why"
|
|
||||||
note left. The badges are npm's version and the Gitea Actions status. The README names the lossy
|
|
||||||
pair, `adfToPlainMarkdown` and `plainMarkdownToAdf`, and the flavours it writes and reads — GitHub
|
|
||||||
Flavored Markdown's alerts and task lists, Obsidian Flavored Markdown's callouts — so a search for
|
|
||||||
any of these names finds the package.
|
|
||||||
|
|
||||||
### 34. Read emphasis flanking by the whole character beside an astral symbol.
|
### 34. Read emphasis flanking by the whole character beside an astral symbol.
|
||||||
|
|
||||||
Check whether `line-escaping.ts`'s `charAt` and the parser's flanking read one UTF-16 unit beside an
|
Check whether `line-escaping.ts`'s `charAt` and the parser's flanking read one UTF-16 unit beside an
|
||||||
astral symbol — a lone surrogate is neither punctuation nor symbol, where CommonMark reads `😀` as
|
astral symbol — a lone surrogate is neither punctuation nor symbol, where CommonMark reads `😀` as
|
||||||
punctuation — and, where they do, read the code point, with a fixture per direction.
|
punctuation — and, where they do, read the code point, with a fixture per direction.
|
||||||
|
|
||||||
### 42. Trim a text leaf's trailing blanks in linear time.
|
|
||||||
|
|
||||||
`plain-inline.ts`'s `leafEdges` finds the trail with an unanchored `/[ \t]*$/`, quadratic in a run
|
|
||||||
of blanks inside one leaf: a paragraph of `a`, 80 000 spaces, `b` takes 6.5 s in
|
|
||||||
`adfToPlainMarkdown`. Scan backward, as the expand title's trim does.
|
|
||||||
|
|
||||||
### 48. Keep the release path publishing past npm's bypass-2FA token retirement.
|
|
||||||
|
|
||||||
Lands after 2027-01-01, or after a release run fails on the token, whichever comes first: the
|
|
||||||
maintainer chose on 2026-10-02 to wait and see whether the retirement bites. It holds back no
|
|
||||||
release: when the rest of its release is done, it moves to the next. `0.1.0` published only once the
|
|
||||||
npm token carried **Bypass 2FA**: the account requiring no 2FA on writes was not enough, and npm
|
|
||||||
answered `EOTP` until the token itself bypassed. npm retires bypass-2FA tokens for direct publishing
|
|
||||||
around January 2027, leaving them `npm stage publish`, which a maintainer approves with 2FA; its
|
|
||||||
replacement — trusted publishing over OIDC — supports GitHub-hosted Actions, GitLab.com's shared
|
|
||||||
runners and CircleCI's cloud, self-hosted runners planned without a date. Revisit: whether npm has
|
|
||||||
added Gitea or self-hosted OIDC, and otherwise whether the release moves to the staged publish —
|
|
||||||
which fits badly with publish-on-merge, and is the maintainer's trade to weigh. The Goals and G
|
|
||||||
cells are provisional: no README goal covers the release path.
|
|
||||||
|
|
||||||
### 31. Make the branch-coverage figure repeat across runs of an unchanged tree.
|
### 31. Make the branch-coverage figure repeat across runs of an unchanged tree.
|
||||||
|
|
||||||
Three Node test legs over one unchanged tree reported `emit/inline-line.ts` at 95.83%, 96.23% and
|
Three Node test legs over one unchanged tree reported `emit/inline-line.ts` at 95.83%, 96.23% and
|
||||||
@@ -169,21 +275,42 @@ moves upward, so the first raise to the measured figure reddens a run that chang
|
|||||||
measurement repeatable, or state the number the floor may be raised to and why it is not the
|
measurement repeatable, or state the number the floor may be raised to and why it is not the
|
||||||
measured one.
|
measured one.
|
||||||
|
|
||||||
|
### 42. Trim a text leaf's trailing blanks in linear time.
|
||||||
|
|
||||||
|
`plain/inline-reduction.ts`'s `leafEdges` finds the trail with an unanchored `/[ \t]*$/`, quadratic
|
||||||
|
in a run of blanks inside one leaf: a paragraph of `a`, 80 000 spaces, `b` takes 6.5 s in
|
||||||
|
`adfToPlainMarkdown`. Scan backward, as the expand title's trim does.
|
||||||
|
|
||||||
### 33. Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.
|
### 33. Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.
|
||||||
|
|
||||||
`adfToMarkdown` spends 23 s on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking
|
`adfToMarkdown` spends 23 s on one paragraph of 2000 × `un` plus `**-r**`: each run its flanking
|
||||||
cannot spell re-emits the whole line before riding the carry, quadratic in the runs, and the plain
|
cannot spell re-emits the whole line before riding the carry, quadratic in the runs, and the plain
|
||||||
reduction's `spellableLine` drops one mark per re-emit the same way. Make both linear.
|
reduction's `spellableLine` drops one mark per re-emit the same way. An inline node whose
|
||||||
|
attributes no spelling writes re-emits the line the same way before riding the carry. Make all
|
||||||
|
three linear.
|
||||||
|
|
||||||
### 46. Publish the bundle size in the README, failing the release pipeline when it drifts.
|
### 56. Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.
|
||||||
|
|
||||||
Lands after item 7, which changes it. The quantity is what a consumer downloads and loads: the
|
Technical principle "One owner per value": the `Walk` record passes through twelve functions that
|
||||||
tarball `npm pack` produces, its unpacked `dist`, and the built JavaScript minified + gzipped — the
|
mutate it and return `void`; `walk.leaf` alone is written in seven places. `ContainerStack` in the
|
||||||
figure the competitors advertise (marklassian's "12kb") and the only apples-to-apples one, since
|
same file shows the shape to follow. `inline-content.ts`'s `Scan` has the same shape: about fifteen
|
||||||
ours ships tsc's unminified output and no minifier yet (decide here whether to minify for the build
|
functions write `pending`, `pieces`, `deactivatedBefore` and `openingSpellableLink` and return
|
||||||
or report the unminified gzip). The figure lands in README §The package beside the "no runtime
|
`void`, and `parseInlineContent` reads a flag `scanInline` leaves on it. The same shape recurs in
|
||||||
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6
|
`mintTaskIds` (`plain/task-ids.ts`), which writes `attrs` on the document `readDocument` built;
|
||||||
kB.
|
`takeFallback` (`emit/inline-line.ts`), which writes the record the next `lineSegments` pass reads;
|
||||||
|
`writeUnpaired`, `markPairings` and `markHighlights` (`inline-content.ts`), whose order changes the
|
||||||
|
output; and `nestIn` (`plain/adf-to-plain-markdown.ts`), which pushes into its caller's array.
|
||||||
|
|
||||||
|
### 57. Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.
|
||||||
|
|
||||||
|
Technical principle "Compose, do not entangle": Deno, Bun and the tests read the install leg's
|
||||||
|
`node_modules`, the floor and consumer legs the pack leg's, and the browser leg whatever `dist/` the
|
||||||
|
build leg left, so a leg run alone can pass on stale output.
|
||||||
|
|
||||||
|
### 58. Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.
|
||||||
|
|
||||||
|
Technical principle "Prefer a standalone Python script over a shell script": `AGENTS.md` §3 teaches
|
||||||
|
bash footguns (`&&` chaining under `||`, `tee /dev/stderr`) the port removes.
|
||||||
|
|
||||||
### 8. Ship a CLI.
|
### 8. Ship a CLI.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user