Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eccfd12d2d | |||
| 7bab478729 | |||
| 00c89dd63b | |||
| fd3452b124 | |||
| d56e5b4f57 | |||
| 36604b1e2b | |||
| 1791542b3c | |||
| f7d2eb6517 | |||
| 79005f07be |
+8
-14
@@ -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
|
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
|
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
|
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 — so two documents the editor holds
|
`0`, and an empty `attrs`, `content` or `marks` the absent key, but the doc's `content` — so two
|
||||||
equal write the same plain markdown.
|
documents the editor holds equal write the same plain markdown.
|
||||||
|
|
||||||
## `!adf:textBreak{}` parts text CommonMark would join
|
## `!adf:textBreak{}` parts text CommonMark would join
|
||||||
|
|
||||||
@@ -60,22 +60,16 @@ around the leaf. The grammar: `spec/flavour.md` §Inline nodes, **Adjacent text
|
|||||||
spells an empty object or array.
|
spells an empty object or array.
|
||||||
|
|
||||||
An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the
|
An `attrs`, `content` or `marks` key holding an empty object or array is the reserved key with the
|
||||||
bare value `empty` on every directive — block, inline node and mark: `{attrs=empty}`,
|
bare value `empty`, so an empty pair stays the node holding no content key. A writer panel chose
|
||||||
`{content=empty}`, `{marks=empty}`. A writer panel chose the spelling, 5 of 7. A container
|
the spelling, 5 of 7. The grammar: `spec/flavour.md` §Directives, **Attributes**, and §Marks.
|
||||||
spelling `content=empty` closes with no body, so an empty pair stays the node holding no content
|
|
||||||
key; a leaf spells it too, `!adf:rule {content=empty}`. A node CommonMark spells takes its
|
|
||||||
directive form to hold an empty key, and a text node holding one rides the inline carry, as does a
|
|
||||||
node under an `em`, `strong`, `strike`, `code` or `link` mark whose `attrs` is empty, since none of
|
|
||||||
those spellings holds attributes. Any other value of a reserved key is `unsupported-node-shape`,
|
|
||||||
except on a block's `marks`, which reads it as the marks array in JSON.
|
|
||||||
|
|
||||||
## `-0` is spelled `-0`
|
## `-0` is spelled `-0`
|
||||||
|
|
||||||
2026-10-03, the maintainer. Goal 1. Valid while JSON's own serialization writes `-0` as `0`.
|
2026-10-03, the maintainer. Goal 1. Valid while JSON's own serialization writes `-0` as `0`.
|
||||||
|
|
||||||
Wherever the flavour writes a number or a JSON value — an attribute, a `json` value, the carry —
|
`-0` is spelled `-0` wherever the flavour writes a number or a JSON value, since JSON's grammar
|
||||||
`-0` is `-0`, which JSON's grammar reads back as `-0`. An `orderedList` whose `order` is `-0` takes
|
reads it back as `-0` and no list marker spells the sign. The grammar: `spec/flavour.md` §Block
|
||||||
the directive form, since no list marker spells the sign.
|
nodes and §The CommonMark blocks.
|
||||||
|
|
||||||
## Empty markdown is a document of no blocks
|
## Empty markdown is a document of no blocks
|
||||||
|
|
||||||
@@ -170,7 +164,7 @@ opener nests by itself and leaf versus container falls out of the node's content
|
|||||||
Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a
|
Plain CommonMark is a subset, with carve-outs (`spec/flavour.md`): literal text shaped like a
|
||||||
directive, a pipe table or a `~~` pair is claimed, and so is a code fence whose info string opens
|
directive, a pipe table or a `~~` pair is claimed, and so is a code fence whose info string opens
|
||||||
`adf:` (§The carry fence names the node type) — plus one image gap. `todo.md` item 43 ends the
|
`adf:` (§The carry fence names the node type) — plus one image gap. `todo.md` item 43 ends the
|
||||||
claims, giving CommonMark its own reader.
|
claims, giving CommonMark its own reader, and `todo.md` item 65 ends the image gap.
|
||||||
|
|
||||||
## Tables
|
## Tables
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -250,8 +250,8 @@ form.
|
|||||||
the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
|
the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
|
||||||
CommonMark spelling.
|
CommonMark spelling.
|
||||||
- `orderedList` — container of `listItem`, block body. Attributes: `localId` (string), `order`
|
- `orderedList` — container of `listItem`, block body. Attributes: `localId` (string), `order`
|
||||||
(number). `order` is the first marker, so a list carrying none, one that is no whole number
|
(number). `order` is the first marker, so a list carrying none, one that is `-0` or no whole
|
||||||
from 0, or one whose markers would run past 999999999, has no CommonMark spelling.
|
number from 0, or one whose markers would run past 999999999, has no CommonMark spelling.
|
||||||
- `paragraph` — container, inline body. Attributes: `localId` (string).
|
- `paragraph` — container, inline body. Attributes: `localId` (string).
|
||||||
- `rule` — leaf. Attributes: `color` (string, `#rrggbb`), `localId` (string), `style` (`dashed`
|
- `rule` — leaf. Attributes: `color` (string, `#rrggbb`), `localId` (string), `style` (`dashed`
|
||||||
`dotted` `fade` `sketch` `solid`), `weight` (number, 1–3).
|
`dotted` `fade` `sketch` `solid`), `weight` (number, 1–3).
|
||||||
|
|||||||
+6
-2
@@ -64,11 +64,15 @@ export function emptyKeys(held: { attrs?: AdfAttributes; content?: AdfNode[]; ma
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
// A text node holding its text and marks alone: a format spells it as text, where anything more rides a carry.
|
// 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.
|
||||||
export function isPlainText(node: AdfNode): boolean {
|
export function isBareText(node: AdfNode): boolean {
|
||||||
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
|
return node.type === 'text' && node.attrs === undefined && node.content === undefined && node.marks?.length !== 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isUnmarkedBareText(node: AdfNode): boolean {
|
||||||
|
return isBareText(node) && node.marks === undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function identicalMark(left: AdfMark, right: AdfMark): boolean {
|
export function identicalMark(left: AdfMark, right: AdfMark): boolean {
|
||||||
return marksKey([left]) === marksKey([right])
|
return marksKey([left]) === marksKey([right])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { joinsWhenEditorNormal } from '../markdown/adjacent-text.ts'
|
import { joinsWhenRead } from '../markdown/adjacent-text.ts'
|
||||||
import { markAttributes } from '../adf/mark-attributes.ts'
|
import { markAttributes } from '../adf/mark-attributes.ts'
|
||||||
|
|
||||||
type Positions = { block: AdfNode; inline: AdfNode }
|
type Positions = { block: AdfNode; inline: AdfNode }
|
||||||
@@ -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, joinsWhenEditorNormal)))
|
return fc.tuple(arbitrary, fc.nat({ max: 5 })).map(([nodes, roll]) => (roll === 0 ? nodes : mergeAdjacentText(nodes, joinsWhenRead)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
|
function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode {
|
||||||
|
|||||||
@@ -1,53 +1,12 @@
|
|||||||
import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts'
|
import type { AdfNode } from '../adf/document.ts'
|
||||||
import type { JsonValue } from '../json-value.ts'
|
import { identicalMarks, isBareText, nodeMarks } from '../adf/document.ts'
|
||||||
import { identicalMark, identicalMarks, isPlainText, nodeAttrs, nodeMarks } from '../adf/document.ts'
|
|
||||||
import { spellInlineLeafDirective } from './directive-syntax.ts'
|
import { spellInlineLeafDirective } from './directive-syntax.ts'
|
||||||
|
|
||||||
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
|
|
||||||
|
|
||||||
export const textBreakName = 'textBreak'
|
export const textBreakName = 'textBreak'
|
||||||
|
|
||||||
export const textBreakSpelling = spellInlineLeafDirective(textBreakName, '')
|
export const textBreakSpelling = spellInlineLeafDirective(textBreakName, '')
|
||||||
|
|
||||||
// The lossless reader's rule: CommonMark reads the pair back as one text run (spec/flavour.md, Inline nodes).
|
// 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 {
|
export function joinsWhenRead(previous: AdfNode, node: AdfNode): boolean {
|
||||||
return isPlainText(previous) && isPlainText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node))
|
return isBareText(previous) && isBareText(node) && identicalMarks(nodeMarks(previous), nodeMarks(node))
|
||||||
}
|
|
||||||
|
|
||||||
// The editor's rule, differing 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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, holdsOnlyAttributes, isPlainText, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
import { adfDocumentFault, holdsOnlyAttributes, isUnmarkedBareText, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||||
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain/conventions.ts'
|
import { alertMarker, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../plain/conventions.ts'
|
||||||
import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts'
|
import { blockDirectiveForm, documentSpelling, listBreakSpelling } from '../block-directive.ts'
|
||||||
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
|
import { blockNodeModel, blockNodes } from '../../adf/block-nodes.ts'
|
||||||
@@ -278,7 +278,7 @@ function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertEr
|
|||||||
// The text each fence holds, or `undefined` where a child is no plain text node, which the carry holds instead.
|
// The text each fence holds, or `undefined` where a child is no plain text node, which the carry holds instead.
|
||||||
function fencedTexts(node: AdfNode, path: ConvertErrorPath): Result<string[] | undefined> {
|
function fencedTexts(node: AdfNode, path: ConvertErrorPath): Result<string[] | undefined> {
|
||||||
if (node.content === undefined) return success([''])
|
if (node.content === undefined) return success([''])
|
||||||
if (node.content.some((child) => !isPlainText(child) || child.marks !== undefined)) return success(undefined)
|
if (node.content.some((child) => !isUnmarkedBareText(child))) return success(undefined)
|
||||||
const texts: string[] = []
|
const texts: string[] = []
|
||||||
for (const [index, child] of node.content.entries()) {
|
for (const [index, child] of node.content.entries()) {
|
||||||
const childPath = [...path, 'content', index]
|
const childPath = [...path, 'content', index]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment,
|
|||||||
import { carriedInline } from '../opaque-carry.ts'
|
import { carriedInline } from '../opaque-carry.ts'
|
||||||
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||||
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
||||||
import { identicalMark, isPlainText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
import { identicalMark, isBareText, 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'
|
||||||
@@ -267,9 +267,14 @@ function emitInlineDirective(node: AdfNode, model: InlineNodeModel, index: numbe
|
|||||||
return success({ segments: [syntax(spellInlineDirectiveOpener(node.type)), ...content, syntax(`]${attributes}`)] })
|
return success({ segments: [syntax(spellInlineDirectiveOpener(node.type)), ...content, syntax(`]${attributes}`)] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function textHoldingContent(node: AdfNode, path: ConvertErrorPath): Result<never> | undefined {
|
||||||
|
return node.type === 'text' && nodeContent(node).length > 0 ? failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
|
function emitText(node: AdfNode, context: InlineContext, index: number, path: ConvertErrorPath): Result<Emission> {
|
||||||
if (nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
|
const holding = textHoldingContent(node, path)
|
||||||
if (!isPlainText(node)) return success({ carry: { first: index, last: index } })
|
if (holding !== undefined) return holding
|
||||||
|
if (!isBareText(node)) return success({ carry: { first: index, last: index } })
|
||||||
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
if (typeof node.text !== 'string' || node.text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
||||||
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
|
if (/\r/.test(node.text)) return failure('unspellable-character', 'a text node holds a carriage return CommonMark rewrites', path)
|
||||||
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
if (holdsNullCharacter(node.text)) return failure('unspellable-character', 'a text node holds a null character CommonMark replaces', path)
|
||||||
@@ -323,8 +328,9 @@ function emitHighlight(nodes: readonly AdfNode[], depth: number, range: NodeRang
|
|||||||
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
|
function emitCodeSpan(nodes: readonly AdfNode[], depth: number, range: NodeRange, path: ConvertErrorPath): Result<Emission> {
|
||||||
const spans: string[] = []
|
const spans: string[] = []
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.type === 'text' && nodeContent(node).length > 0) return failure('unsupported-node-shape', 'a text node holds no content: this one holds some', path)
|
const holding = textHoldingContent(node, path)
|
||||||
if (!isPlainText(node) || nodeMarks(node).length !== depth + 1) return success({ carry: range })
|
if (holding !== undefined) return holding
|
||||||
|
if (!isBareText(node) || nodeMarks(node).length !== depth + 1) return success({ carry: range })
|
||||||
const { text } = node
|
const { text } = node
|
||||||
if (typeof text !== 'string' || text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
if (typeof text !== 'string' || text === '') return failure('unsupported-node-shape', 'a text node holds text: this one has none', path)
|
||||||
if (/[\n\r]/.test(text)) return success({ carry: range })
|
if (/[\n\r]/.test(text)) return success({ carry: range })
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
|||||||
import type { ConvertFault } from '../../result.ts'
|
import type { ConvertFault } from '../../result.ts'
|
||||||
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
|
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
|
||||||
import type { Elsewhere } from './directive-attributes.ts'
|
import type { Elsewhere } from './directive-attributes.ts'
|
||||||
import { attributeNestingMessage, isPlainText } from '../../adf/document.ts'
|
import { attributeNestingMessage, isUnmarkedBareText } from '../../adf/document.ts'
|
||||||
import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
|
import { attributeValue, directivePrefix, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts'
|
||||||
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts'
|
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts'
|
||||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||||
@@ -92,7 +92,7 @@ function blockSpellingFault(name: string): ConvertFault | undefined {
|
|||||||
function slotText(content: readonly AdfNode[]): string | undefined {
|
function slotText(content: readonly AdfNode[]): string | undefined {
|
||||||
if (content.length === 0) return ''
|
if (content.length === 0) return ''
|
||||||
const only = content.length === 1 ? content[0] : undefined
|
const only = content.length === 1 ? content[0] : undefined
|
||||||
if (only === undefined || !isPlainText(only) || only.marks !== undefined || typeof only.text !== 'string') return undefined
|
if (only === undefined || !isUnmarkedBareText(only) || typeof only.text !== 'string') return undefined
|
||||||
return only.text
|
return only.text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,17 +21,17 @@ 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'
|
||||||
|
|
||||||
export type InlineContent = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: AdfNode[] }
|
export type InlineContent = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: AdfNode[] }
|
||||||
|
|
||||||
// The text break leaf: it holds no marks, and stands between the nodes it parts until the outermost content checks and drops it.
|
// The text break leaf: it holds no marks, and stands between the nodes it parts until the outermost content checks and drops it.
|
||||||
type TextBreak = { kind: 'textBreak' }
|
type TextBreak = { kind: 'textBreak' }
|
||||||
|
|
||||||
// A node whose marks are its own — an opaque carry, or an inline node spelling marks=empty: no mark spelling wraps it, and it joins no neighbour.
|
// A node whose marks are its own — an opaque carry, or an inline node spelling marks=empty: no mark spelling wraps it, and it joins no neighbour.
|
||||||
type Carried = { kind: 'carry'; node: AdfNode }
|
type OwnMarks = { kind: 'ownMarks'; node: AdfNode }
|
||||||
|
|
||||||
type Inline = AdfNode | Carried | TextBreak
|
type Inline = AdfNode | OwnMarks | TextBreak
|
||||||
|
|
||||||
type Scanned = { carry?: undefined; image: AdfNode; nodes?: undefined } | { carry: boolean; image?: undefined; nodes: Inline[] }
|
type Scanned = { image: AdfNode; nodes?: undefined } | { image?: undefined; nodes: Inline[] }
|
||||||
|
|
||||||
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
|
export type LinkDefinitions = ReadonlyMap<string, LinkDefinition>
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ type Pairing = EmphasisPairing<Run>
|
|||||||
|
|
||||||
type Piece =
|
type Piece =
|
||||||
| Bracket
|
| Bracket
|
||||||
| Carried
|
| OwnMarks
|
||||||
| { alt: string; kind: 'image'; node: AdfNode }
|
| { alt: string; kind: 'image'; node: AdfNode }
|
||||||
| { kind: 'nodes'; nodes: Inline[] }
|
| { kind: 'nodes'; nodes: Inline[] }
|
||||||
| TextBreak
|
| TextBreak
|
||||||
@@ -70,9 +70,9 @@ type Scan = {
|
|||||||
// What a directive's content slot inherits from the content holding it.
|
// What a directive's content slot inherits from the content holding it.
|
||||||
type SharedScan = Pick<Scan, 'definitions' | 'path'>
|
type SharedScan = Pick<Scan, 'definitions' | 'path'>
|
||||||
|
|
||||||
type SlotContent = { carry: boolean; nodes: Inline[] }
|
type SlotContent = { nodes: Inline[] }
|
||||||
|
|
||||||
const carriedInMark = 'move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks'
|
const ownMarksInMark = 'move the opaque carry, or the node spelling marks=empty, out of the mark spelling: it holds its own marks'
|
||||||
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
|
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
|
||||||
const hreflessLink = 'the link mark spells its href: this one spells none'
|
const hreflessLink = 'the link mark spells its href: this one spells none'
|
||||||
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
|
const imageAlone = 'an image fits only as a paragraph of its own: this one sits inside other content'
|
||||||
@@ -93,7 +93,7 @@ export function parseInlineContent(source: string, definitions: LinkDefinitions,
|
|||||||
if (!takesDirective.ok) return takesDirective
|
if (!takesDirective.ok) return takesDirective
|
||||||
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path)
|
if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path)
|
||||||
}
|
}
|
||||||
return success({ carry: scanned.value.carry, nodes: nodes.value })
|
return success({ nodes: nodes.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'container' | 'highlights' | 'spans'>): Scan {
|
function freshScan(source: string, shared: SharedScan, own: Pick<Scan, 'container' | 'highlights' | 'spans'>): Scan {
|
||||||
@@ -236,7 +236,7 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
|
|||||||
const carried = readCarriedInline(span)
|
const carried = readCarriedInline(span)
|
||||||
if (carried !== undefined) {
|
if (carried !== undefined) {
|
||||||
if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
|
if (carried.fault !== undefined) return faulted(carried.fault, scan.path)
|
||||||
return success({ kind: 'carry', node: carried.value })
|
return success({ kind: 'ownMarks', node: carried.value })
|
||||||
}
|
}
|
||||||
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success(textBreak) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.path)
|
if (span.name === textBreakName) return span.content === undefined && span.attributes.size === 0 ? success(textBreak) : failure('unsupported-node-shape', `${textBreakName} spells the bare leaf form, ${textBreakSpelling}: this one spells more`, scan.path)
|
||||||
const text = readTextDirective(span)
|
const text = readTextDirective(span)
|
||||||
@@ -250,14 +250,14 @@ function directivePiece(scan: Scan, span: DirectiveSpan, index: number): Result<
|
|||||||
if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this content slot: a slot holds one text node`, scan.path)
|
if (content?.some(isTextBreak) === true) return failure('unsupported-node-shape', `delete ${textBreakSpelling} from this content slot: a slot holds one text node`, scan.path)
|
||||||
const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.path)
|
const node = readInlineDirectiveNode(span.name, span.attributes, content === undefined ? undefined : adfNodes(content), scan.path)
|
||||||
if (!node.ok) return node
|
if (!node.ok) return node
|
||||||
return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : { kind: 'carry', node: node.value })
|
return success(node.value.marks === undefined ? { kind: 'nodes', nodes: [node.value] } : { kind: 'ownMarks', node: node.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result<Piece> {
|
function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotContent | undefined, index: number): Result<Piece> {
|
||||||
if (slot === undefined || slot.nodes.length === 0) {
|
if (slot === undefined || slot.nodes.length === 0) {
|
||||||
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path)
|
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path)
|
||||||
}
|
}
|
||||||
if (slot.carry) return failure('unsupported-node-shape', carriedInMark, scan.path)
|
if (slot.nodes.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
|
||||||
const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, slot.nodes, index) : undefined
|
const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, slot.nodes, index) : undefined
|
||||||
if (refused !== undefined) return refused
|
if (refused !== undefined) return refused
|
||||||
return success({ kind: 'nodes', nodes: applyMark(slot.nodes, mark) })
|
return success({ kind: 'nodes', nodes: applyMark(slot.nodes, mark) })
|
||||||
@@ -299,7 +299,7 @@ function assemble(scan: Scan): Result<Scanned> {
|
|||||||
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
|
if (holdsImage(scan.pieces)) return failure('unmappable-image', imageAlone, scan.path)
|
||||||
const nodes = resolveNodes(scan.pieces, scan, true)
|
const nodes = resolveNodes(scan.pieces, scan, true)
|
||||||
if (!nodes.ok) return nodes
|
if (!nodes.ok) return nodes
|
||||||
return success({ carry: holdsCarry(scan.pieces), nodes: nodes.value })
|
return success({ nodes: nodes.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
// spec/flavour.md, Inline nodes: the leaf builds no node, so only the pair it parts spells it.
|
// spec/flavour.md, Inline nodes: the leaf builds no node, so only the pair it parts spells it.
|
||||||
@@ -327,7 +327,7 @@ function isTextBreak(item: Inline): item is TextBreak {
|
|||||||
return 'kind' in item && item.kind === 'textBreak'
|
return 'kind' in item && item.kind === 'textBreak'
|
||||||
}
|
}
|
||||||
|
|
||||||
// The nodes the items hold, carried ones unwrapped and text breaks dropped.
|
// The nodes the items hold, own-marks ones unwrapped and text breaks dropped.
|
||||||
function adfNodes(items: readonly Inline[]): AdfNode[] {
|
function adfNodes(items: readonly Inline[]): AdfNode[] {
|
||||||
const nodes: AdfNode[] = []
|
const nodes: AdfNode[] = []
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -337,15 +337,15 @@ function adfNodes(items: readonly Inline[]): AdfNode[] {
|
|||||||
return nodes
|
return nodes
|
||||||
}
|
}
|
||||||
|
|
||||||
function holdsCarry(pieces: readonly Piece[]): boolean {
|
function holdsOwnMarks(item: Inline | Piece): boolean {
|
||||||
return pieces.some((piece) => piece.kind === 'carry')
|
return 'kind' in item && item.kind === 'ownMarks'
|
||||||
}
|
}
|
||||||
|
|
||||||
function holdsImage(pieces: readonly Piece[]): boolean {
|
function holdsImage(pieces: readonly Piece[]): boolean {
|
||||||
return pieces.some((piece) => piece.kind === 'image')
|
return pieces.some((piece) => piece.kind === 'image')
|
||||||
}
|
}
|
||||||
|
|
||||||
// A carry rides its own piece: the carried node's marks restore with it rather than riding a spelling, so the guard below answers for it.
|
// A node whose marks are its own rides its own piece, its marks restoring with it rather than riding a spelling, so the guard below answers for it.
|
||||||
function holdsLink(pieces: readonly Piece[]): boolean {
|
function holdsLink(pieces: readonly Piece[]): boolean {
|
||||||
return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes))
|
return pieces.some((piece) => piece.kind === 'nodes' && marksLink(piece.nodes))
|
||||||
}
|
}
|
||||||
@@ -447,7 +447,7 @@ function closeLink(scan: Scan, at: number, inner: readonly Piece[], definition:
|
|||||||
return success(false)
|
return success(false)
|
||||||
}
|
}
|
||||||
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
|
if (holdsImage(inner)) return failure('unmappable-image', imageAlone, scan.path)
|
||||||
if (holdsCarry(inner)) return failure('unsupported-node-shape', carriedInMark, scan.path)
|
if (inner.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
|
||||||
const resolved = resolveNodes(inner, scan, true)
|
const resolved = resolveNodes(inner, scan, true)
|
||||||
if (!resolved.ok) return resolved
|
if (!resolved.ok) return resolved
|
||||||
const nodes = resolved.value
|
const nodes = resolved.value
|
||||||
@@ -506,12 +506,12 @@ function resolveNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean)
|
|||||||
const runs = delimiterRuns(pieces)
|
const runs = delimiterRuns(pieces)
|
||||||
const pairings = matchEmphasis(runs)
|
const pairings = matchEmphasis(runs)
|
||||||
writeUnpaired(nodes, runs, pairings)
|
writeUnpaired(nodes, runs, pairings)
|
||||||
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', carriedInMark, scan.path)
|
if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
|
||||||
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
|
markHighlights(pieces, nodes, highlights ? pairedHighlights(pieces, nodes) : [])
|
||||||
return success(mergeReadText(nodes.flat()))
|
return success(mergeReadText(nodes.flat()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// A text break and a carried node are walls: the text on either side joins only its own side.
|
// A text break and a node whose marks are its own are walls: the text on either side joins only its own side.
|
||||||
function mergeReadText(items: readonly Inline[]): Inline[] {
|
function mergeReadText(items: readonly Inline[]): Inline[] {
|
||||||
const merged: Inline[] = []
|
const merged: Inline[] = []
|
||||||
let run: AdfNode[] = []
|
let run: AdfNode[] = []
|
||||||
@@ -532,7 +532,7 @@ function mergeReadText(items: readonly Inline[]): Inline[] {
|
|||||||
// A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it.
|
// A highlight delimiter holds an empty text node until it pairs, so the emphasis around it marks it.
|
||||||
function pieceNodes(piece: Piece): Inline[] {
|
function pieceNodes(piece: Piece): Inline[] {
|
||||||
switch (piece.kind) {
|
switch (piece.kind) {
|
||||||
case 'carry':
|
case 'ownMarks':
|
||||||
return [piece]
|
return [piece]
|
||||||
case 'highlight':
|
case 'highlight':
|
||||||
return [{ text: '', type: 'text' }]
|
return [{ text: '', type: 'text' }]
|
||||||
@@ -577,7 +577,7 @@ function markPairings(pieces: readonly Piece[], nodes: Inline[][], pairings: rea
|
|||||||
for (const pairing of pairings) {
|
for (const pairing of pairings) {
|
||||||
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
|
const mark: AdfMark = { type: markType(pairing.opener.character, pairing.used) }
|
||||||
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) {
|
for (let index = pairing.opener.index + 1; index < pairing.closer.index; index += 1) {
|
||||||
if (pieces[index]?.kind === 'carry') return false
|
if (pieces[index]?.kind === 'ownMarks') return false
|
||||||
nodes[index] = applyMark(nodes[index] ?? [], mark)
|
nodes[index] = applyMark(nodes[index] ?? [], mark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function reduceToPlain(document: AdfDocument): Result<AdfDocument> {
|
|||||||
const raw = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
|
const raw = reduceBlocks(nodeContent(document), { depth: 0, memo: new Map(), path: [] })
|
||||||
return !raw.ok && raw.error.code === blocks.error.code ? raw : blocks
|
return !raw.ok && raw.error.code === blocks.error.code ? raw : blocks
|
||||||
}
|
}
|
||||||
return success({ content: nodeContent(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 })).slice(), type: 'doc', version: 1 })
|
return success(toEditorNormal({ content: blocks.value, type: 'doc', version: 1 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
|
function reduceBlocks(nodes: readonly AdfNode[], reduction: Reduction): Result<AdfNode[]> {
|
||||||
|
|||||||
@@ -50,14 +50,15 @@ test('reads negative zero as zero, as JSON does', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('reads an empty attrs object, marks array or content array as the absent key', () => {
|
test('reads an empty attrs object, marks array or content array as the absent key, but on doc', () => {
|
||||||
const paragraph: AdfNode = { attrs: {}, content: [{ attrs: {}, marks: [], text: 'a', type: 'text' }, { marks: [{ attrs: {}, type: 'em' }], text: 'b', type: 'text' }], marks: [], type: 'paragraph' }
|
const paragraph: AdfNode = { attrs: {}, content: [{ attrs: {}, marks: [], text: 'a', type: 'text' }, { marks: [{ attrs: {}, type: 'em' }], text: 'b', type: 'text' }], marks: [], type: 'paragraph' }
|
||||||
assert.deepEqual(toEditorNormal({ content: [paragraph, { content: [], type: 'rule' }], type: 'doc', version: 1 }), {
|
assert.deepEqual(toEditorNormal({ content: [paragraph, { content: [], type: 'rule' }], type: 'doc', version: 1 }), {
|
||||||
content: [{ content: [{ text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: 'b', type: 'text' }], type: 'paragraph' }, { type: 'rule' }],
|
content: [{ content: [{ text: 'a', type: 'text' }, { marks: [{ type: 'em' }], text: 'b', type: 'text' }], type: 'paragraph' }, { type: 'rule' }],
|
||||||
type: 'doc',
|
type: 'doc',
|
||||||
version: 1,
|
version: 1,
|
||||||
})
|
})
|
||||||
assert.deepEqual(toEditorNormal({ content: [], type: 'doc', version: 1 }), { type: 'doc', version: 1 })
|
assert.deepEqual(toEditorNormal({ content: [], type: 'doc', version: 1 }), { content: [], type: 'doc', version: 1 })
|
||||||
|
assert.deepEqual(toEditorNormal({ type: 'doc', version: 1 }), { type: 'doc', version: 1 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('normalizes blocks and mark attributes nesting far past the levels a recursive walk survives', () => {
|
test('normalizes blocks and mark attributes nesting far past the levels a recursive walk survives', () => {
|
||||||
@@ -75,3 +76,8 @@ test('normalizes blocks and mark attributes nesting far past the levels a recurs
|
|||||||
const merged = toEditorNormal({ content: [{ content: [{ marks, text: 'a', type: 'text' }, { marks, text: 'b', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 })
|
const merged = toEditorNormal({ content: [{ content: [{ marks, text: 'a', type: 'text' }, { marks, text: 'b', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 })
|
||||||
assert.deepEqual(merged.content?.[0]?.content?.map((text) => text.text), ['ab'])
|
assert.deepEqual(merged.content?.[0]?.content?.map((text) => text.text), ['ab'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('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' }])
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
|
||||||
import { joinsWhenEditorNormal, normalAttributes, normalMark } from '../adjacent-text.ts'
|
import type { JsonValue } from '../../json-value.ts'
|
||||||
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||||
|
import { joinsWhenRead } from '../adjacent-text.ts'
|
||||||
|
|
||||||
|
type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
|
||||||
|
|
||||||
type NodeHolder = { content?: AdfNode[] }
|
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.
|
||||||
|
export function joinsWhenEditorNormal(previous: AdfNode, node: AdfNode): boolean {
|
||||||
|
return joinsWhenRead(normalNode(previous), normalNode(node))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameMarkWhenEditorNormal(left: AdfMark, right: AdfMark): boolean {
|
||||||
|
return identicalMark(normalMark(left), normalMark(right))
|
||||||
|
}
|
||||||
|
|
||||||
export function toEditorNormal(document: AdfDocument): AdfDocument {
|
export function toEditorNormal(document: AdfDocument): AdfDocument {
|
||||||
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
|
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
|
||||||
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
|
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
|
||||||
@@ -16,6 +28,8 @@ export function toEditorNormal(document: AdfDocument): AdfDocument {
|
|||||||
return holder
|
return holder
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// ADF's schema requires content on doc, so an empty one stays.
|
||||||
|
if (document.content !== undefined && normal.content === undefined) normal.content = []
|
||||||
return normal
|
return normal
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,3 +42,27 @@ function normalNode(node: AdfNode): AdfNode {
|
|||||||
if (node.text !== undefined) normal.text = node.text
|
if (node.text !== undefined) normal.text = node.text
|
||||||
return normal
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { LineContainer } from '../line-container.ts'
|
|||||||
import type { MarkRun } from '../emit/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 { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from '../adjacent-text.ts'
|
import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||||
import { plainLineFallback, type PlainLineFallback } from '../emit/inline-line.ts'
|
import { plainLineFallback, type PlainLineFallback } from '../emit/inline-line.ts'
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
`Bar = 9`
|
`Bar = 9`
|
||||||
|
|
||||||
`Next ID = 63`
|
`Next ID = 66`
|
||||||
|
|
||||||
| Goal | W |
|
| Goal | W |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -24,11 +24,14 @@
|
|||||||
|
|
||||||
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|
| 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 |
|
||||||
| 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 3 | 25.8 |
|
| 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 or a newline inside an emoji, mention or status.** | 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 with empty, missing or content-holding text.** | 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 |
|
| 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 |
|
| 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 |
|
||||||
| 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 |
|
||||||
|
| 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 |
|
||||||
| 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 |
|
| 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 |
|
||||||
@@ -37,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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
| 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 |
|
||||||
@@ -47,28 +50,36 @@
|
|||||||
| 33 | 0.3.0 | | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 8 | 10.7 |
|
| 33 | 0.3.0 | | **Emit a line in time linear in its mark runs, in `adfToMarkdown` and `adfToPlainMarkdown`.** | 4 | 5 | 6 | 9 | 8 | 10.7 |
|
||||||
| 46 | 0.3.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 7, 9 | 10.3 |
|
| 46 | 0.3.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 7, 9 | 10.3 |
|
||||||
| 9 | 0.3.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 6 | 8 | 9 | 10.3 |
|
| 9 | 0.3.0 | | **Ship an online sandbox: a web page with two textboxes converting between ADF and markdown on the library's browser build.** | 2 | 6 | 6 | 8 | 9 | 10.3 |
|
||||||
| 56 | 0.3.0 | principle | **Give each piece of `blocks.ts`'s block-walk state one owner that returns what it changes.** | 4 | 5 | 2 | 4 | 1 | 6.8 |
|
| 56 | 0.3.0 | principle | **Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.** | 4 | 5 | 2 | 4 | 1 | 6.8 |
|
||||||
| 57 | 0.3.0 | principle | **Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.** | 2 | 3 | 1 | 3 | 7 | 1.2 |
|
| 57 | 0.3.0 | principle | **Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.** | 2 | 3 | 1 | 3 | 7 | 1.2 |
|
||||||
| 58 | 0.3.0 | principle | **Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.** | 4 | 6 | 1 | 2 | 7 | -2.2 |
|
| 58 | 0.3.0 | principle | **Port `ci.sh`, `publish.sh` and `docker-runner.sh` to standalone Python scripts.** | 4 | 6 | 1 | 2 | 7 | -2.2 |
|
||||||
| 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 9 | 10.6 |
|
| 8 | 0.4.0 | | **Ship a CLI.** | 3 | 7 | 7 | 6 | 9 | 10.6 |
|
||||||
|
|
||||||
## Details
|
## Details
|
||||||
|
|
||||||
|
### 63. Refuse a cyclic input as `not-an-adf-document` in place of looping forever.
|
||||||
|
|
||||||
|
`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
|
||||||
|
`adfToMarkdown`, `adfToPlainMarkdown` and `isAdfDocument`; README §The guarantees promises no input
|
||||||
|
loops forever.
|
||||||
|
|
||||||
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.
|
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.
|
||||||
|
|
||||||
Lands after items 6, 59, 60, 61 and 62. The CommonMark spec suite also runs against
|
Lands after items 6, 59, 60, 61 and 62. The CommonMark spec suite also runs against
|
||||||
`markdownToHtml`. The README documents HTML as it documents markdown, and its tagline and
|
`markdownToHtml`. The README documents HTML as it documents markdown, and its tagline and
|
||||||
`package.json`'s `description` regain 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, a newline inside an emoji, mention or status, or a text node with empty, missing or content-holding text.
|
||||||
|
|
||||||
`inline-line.ts` refuses a carriage return or NUL in text (`unspellable-character`) and a paragraph
|
`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
|
opening with a code-span run (`unspellable-line-start`); `fencedTexts` refuses the same characters
|
||||||
in a code block; `directive-syntax.ts` refuses a newline in an emoji, mention or status
|
in a code block; `directive-syntax.ts` refuses a newline in an emoji, mention or status
|
||||||
(`unspellable-whitespace`). The inline carry's JSON escapes all of them, so a lossless spelling
|
(`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
|
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. Found by
|
code a cause takes and §Markdown in is a canonical fixpoint, which the maintainer decides. Also a
|
||||||
the README-goals audit, 2026-10-03.
|
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.
|
||||||
|
|
||||||
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
|
### 45. Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.
|
||||||
|
|
||||||
@@ -82,6 +93,15 @@ 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
|
foreign-element set `htmlToAdf` accepts — the set `markdownToAdf` shares (`spec/flavour.md` §Raw
|
||||||
HTML in input). The set sorts per `docs/decisions.md` §Foreign HTML sorts three ways.
|
HTML in input). The set sorts per `docs/decisions.md` §Foreign HTML sorts three ways.
|
||||||
|
|
||||||
|
### 64. Read every JSON key the emitter writes back unchanged on V8, 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.
|
||||||
|
|
||||||
### 43. Give each markdown input its own reader, strict to its own standard.
|
### 43. Give each markdown input its own reader, strict to its own standard.
|
||||||
|
|
||||||
Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a
|
Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a
|
||||||
@@ -90,6 +110,12 @@ a code fence whose info string opens `adf:` becomes the block carry where Common
|
|||||||
caller names the markdown it hands in: CommonMark, read as its spec says, or the lossless flavour,
|
caller names the markdown it hands in: CommonMark, read as its spec says, or the lossless flavour,
|
||||||
read as `spec/flavour.md` says. Breaking: `MIGRATION.md` says which call a caller takes.
|
read as `spec/flavour.md` says. Breaking: `MIGRATION.md` says which call a caller takes.
|
||||||
|
|
||||||
|
### 65. Read a mid-text or titled CommonMark image as Goal 6 allows, in place of refusing it with `unmappable-image`.
|
||||||
|
|
||||||
|
§CommonMark is a subset keeps the image gap and no item ends it: a bot's `See  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.
|
||||||
|
|
||||||
### 49. Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.
|
### 49. Read a list whose bullet or ordered delimiter changes as two lists in the CommonMark reader.
|
||||||
|
|
||||||
Lands after item 43. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads
|
Lands after item 43. Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads
|
||||||
@@ -147,10 +173,12 @@ 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/`.
|
### 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.
|
||||||
|
|
||||||
What moves: the plain reader's code in `markdown-to-adf.ts` — the alert and task-marker reads, the
|
Reading: the alert and task-marker reads in `parse/markdown-to-adf.ts`, the `mintTaskIds` call and
|
||||||
`mintTaskIds` call and the `inlineLeaves` use.
|
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.
|
||||||
|
|
||||||
### 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.
|
||||||
|
|
||||||
@@ -219,11 +247,14 @@ or report the unminified gzip). The figure lands in README §The package beside
|
|||||||
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6
|
dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6
|
||||||
kB.
|
kB.
|
||||||
|
|
||||||
### 56. Give each piece of `blocks.ts`'s block-walk state one owner that returns what it changes.
|
### 56. Give each piece of `blocks.ts`'s block-walk state and `inline-content.ts`'s `Scan` one owner that returns what it changes.
|
||||||
|
|
||||||
Technical principle "One owner per value": the `Walk` record passes through twelve functions that
|
Technical principle "One owner per value": the `Walk` record passes through twelve functions that
|
||||||
mutate it and return `void`; `walk.leaf` alone is written in seven places. `ContainerStack` in the
|
mutate it and return `void`; `walk.leaf` alone is written in seven places. `ContainerStack` in the
|
||||||
same file shows the shape to follow.
|
same file shows the shape to follow.
|
||||||
|
`inline-content.ts`'s `Scan` has the same shape: about fifteen functions write `pending`, `pieces`,
|
||||||
|
`deactivatedBefore` and `openingSpellableLink` and return `void`, and `parseInlineContent` reads a
|
||||||
|
flag `scanInline` leaves on it.
|
||||||
|
|
||||||
### 57. Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.
|
### 57. Make each `ci.sh` leg build what it reads, so one leg run alone tests the current tree.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user