Compare commits

..

7 Commits

33 changed files with 246 additions and 203 deletions
+3 -2
View File
@@ -48,6 +48,7 @@ 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 comprehension floor ships under 7 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
@@ -175,8 +176,8 @@ settle it; otherwise four more read, five of seven settle it, and less is a miss
The verdict lands in `docs/decisions.md`. The verdict lands in `docs/decisions.md`.
A writer panel settles every new or changed markdown or HTML spelling: a reader panel whose readers A writer panel settles every new or changed markdown or HTML spelling: a reader panel whose readers
are the people who read and write that format (README `## Audience`). A question about what an app are the people who read and write that format (README `## Audience`). A panel of the developer
relies on goes to the developer personas instead. personas settles a question about what an app relies on.
### Stated numbers ### Stated numbers
+7 -9
View File
@@ -5,20 +5,18 @@
- **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: text holding an unescaped `!adf:name arg {attrs}`) in place of the `:::`/`::`/`:name` forms: text holding an unescaped
`!adf:` is claimed, and `adf` is an ordinary code block language. Convert stored markdown per `!adf:` is claimed. Convert stored markdown per `MIGRATION.md`.
`MIGRATION.md`.
- **Breaking:** the block carry is a code fence whose info string `adf:<type>` names the node's - **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 type, its body the node's JSON without `type`, and a code fence whose info string opens `adf:` is
claimed. 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}` document whose `content` is empty, as Atlassian's schema requires; `!adf:doc {content=none}`
spells a document holding no `content` key. spells a document holding no `content` key.
- `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as JSON, for a document of plain objects as - `markdownToAdf(adfToMarkdown(doc))` deep-equals `doc` as `JSON.parse` builds it: two adjacent text
`JSON.parse` builds them: two adjacent text nodes CommonMark would read back as one are parted by nodes CommonMark would read back as one are parted by `!adf:textBreak{}`, an empty `attrs`,
`!adf:textBreak{}`, an empty `attrs`, `content` or `marks` is spelled `{attrs=empty}`, `content` or `marks` is spelled `{attrs=empty}`, `{content=empty}` or `{marks=empty}`, `-0` is
`{content=empty}` or `{marks=empty}`, `-0` is spelled `-0`, and a `codeBlock` of several text spelled `-0`, and a `codeBlock` of several text nodes is a fence per node. A `codeBlock` holding
nodes is a fence per node. A `codeBlock` holding other than plain text nodes rides the block other than plain text nodes rides the block carry, where it was refused.
carry, where it was refused.
- **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
+1 -1
View File
@@ -7,7 +7,7 @@ turning each directive into text and each carried node into an `adf` code block.
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 the recipe below, and rewrite markdown your code writes or matches (templates, prompts, patterns) by the
tables below. Stored ADF needs one change: give a document holding no `content` key `content: []`. tables below. Stored ADF needs one change: give a document holding no `content` key `content: []`.
`0.1.0` read empty markdown to that shape, meaning the empty document. `0.1.0` built that shape from empty markdown, meaning the empty document.
### Convert markdown ### Convert markdown
+1 -1
View File
@@ -198,7 +198,7 @@ emit refuses:
| `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span | | `unspellable-line-start` | a paragraph line begins with a code span whose backticks would read back as a code fence | put any text before the code span |
| `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines | | `unspellable-whitespace` | an `emoji`, `mention` or `status` holds a newline in the text its inline directive spells in the content slot | replace it with a space — an inline directive never spans lines |
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | | `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
| `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark, or a reserved directive (`!adf:textBreak{}`, `!adf:listBreak`, `!adf:doc`) stands where it parts nothing | make the edit the message opens with; `spec/flavour.md` lists every type's attributes and body | | `unsupported-node-shape` | a node carries an attribute, value, argument or body its type does not take, or lacks one it needs — or markdown writes as a directive a node or mark the lossless flavour spells as CommonMark, or a reserved directive stands out of place: `!adf:textBreak{}` or `!adf:listBreak` parting nothing, `!adf:doc` beside another block | fix what the message names, or give the node the shape it names; `spec/flavour.md` lists every type's attributes and body |
## The guarantees ## The guarantees
+24 -5
View File
@@ -110,9 +110,9 @@ node's JSON without `type`: ```` ```adf:blockCard ````. A type no info string ca
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 type, `language` attribute, and `carry` is an ordinary language. A body holding `type` under a named type,
or a bare `adf:` fence whose body's `type` an info string could carry, is `unsupported-node-shape`. or a fence whose info string is `adf:` alone while its body's `type` could be spelled in the info
The reservation claims a fence CommonMark reads as code until `todo.md` item 43 gives CommonMark its string, is `unsupported-node-shape`. The reservation claims a fence CommonMark reads as code until
own 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
@@ -496,6 +496,22 @@ 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 comprehension floor ships under 7 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 6 against the floor of 7, and the project ships
under it while the restructures it named land: `todo.md` items 59, 60, 61 and 62 end this. The
scores 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 |
## 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.
@@ -642,8 +658,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
@@ -666,3 +682,6 @@ conservative the copy would be. Where the rule is the emitter's own choice, inpu
rather than restating it, and that is the only import `parse/` takes from `emit/` — rather than restating it, and that is the only import `parse/` takes from `emit/` —
`commonMarkSpelling` and `openingLinkTakesDirective` — so no fixture the emitter writes can be `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. refused, and a spelling the emitter refuses gives its own error rather than a second name for it.
`markdown/plain/` holds the plain flavour's own code — the writer's reduction to editor-normal ADF
and the conventions and task ids the reader takes — so neither direction reaches into the other
for it.
+3 -2
View File
@@ -181,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 a bare string `adf:` and keeps `type` in the body. A body holding `type` under a named type, or a fence
`adf:` fence whose body's `type` an info string could carry, 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.
+1 -1
View File
@@ -51,7 +51,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)
} }
-69
View File
@@ -1,69 +0,0 @@
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './document.ts'
import type { JsonValue } from '../json-value.ts'
import { identicalMark, identicalMarks, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from './document.ts'
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
type NodeHolder = { content?: AdfNode[] }
export function sameMark(left: AdfMark, right: AdfMark): boolean {
return identicalMark(normalMark(left), normalMark(right))
}
export function joinsNormally(previous: AdfNode, node: AdfNode): boolean {
if (!mergesText(previous) || !mergesText(node)) return false
return identicalMarks(nodeMarks(previous).map(normalMark), nodeMarks(node).map(normalMark))
}
export function toEditorNormal(document: AdfDocument): AdfDocument {
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 }]
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
const content = mergeAdjacentText(nodeContent(entry.source), joinsNormally)
if (content.length === 0) continue
entry.holder.content = content.map((source) => {
const holder = normalNode(source)
pending.push({ holder, source })
return holder
})
}
return normal
}
function normalNode(node: AdfNode): AdfNode {
const normal: AdfNode = { type: node.type }
const attrs = normalAttributes(nodeAttrs(node))
if (attrs !== undefined) normal.attrs = attrs
const marks = nodeMarks(node).map(normalMark)
if (marks.length > 0) normal.marks = marks
if (node.text !== undefined) normal.text = node.text
return normal
}
function normalMark(mark: AdfMark): AdfMark {
const attrs = normalAttributes(nodeAttrs(mark))
return attrs === undefined ? { type: mark.type } : { attrs, type: mark.type }
}
function normalAttributes(attrs: AdfAttributes): AdfAttributes | undefined {
if (Object.keys(attrs).length === 0) return undefined
const normal = { ...attrs }
const pending: JsonContainer[] = [normal]
for (let held = pending.pop(); held !== undefined; held = pending.pop()) {
if (Array.isArray(held)) for (const [index, value] of held.entries()) held[index] = normalValue(value, pending)
else for (const [key, value] of Object.entries(held)) held[key] = normalValue(value, pending)
}
return normal
}
function normalValue(value: JsonValue, pending: JsonContainer[]): JsonValue {
if (Object.is(value, -0)) return 0
if (value === null || typeof value !== 'object') return value
const copy = Array.isArray(value) ? [...value] : { ...value }
pending.push(copy)
return copy
}
function mergesText(node: AdfNode): boolean {
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
}
+2 -2
View File
@@ -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:'
+3 -3
View File
@@ -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 { joinsWhenEditorNormal } 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 }
@@ -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, joinsWhenEditorNormal)))
} }
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode { function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
+1 -1
View File
@@ -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'
+53
View File
@@ -0,0 +1,53 @@
import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts'
import type { JsonValue } from '../json-value.ts'
import { identicalMark, identicalMarks, isPlainText, nodeAttrs, nodeMarks } from '../adf/document.ts'
import { spellInlineLeafDirective } from './directive-syntax.ts'
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
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 isPlainText(previous) && isPlainText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node))
}
// The editor's rule, parting from the reader's only on shapes editor-normal ADF erases: an empty attrs, content or marks, and -0 in a mark.
export function joinsWhenEditorNormal(previous: AdfNode, node: AdfNode): boolean {
if (!joinsAsEditorText(previous) || !joinsAsEditorText(node)) return false
return identicalMarks(nodeMarks(previous).map(normalMark), nodeMarks(node).map(normalMark))
}
export function sameMarkWhenEditorNormal(left: AdfMark, right: AdfMark): boolean {
return identicalMark(normalMark(left), normalMark(right))
}
export function normalMark(mark: AdfMark): AdfMark {
const attrs = normalAttributes(nodeAttrs(mark))
return attrs === undefined ? { type: mark.type } : { attrs, type: mark.type }
}
export function normalAttributes(attrs: AdfAttributes): AdfAttributes | undefined {
if (Object.keys(attrs).length === 0) return undefined
const normal = { ...attrs }
const pending: JsonContainer[] = [normal]
for (let held = pending.pop(); held !== undefined; held = pending.pop()) {
if (Array.isArray(held)) for (const [index, value] of held.entries()) held[index] = normalValue(value, pending)
else for (const [key, value] of Object.entries(held)) held[key] = normalValue(value, pending)
}
return normal
}
function normalValue(value: JsonValue, pending: JsonContainer[]): JsonValue {
if (Object.is(value, -0)) return 0
if (value === null || typeof value !== 'object') return value
const copy = Array.isArray(value) ? [...value] : { ...value }
pending.push(copy)
return copy
}
function joinsAsEditorText(node: AdfNode): boolean {
return node.type === 'text' && Object.keys(nodeAttrs(node)).length === 0
}
+20 -20
View File
@@ -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, isPlainText, 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 {
@@ -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')
@@ -252,7 +252,7 @@ function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, wri
} }
function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined { function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | 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 texts = fencedTexts(node, path)
@@ -291,7 +291,7 @@ function fencedTexts(node: AdfNode, path: ConvertErrorPath): Result<string[] | u
} }
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 +304,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 +356,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 '---'
} }
+3 -3
View File
@@ -1,6 +1,6 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { ConvertErrorPath } from '../../result.ts' import type { ConvertErrorPath } from '../../result.ts'
import { carriesOnly, nodeAttrs, nodeContent } from '../../adf/document.ts' import { holdsOnlyAttributes, 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'
@@ -16,8 +16,8 @@ export function tryImage(node: AdfNode, path: ConvertErrorPath): string | undefi
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']
+18 -18
View File
@@ -1,5 +1,5 @@
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'
@@ -9,11 +9,11 @@ import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../
import { identicalMark, isPlainText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.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 { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { highlightDelimiter } from '../plain-conventions.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 { slotLineEndingFault, 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'
@@ -114,7 +114,7 @@ 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, path: ConvertErrorPath, flavour: Flavour): Result<LineAttempt> {
@@ -137,32 +137,32 @@ function lineVerdict(segments: readonly InlineSegment[], container: LineContaine
} }
// 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 +186,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,10 +213,10 @@ 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 {
@@ -275,7 +275,7 @@ function emitText(node: AdfNode, context: InlineContext, index: number, path: Co
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', 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+)/).filter((part) => part !== '')
return success({ segments: parts.map((part) => (part.startsWith('\n') ? carriedText(part) : { escaping, text: part })) }) return success({ segments: parts.map((part) => (part.startsWith('\n') ? 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> {
@@ -300,11 +300,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 },
], ],
}) })
+2 -2
View File
@@ -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'
+6 -6
View File
@@ -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
} }
+1 -1
View File
@@ -27,6 +27,6 @@ export function readEmptyKeys(type: string, attributes: DirectiveAttributes, key
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 another attribute, an argument or content: drop attrs=empty or the rest`) } 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 } }
} }
+1 -1
View File
@@ -14,7 +14,7 @@ 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 { slotLineEndingFault } 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 }
+8 -8
View File
@@ -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,7 +20,6 @@ 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 = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] }
@@ -312,7 +312,7 @@ 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', `delete ${textBreakSpelling} here: it stands only between two runs of text with the same formatting, which would otherwise read as one`, 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)
} }
} }
@@ -508,11 +508,11 @@ function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean)
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', carriedInMark, 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 carried node 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
} }
+6 -2
View File
@@ -166,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')), "unsupported-node-shape: move language=rust to the fence's info string: a fence carries the language wherever its info string can") 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)
@@ -1116,6 +1120,6 @@ test('leads a refusal ordinary editing meets with the edit that fixes it', () =>
) )
assert.equal( assert.equal(
content(markdownToAdf('!adf:panel info {attrs=empty}\nText.\n!adf:/panel\n')), 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 content: drop attrs=empty or the rest', 'unsupported-node-shape: panel spells attrs=empty beside another attribute, an argument or a [content] slot: drop attrs=empty, or the rest',
) )
}) })
+6 -6
View File
@@ -2,7 +2,7 @@ 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'
@@ -10,16 +10,16 @@ import { commonMarkSpelling, type SpellingMemo } from '../emit/adf-to-markdown.t
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 { 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 { 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 { spellStringAttribute, unsupportedNodeShape } from '../directive-syntax.ts'
import { spellsEmpty } from '../empty-keys.ts' import { spellsEmpty } from '../empty-keys.ts'
import { unsupportedNodeShape } from '../directive-syntax.ts'
type Paragraph = Extract<Block, { kind: 'paragraph' }> type Paragraph = Extract<Block, { kind: 'paragraph' }>
@@ -257,7 +257,7 @@ function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: Conver
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') { if (!fromFence && slot.kind === 'fence') {
return failure('unsupported-node-shape', `move language=${slot.info} to the fence's info string: a fence carries the language wherever its info string can`, path) 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)
@@ -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'
@@ -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' }
@@ -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 }
@@ -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', () => {
+30
View File
@@ -0,0 +1,30 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { joinsWhenEditorNormal, normalAttributes, normalMark } from '../adjacent-text.ts'
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
type NodeHolder = { content?: AdfNode[] }
export function toEditorNormal(document: AdfDocument): AdfDocument {
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 }]
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
const content = mergeAdjacentText(nodeContent(entry.source), joinsWhenEditorNormal)
if (content.length === 0) continue
entry.holder.content = content.map((source) => {
const holder = normalNode(source)
pending.push({ holder, source })
return holder
})
}
return normal
}
function normalNode(node: AdfNode): AdfNode {
const normal: AdfNode = { type: node.type }
const attrs = normalAttributes(nodeAttrs(node))
if (attrs !== undefined) normal.attrs = attrs
const marks = nodeMarks(node).map(normalMark)
if (marks.length > 0) normal.marks = marks
if (node.text !== undefined) normal.text = node.text
return normal
}
@@ -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 '../adjacent-text.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'
@@ -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[]> {
-12
View File
@@ -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))
}
+30 -12
View File
@@ -6,7 +6,7 @@
`Bar = 9` `Bar = 9`
`Next ID = 59` `Next ID = 63`
| Goal | W | | Goal | W |
|---|---| |---|---|
@@ -30,11 +30,14 @@
| 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 3 | 24.7 | | 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 3 | 24.7 |
| 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 | | 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 |
| 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 | | 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 | | 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 5 | 13.0 |
| 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 3, 4 | 12.4 | | 51 | 0.2.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 3, 4 | 12.4 |
| 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 |
| 53 | 0.2.0 | | **Put a block's `marks` spelling to the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 5 | 11.5 | | 53 | 0.2.0 | | **Put a block's `marks` spelling to the writer panel and adopt its pick.** | 4 | 5 | 5 | 6 | 5 | 11.5 |
| 60 | 0.2.0 | decision | **Give the parser's questions to the emitter one named module, so the parse→emit dependency reads as intended.** | 3 | 4 | 2 | 6 | 2 | 10.7 |
| 59 | 0.2.0 | decision | **Group the directive grammar into `src/markdown/directive/`, and move `Read<T>` to `result.ts` and `Flavour` out of the plain flavour.** | 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 | | 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 reader out of `parse/markdown-to-adf.ts` into `markdown/plain/`.** | 3 | 4 | 2 | 5 | 2 | 8.9 |
| 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 | | 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 | | 34 | 0.3.0 | | **Read emphasis flanking by the whole character beside an astral symbol.** | 2 | 3 | 3 | 6 | 3, 4 | 12.6 |
@@ -53,9 +56,9 @@
### 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, and its tagline and
HTML. `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 or a newline inside an emoji, mention or status. ### 55. Spell through the carry every document the emitter refuses today for a carriage return, a NUL, a code-span line start or a newline inside an emoji, mention or status.
@@ -99,6 +102,12 @@ Readings table gains its row, and its Spellings table one if `!adf:listBreak` re
and 302 lose their `pending` exceptions, and the spelling leaves the README's "Four CommonMark and 302 lose their `pending` exceptions, and the spelling leaves the README's "Four CommonMark
spellings" bullet, which counts one fewer. spellings" bullet, which counts one fewer.
### 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 Locality.
### 52. Spell `colwidth` as a comma list, `colwidth="340,420"`. ### 52. Spell `colwidth` as a comma list, `colwidth="340,420"`.
A writer panel chose it on 2026-10-03, 5 of 7, over today's `colwidth="[340,420]"`. Breaking: A writer panel chose it on 2026-10-03, 5 of 7, over today's `colwidth="[340,420]"`. Breaking:
@@ -111,18 +120,23 @@ 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/`.
The entry says `parse/` imports only `commonMarkSpelling` and `openingLinkTakesDirective` from
`emit/`; `parse/markdown-to-adf.ts` also imports `inlineLeaves` from `emit/plain-inline.ts`. Either
the entry names it with its reason, or the reader stops importing it.
### 53. Put a block's `marks` spelling to the writer panel and adopt its pick. ### 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.
### 60. Give the parser's questions to the emitter one named module, so the parse→emit dependency reads as intended.
`parse/` asks `emit/` through `commonMarkSpelling` and `openingLinkTakesDirective`, each imported
from where it happens to live. One module naming the questions keeps `docs/decisions.md` §The source
parts by ADF and format true as they grow.
### 59. Group the directive grammar into `src/markdown/directive/`, and move `Read<T>` to `result.ts` and `Flavour` out of the plain flavour.
Eight directive files sit across three directories, and the `markdown/` root holds 14 entries. HTML
needs `Read<T>`, `Flavour`, the carry and the mark spellings out of `markdown/`.
### 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
@@ -132,6 +146,10 @@ 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, `pending` exceptions go, and its spelling leaves the README's "Four CommonMark spellings" bullet,
which counts one fewer. which counts one fewer.
### 62. Move the plain reader out of `parse/markdown-to-adf.ts` into `markdown/plain/`.
### 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
@@ -154,7 +172,7 @@ punctuation — and, where they do, read the code point, with a fixture per dire
### 42. Trim a text leaf's trailing blanks in linear time. ### 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 `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 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. `adfToPlainMarkdown`. Scan backward, as the expand title's trim does.