Compare commits

...

6 Commits

7 changed files with 65 additions and 51 deletions
+27 -23
View File
@@ -42,8 +42,8 @@ adjacent text nodes, an empty `attrs`, `content` or `marks`, and `-0`. Neither s
CommonMark's spelling stays wherever a document holds none of those shapes. The plain reader builds
what is written, as `markdownToAdf` does. Only the plain writer is lossy: its reduction reads and
writes editor-normal ADF — adjacent text nodes of identical marks and no attributes merged, `-0` as
`0`, and an empty `attrs`, `content` or `marks` the absent key, but the doc's `content` — so two
documents 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
@@ -60,16 +60,16 @@ around the leaf. The grammar: `spec/flavour.md` §Inline nodes, **Adjacent text
spells an empty object or array.
An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the
bare value `empty`, so an empty pair stays the node holding no content key. A writer panel chose
the spelling, 5 of 7. The grammar: `spec/flavour.md` §Directives, **Attributes**, and §Marks.
bare value `empty`, so a container opener and closer with nothing between them stays the node
holding no `content` key. A writer panel chose the spelling, 5 of 7. The grammar: `spec/flavour.md`
§Directives, **Attributes**, and §Marks.
## `-0` is spelled `-0`
2026-10-03, the maintainer. Goal 1. Valid while JSON's own serialization writes `-0` as `0`.
`-0` is spelled `-0` wherever the flavour writes a number or a JSON value, since JSON's grammar
reads it back as `-0` and no list marker spells the sign. The grammar: `spec/flavour.md` §Block
nodes and §The CommonMark blocks.
reads it back as `-0`. The grammar: `spec/flavour.md` §Block nodes and §The CommonMark blocks.
## Empty markdown is a document of no blocks
@@ -495,15 +495,18 @@ exits 0 on.
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 6 against the floor of 7. The scores are the
baseline: a later panel may not score lower on any dimension.
A four-seat comprehension panel scored the project under the floor of 7. Its round-5 scores
(2026-10-03) are the baseline: a later panel may not score lower on any dimension.
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior | 6.5 | 5.5 | 6 | 5.5 | 6 |
| Mid | 7 | 5.5 | 6 | 5 | 5.5 |
| Senior | 7 | 5.5 | 6.5 | 5.5 | 6 |
| Architect | 6 | 5.5 | 5.5 | 6 | 6 |
| Junior | 6 | 5 | 6 | 5 | 5.5 |
| Mid | 6.5 | 5 | 6 | 4.5 | 5.5 |
| Senior | 6.5 | 5.5 | 6.5 | 5.5 | 6 |
| Architect | 6.5 | 5.5 | 6 | 6 | 6 |
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
@@ -665,14 +668,15 @@ them. A primitive knowing neither ADF nor a format stays at `src/` root. A const
`spec/flavour.md` draws, and a placement nothing here settles goes beside its only reader, or in
what both read where there are two.
A flavour's own code, both directions included, sits in its own directory: `markdown/plain/`.
Otherwise each format directory parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest
of it holding what both directions read. A construct's reader lives there beside the regex the
emitter escapes against, so the two cannot drift; a reader with no emit counterpart goes in
`parse/`, unless it is part of a construct that side already holds — a grammar stays in one file
rather than splitting across the seam. A rule both directions must answer alike — whether a list
marker interrupts a paragraph — is one function there too, never a copy per direction, however
conservative the copy would be. Where the rule is the emitter's own choice, input consults it rather
than restating it, and that is the only value import `parse/` takes from `emit/` —
`commonMarkSpelling` and `openingLinkTakesDirective` — so no fixture the emitter writes can be
refused, and a spelling the emitter refuses gives its own error rather than a second name for it.
A flavour's own code, both directions included, sits in its own directory: `markdown/plain/`;
`todo.md` item 62 moves what still sits in `parse/` and `emit/`. Otherwise each format directory
parts into `emit/` (ADF→format) and `parse/` (format→ADF), the rest of it holding what both
directions read. A construct's reader lives there beside the regex the emitter escapes against, so
the two cannot drift; a reader with no emit counterpart goes in `parse/`, unless it is part of a
construct that side already holds — a grammar stays in one file rather than splitting across the
seam. A rule both directions must answer alike — whether a list marker interrupts a paragraph — is
one function there too, never a copy per direction, however conservative the copy would be. Where
the rule is the emitter's own choice, input consults it rather than restating it, and that is the
only value import `parse/` takes from `emit/` — `commonMarkSpelling` and `openingLinkTakesDirective`
— so no fixture the emitter writes can be refused, and a spelling the emitter refuses gives its own
error rather than a second name for it.
+1 -1
View File
@@ -64,7 +64,7 @@ export function emptyKeys(held: { attrs?: AdfAttributes; content?: AdfNode[]; ma
return keys
}
// A text node holding no attrs or content key and no empty marks: a format spells it as text, where anything more rides a carry.
// A format spells this node as text; anything more rides a carry, save content, which is refused.
export function isBareText(node: AdfNode): boolean {
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
}
+1 -1
View File
@@ -327,7 +327,7 @@ function isTextBreak(item: Inline): item is TextBreak {
return 'kind' in item && item.kind === 'textBreak'
}
// The nodes the items hold, own-marks ones unwrapped and text breaks dropped.
// The nodes the items hold, with text breaks dropped.
function adfNodes(items: readonly Inline[]): AdfNode[] {
const nodes: AdfNode[] = []
for (const item of items) {
@@ -344,3 +344,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(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')
})
+6 -4
View File
@@ -50,7 +50,7 @@ test('reads negative zero as zero, as JSON does', () => {
)
})
test('reads an empty attrs object, marks array or content array as the absent key, but on doc', () => {
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' }
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' }],
@@ -77,7 +77,9 @@ test('normalizes blocks and mark attributes nesting far past the levels a recurs
assert.deepEqual(merged.content?.[0]?.content?.map((text) => text.text), ['ab'])
})
test('joins a text node holding content to its neighbour, as editor-normal forms hold no content to part them', () => {
const paragraph: AdfNode = { content: [{ content: [{ text: 'lost', type: 'text' }], text: 'a', type: 'text' }, { text: 'b', type: 'text' }], type: 'paragraph' }
assert.deepEqual(toEditorNormal({ content: [paragraph], type: 'doc', version: 1 }).content, [{ content: [{ content: [{ text: 'lost', type: 'text' }], text: 'ab', type: 'text' }], type: 'paragraph' }])
test('joins a text node holding content to no neighbour, so its content is kept', () => {
const holding: AdfNode = { content: [{ text: 'kept', type: 'text' }], text: 'a', type: 'text' }
for (const content of [[holding, { text: 'b', type: 'text' }], [{ text: 'b', type: 'text' }, holding]]) {
assert.deepEqual(toEditorNormal({ content: [{ content, type: 'paragraph' }], type: 'doc', version: 1 }).content, [{ content, type: 'paragraph' }])
}
})
+2 -1
View File
@@ -7,8 +7,9 @@ type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
type NodeHolder = { content?: AdfNode[] }
// The editor's rule is the reader's over the pair's editor-normal forms, which hold no content: a text node's content never parts it.
// The editor's rule is the reader's over the pair's editor-normal forms; a text node holding content never joins, so its content is kept.
export function joinsWhenEditorNormal(previous: AdfNode, node: AdfNode): boolean {
if (previous.type !== 'text' || node.type !== 'text' || nodeContent(previous).length > 0 || nodeContent(node).length > 0) return false
return joinsWhenRead(normalNode(previous), normalNode(node))
}
+22 -21
View File
@@ -24,14 +24,14 @@
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|---|---|---|---|---|---|---|---|---|---|
| 63 | 0.2.0 | defect | **Refuse a cyclic input as `not-an-adf-document` in place of looping forever.** | 2 | 2 | 6 | 9 | 1 | 27.5 |
| 63 | 0.2.0 | defect | **Refuse a cyclic input as `not-an-adf-document`.** | 2 | 2 | 6 | 9 | 1 | 27.5 |
| 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 3 | 25.8 |
| 55 | 0.2.0 | defect | **Spell through the carry every document the emitter refuses today for a carriage return, a NUL, a code-span line start, a newline inside an emoji, mention or status, or a text node with empty, missing or content-holding text.** | 5 | 5 | 7 | 9 | 1 | 25.8 |
| 55 | 0.2.0 | defect | **Spell through the carry every document the emitter refuses today for a carriage return, a NUL, a code-span line start, a newline inside an emoji, mention or status, or a text node whose text is empty or missing, or which holds content.** | 5 | 5 | 7 | 9 | 1 | 25.8 |
| 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 |
| 64 | 0.2.0 | defect | **Read every JSON key the emitter writes back unchanged on V8, with a fixture whose key holds `\`, `"` or a control character.** | 4 | 4 | 6 | 8 | 1, 7 | 23.0 |
| 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, 7 | 23.0 |
| 43 | 0.2.0 | decision | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 3, 4 | 22.3 |
| 65 | 0.2.0 | defect | **Read a mid-text or titled CommonMark image as Goal 6 allows, in place of refusing it with `unmappable-image`.** | 5 | 5 | 7 | 7 | 3, 4, 6 | 18.7 |
| 65 | 0.2.0 | defect | **Read a mid-text or titled CommonMark image as Goal 6 allows.** | 5 | 5 | 7 | 7 | 3, 4, 6 | 18.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 |
| 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 | 5 | 13.0 |
@@ -40,7 +40,7 @@
| 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 |
| 50 | 0.2.0 | | **Read `[](/url)` and `[]()` as CommonMark's empty link.** | 4 | 4 | 3 | 6 | 3, 4, 6 | 10.4 |
| 62 | 0.2.0 | decision | **Move the plain flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/plain/`, so `plain/` and `emit/` no longer import each other.** | 4 | 5 | 2 | 6 | 2 | 9.4 |
| 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 |
| 38 | 0.3.0 | | **Spell a lone surrogate in a text node so it survives a UTF-8 encode.** | 2 | 2 | 4 | 7 | 1 | 19.5 |
| 47 | 0.3.0 | | **Open the README with what the package is, what it does and for whom.** | 1 | 4 | 7 | 9 | 9 | 14.0 |
| 34 | 0.3.0 | | **Read emphasis flanking by the whole character beside an astral symbol.** | 2 | 3 | 3 | 6 | 3, 4 | 12.6 |
@@ -57,7 +57,7 @@
## Details
### 63. Refuse a cyclic input as `not-an-adf-document` in place of looping forever.
### 63. Refuse a cyclic input as `not-an-adf-document`.
`adfDocumentFault` walks with `isNodeArray` (`adf/document.ts`) and `isJsonValue` (`json-value.ts`),
worklists that record no visited object, so a node whose `content` holds itself hangs
@@ -70,7 +70,7 @@ Lands after items 6, 59, 60, 61 and 62. The CommonMark spec suite also runs agai
`markdownToHtml`. The README documents HTML as it documents markdown, and its tagline and
`package.json`'s `description` regain HTML.
### 55. Spell through the carry every document the emitter refuses today for a carriage return, a NUL, a code-span line start, a newline inside an emoji, mention or status, or a text node with empty, missing or content-holding text.
### 55. Spell through the carry every document the emitter refuses today for a carriage return, a NUL, a code-span line start, a newline inside an emoji, mention or status, or a text node whose text is empty or missing, or which holds content.
`inline-line.ts` refuses a carriage return or NUL in text (`unspellable-character`) and a paragraph
opening with a code-span run (`unspellable-line-start`); `fencedTexts` refuses the same characters
@@ -78,8 +78,8 @@ in a code block; `directive-syntax.ts` refuses a newline in an emoji, mention or
(`unspellable-whitespace`). The inline carry's JSON escapes all of them, so a lossless spelling
exists, and §The code list says a cause the carry answers gets no code. Fixing it revises §Which
code a cause takes and §Markdown in is a canonical fixpoint, which the maintainer decides. Also a
text node whose `text` is empty or missing, or which holds `content`: `inline-line.ts` refuses it
inline and `fencedTexts` in a code block. Found by the README-goals audit, 2026-10-03.
text node whose `text` is empty or missing, which `inline-line.ts` and `fencedTexts` refuse, or
which holds `content`, which `inline-line.ts` refuses. Found by the README-goals audit, 2026-10-03.
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
@@ -93,14 +93,14 @@ Element-by-element mapping, the `data-*` fidelity scheme, the opaque-carry form,
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.
### 64. Read every JSON key the emitter writes back unchanged on V8, with a fixture whose key holds `\`, `"` or a control character.
### 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. 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.
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.
### 43. Give each markdown input its own reader, strict to its own standard.
@@ -110,11 +110,11 @@ 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,
read as `spec/flavour.md` says. Breaking: `MIGRATION.md` says which call a caller takes.
### 65. Read a mid-text or titled CommonMark image as Goal 6 allows, in place of refusing it with `unmappable-image`.
### 65. Read a mid-text or titled CommonMark image as Goal 6 allows.
§CommonMark is a subset keeps the image gap and no item ends it: a bot's `See ![diagram](url) here`
or a titled image is refused, 14 examples in `corpus/commonmark-spec/refusals.json`. Settle what such
an image builds — Goal 6 drops form and keeps the target — and have the decision cite this item.
A mid-text or titled image, such as a bot's `See ![diagram](url) here`, is refused: 14 examples in
`corpus/commonmark-spec/refusals.json`. Settle what such an image builds. Goal 6 drops form and
keeps the target.
### 49. Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.
@@ -173,12 +173,13 @@ input. Breaking, so it ships beside item 43: `MIGRATION.md`'s Readings table gai
`pending` exceptions go, and its spelling leaves the README's "Four CommonMark spellings" bullet,
which counts one fewer.
### 62. Move the plain flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/plain/`, so `plain/` and `emit/` no longer import each other.
### 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`. Every comprehension seat on 2026-10-03 named the plain flavour's
spread across three directories.
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.
### 38. Spell a lone surrogate in a text node so it survives a UTF-8 encode.