Merge pull request 'Read the block nodes back, and stop a marker change splitting a list' (#40) from block-nodes-read-back into main
CI / gate (push) Successful in 9s
CI / gate (push) Successful in 9s
This commit was merged in pull request #40.
This commit is contained in:
+2
-4
@@ -6,12 +6,10 @@ One directory per contract kind, each landing with its milestone:
|
||||
document, byte for byte, and that `markdownToAdf` must read back to it (AGENTS.md §2). Grouped
|
||||
by what the fixture exercises.
|
||||
- `normalization/` — `<name>.md` + `<name>.json`: markdown input, and the document
|
||||
`markdownToAdf` must build from it. One-way; the markdown is not canonical.
|
||||
`markdownToAdf` must build from it, which must in turn emit and read back to itself. The
|
||||
markdown is not canonical.
|
||||
- `errors/` — `<name>.md`: markdown input that must not convert. A `<name>.error` beside it
|
||||
pins which error.
|
||||
- `unspellable/` — `<name>.json`: ADF `adfToMarkdown` must refuse, the `ConvertErrorCode` in the
|
||||
`<name>.error` beside it. Two populations live here: refusals that stay contract, and documents
|
||||
a maintainer decision (`todo.md`) moves to `round-trip/`.
|
||||
- `real-payloads/` — `<name>.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No
|
||||
expected markdown.
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
unsupported-node-shape
|
||||
@@ -0,0 +1,3 @@
|
||||
:::codeBlock {wrap=true}
|
||||
fn main() {}
|
||||
:::
|
||||
@@ -0,0 +1 @@
|
||||
unsupported-node-shape
|
||||
@@ -0,0 +1,5 @@
|
||||
:::codeBlock {language=rust wrap=true}
|
||||
```
|
||||
fn main() {}
|
||||
```
|
||||
:::
|
||||
@@ -0,0 +1 @@
|
||||
malformed-pipe-table
|
||||
@@ -0,0 +1,3 @@
|
||||
| Part | Qty |
|
||||
| :--- | ---: |
|
||||
| Bolt M8 | 40 |
|
||||
@@ -0,0 +1 @@
|
||||
malformed-pipe-table
|
||||
@@ -0,0 +1,3 @@
|
||||
| Part | Qty |
|
||||
| --- | --- |
|
||||
| Bolt M8 |
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "Bolt M8",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
}
|
||||
],
|
||||
"type": "listItem"
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "Nut M8",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
}
|
||||
],
|
||||
"type": "listItem"
|
||||
},
|
||||
{
|
||||
"type": "listItem"
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "Washer M8",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
}
|
||||
],
|
||||
"type": "listItem"
|
||||
}
|
||||
],
|
||||
"type": "bulletList"
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"order": 1
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "Loosen the clamp",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
}
|
||||
],
|
||||
"type": "listItem"
|
||||
},
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "Lift the cover",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "paragraph"
|
||||
}
|
||||
],
|
||||
"type": "listItem"
|
||||
}
|
||||
],
|
||||
"type": "orderedList"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
- Bolt M8
|
||||
* Nut M8
|
||||
-
|
||||
|
||||
+ Washer M8
|
||||
|
||||
1. Loosen the clamp
|
||||
1) Lift the cover
|
||||
@@ -0,0 +1,6 @@
|
||||
::::expand {title="Full build log"}
|
||||
The build ran for 11 minutes.
|
||||
:::panel warning
|
||||
Three warnings went unread.
|
||||
:::
|
||||
::::
|
||||
@@ -1 +0,0 @@
|
||||
unspelled-block-separation
|
||||
+6
-2
@@ -22,7 +22,9 @@ normalizes to it through the round-trip.
|
||||
between a nested list and a CommonMark block above it — one wherever the nested list's own
|
||||
marker cannot interrupt a paragraph (an ordered list whose first number is not 1, or a list
|
||||
whose first item is empty), whatever block sits above it. Blank lines between items normalize
|
||||
away; ADF does not record tightness.
|
||||
away, and no list opens beside one of its own kind — the marker change CommonMark starts a
|
||||
second list on merges instead: ADF records no tightness, and one `- ` spelling leaves two
|
||||
adjacent lists of a kind no way back.
|
||||
- Blockquotes prefix lines with `> `; a blank line inside a blockquote is a bare `>`.
|
||||
- ATX headings (`#` … `######`); setext input normalizes to ATX.
|
||||
- Code fences ``` with the node's language as info string, the fence lengthened past any backtick
|
||||
@@ -190,7 +192,9 @@ form.
|
||||
(string), `uniqueId` (string), `wrap` (boolean). A language no info string carries back — empty,
|
||||
the reserved `adf`, or holding a backtick, a backslash, a control character, edge whitespace or
|
||||
an entity reference — rides the `language` attribute instead and the fence carries no info
|
||||
string; writing both is a named error.
|
||||
string; writing it in the slot that rule leaves empty, or in both, is a named error. The body is
|
||||
one ordinary code block, and a fence's info string decodes escapes and entity references as any
|
||||
other does.
|
||||
- `heading` — container, inline body. Attributes: `level` (number), `localId` (string). `level` is
|
||||
the `#` count, so a heading carrying none, or one that is no whole number from 1 to 6, has no
|
||||
CommonMark spelling.
|
||||
|
||||
+8
-14
@@ -14,11 +14,10 @@ const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus')
|
||||
const errorsRoot = join(corpusRoot, 'errors')
|
||||
const normalizationRoot = join(corpusRoot, 'normalization')
|
||||
const roundTripRoot = join(corpusRoot, 'round-trip')
|
||||
const unspellableRoot = join(corpusRoot, 'unspellable')
|
||||
|
||||
const emittingDirectories = ['block-nodes', 'combinations', 'commonmark-subset', 'inline-nodes', 'opaque-carry']
|
||||
// A directory joins once every fixture in it reads back to its document.
|
||||
const parsingDirectories = ['commonmark-subset']
|
||||
const parsingDirectories = ['block-nodes', 'commonmark-subset']
|
||||
|
||||
function directoryNames(root: string): string[] {
|
||||
return readdirSync(root, { withFileTypes: true })
|
||||
@@ -61,7 +60,7 @@ function corpusJsonPaths(): string[] {
|
||||
}
|
||||
|
||||
test('every corpus directory is a kind the runner reads', () => {
|
||||
assert.deepEqual(directoryNames(corpusRoot), ['errors', 'normalization', 'round-trip', 'unspellable'])
|
||||
assert.deepEqual(directoryNames(corpusRoot), ['errors', 'normalization', 'round-trip'])
|
||||
})
|
||||
|
||||
test('every round-trip directory emits', () => {
|
||||
@@ -187,23 +186,18 @@ for (const directory of emittingDirectories) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of pairedNames(unspellableRoot, '.json', '.error')) {
|
||||
test(`unspellable/${name} is refused with the error it names`, () => {
|
||||
const parsed: unknown = JSON.parse(readFileSync(join(unspellableRoot, `${name}.json`), 'utf8'))
|
||||
assert.ok(isAdfDocument(parsed), `${name}.json is not an ADF document`)
|
||||
const result = adfToMarkdown(parsed)
|
||||
assert.ok(!result.ok, result.ok ? `emitted ${JSON.stringify(result.value)}` : '')
|
||||
assert.equal(result.error.code, readFileSync(join(unspellableRoot, `${name}.error`), 'utf8').trimEnd())
|
||||
})
|
||||
}
|
||||
|
||||
for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
|
||||
test(`normalization/${name} parses to the document beside it`, () => {
|
||||
test(`normalization/${name} parses to the document beside it, which emits and reads back to itself`, () => {
|
||||
const expected: unknown = JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8'))
|
||||
assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`)
|
||||
const result = markdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8'))
|
||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
||||
assert.deepEqual(result.value, expected)
|
||||
const emitted = adfToMarkdown(result.value)
|
||||
assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`)
|
||||
const again = markdownToAdf(emitted.value)
|
||||
assert.ok(again.ok, again.ok ? '' : `${again.error.code}: ${again.error.message}`)
|
||||
assert.deepEqual(again.value, expected)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { carryName } from './opaque-carry.ts'
|
||||
import { holdsControlCharacter } from './commonmark-grammar.ts'
|
||||
import { holdsEntityReference } from './entity-references.ts'
|
||||
|
||||
export type LanguageSlot = { info: string; kind: 'fence' } | { kind: 'attribute' } | { kind: 'none' }
|
||||
|
||||
// spec/flavour.md, The CommonMark blocks: the one slot a codeBlock's language rides.
|
||||
export function languageSlot(language: JsonValue | undefined): LanguageSlot {
|
||||
if (language === undefined) return { kind: 'none' }
|
||||
if (typeof language !== 'string' || language === '' || language === carryName) return { kind: 'attribute' }
|
||||
if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return { kind: 'attribute' }
|
||||
return { info: language, kind: 'fence' }
|
||||
}
|
||||
@@ -60,7 +60,7 @@ const firstCharacterOpeners = [atxHeadingOpener, /^>/, bulletListOpener, codeFen
|
||||
const emailNameSource = "[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+"
|
||||
const emailLabelSource = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
|
||||
const emailAutolink = new RegExp(`<${emailNameSource}@${emailLabelSource}(?:\\.${emailLabelSource})*>`, 'y')
|
||||
const orderedListOpener = /^(\d{1,9})([.)])(?:[ \t]|$)/
|
||||
const orderedListOpener = /^(\d{1,9})(?:[.)])(?:[ \t]|$)/
|
||||
const setextUnderline = /^(=+|-+)[ \t]*$/
|
||||
const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/
|
||||
const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u
|
||||
@@ -171,13 +171,13 @@ export function isUnicodeWhitespace(character: string): boolean {
|
||||
}
|
||||
|
||||
// `start` is the list's first number, `undefined` for a bullet.
|
||||
export function listMarker(line: string): { delimiter: string; start: number | undefined; width: number } | undefined {
|
||||
export function listMarker(line: string): { start: number | undefined; width: number } | undefined {
|
||||
const ordered = orderedListOpener.exec(line)
|
||||
if (ordered !== null) {
|
||||
const digits = ordered[1] ?? ''
|
||||
return { delimiter: ordered[2] ?? '', start: Number(digits), width: digits.length + 1 }
|
||||
return { start: Number(digits), width: digits.length + 1 }
|
||||
}
|
||||
return bulletListOpener.test(line) ? { delimiter: line.charAt(0), start: undefined, width: 1 } : undefined
|
||||
return bulletListOpener.test(line) ? { start: undefined, width: 1 } : undefined
|
||||
}
|
||||
|
||||
export function markerInterruptsParagraph(start: number | undefined, empty: boolean): boolean {
|
||||
@@ -223,6 +223,18 @@ export function setextHeadingLevel(line: string): number | undefined {
|
||||
return underline.startsWith('=') ? 1 : 2
|
||||
}
|
||||
|
||||
export function trimSpace(text: string): string {
|
||||
return text.replace(/^[ \t]+|[ \t]+$/g, '')
|
||||
function spaceOrTab(character: string): boolean {
|
||||
return character === ' ' || character === '\t'
|
||||
}
|
||||
|
||||
export function trimSpace(text: string): string {
|
||||
let start = 0
|
||||
while (start < text.length && spaceOrTab(text.charAt(start))) start += 1
|
||||
return trimTrailingSpace(text.slice(start))
|
||||
}
|
||||
|
||||
export function trimTrailingSpace(text: string): string {
|
||||
let end = text.length
|
||||
while (end > 0 && spaceOrTab(text.charAt(end - 1))) end -= 1
|
||||
return text.slice(0, end)
|
||||
}
|
||||
|
||||
@@ -472,15 +472,15 @@ test('refuses the content a directive body has no room for', () => {
|
||||
assert.equal(code(adfToMarkdown(document({ text: 'x', type: 'panel' }))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('separates two directive blocks in a container body by one line, two CommonMark blocks by a blank one', () => {
|
||||
test('separates blocks in a container body by a blank line only where the fence is not separation already', () => {
|
||||
const text = (value: string): AdfNode => ({ content: [{ text: value, type: 'text' }], type: 'paragraph' })
|
||||
const panel = (...content: AdfNode[]): AdfDocument => document({ attrs: { panelType: 'info' }, content, type: 'panel' })
|
||||
assert.equal(markdown(adfToMarkdown(panel(text('a'), text('b')))), ':::panel info\na\n\nb\n:::\n')
|
||||
const caption: AdfNode = { content: [{ text: 'c', type: 'text' }], type: 'caption' }
|
||||
assert.equal(markdown(adfToMarkdown(panel(caption, caption))), '::::panel info\n:::caption\nc\n:::\n:::caption\nc\n:::\n::::\n')
|
||||
assert.equal(code(adfToMarkdown(panel(text('a'), caption))), 'unspelled-block-separation')
|
||||
assert.equal(code(adfToMarkdown(panel(caption, text('a')))), 'unspelled-block-separation')
|
||||
assert.equal(code(adfToMarkdown(panel(paragraph(), text('a')))), 'unspelled-block-separation')
|
||||
assert.equal(markdown(adfToMarkdown(panel(text('a'), caption))), '::::panel info\na\n:::caption\nc\n:::\n::::\n')
|
||||
assert.equal(markdown(adfToMarkdown(panel(caption, text('a')))), '::::panel info\n:::caption\nc\n:::\na\n::::\n')
|
||||
assert.equal(markdown(adfToMarkdown(panel(paragraph(), text('a')))), ':::panel info\n::paragraph\na\n:::\n')
|
||||
})
|
||||
|
||||
test('spells the image form for exactly the centered external media shape', () => {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { BlockDirective } from '../../adf/block-directives.ts'
|
||||
import type { JsonValue } from '../../json-value.ts'
|
||||
import { blockDirective } from '../../adf/block-directives.ts'
|
||||
import { carriedBlock, carryName } from '../opaque-carry.ts'
|
||||
import { carriedBlock } from '../opaque-carry.ts'
|
||||
import { carriesOnly, isAdfDocument } from '../../adf/document.ts'
|
||||
import { emitInlineLine } from './inline-line.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { fencedCodeBlock } from '../backtick-runs.ts'
|
||||
import { holdsControlCharacter, holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts'
|
||||
import { holdsEntityReference } from '../entity-references.ts'
|
||||
import { holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { spellDirectiveHeader } from './block-directive-spelling.ts'
|
||||
import { tryImage } from './image.ts'
|
||||
@@ -62,13 +61,7 @@ function separationBetween(previous: PlacedBlock, next: PlacedBlock, container:
|
||||
}
|
||||
if (container === 'list-item') return success(interruptsParagraph(next.node) ? '\n' : '\n\n')
|
||||
}
|
||||
if (container !== 'directive' || plainPair) return success('\n\n')
|
||||
if (previous.spelling === 'directive' && next.spelling === 'directive') return success('\n')
|
||||
return failure(
|
||||
'unspelled-block-separation',
|
||||
`the canonical form leaves the separation between a ${previous.spelling} and a ${next.spelling} block in a container body unspelled`,
|
||||
next.path,
|
||||
)
|
||||
return success(container === 'directive' && !plainPair ? '\n' : '\n\n')
|
||||
}
|
||||
|
||||
function interruptsParagraph(node: AdfNode): boolean {
|
||||
@@ -154,20 +147,21 @@ function emitBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number): R
|
||||
|
||||
function emitCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||
if (!carriesOnly(node, ['language'])) return undefined
|
||||
const info = fenceInfo(node.attrs?.['language'])
|
||||
if (info === undefined) return undefined
|
||||
const slot = languageSlot(node.attrs?.['language'])
|
||||
if (slot.kind === 'attribute') return undefined
|
||||
const text = codeBlockText(node, path)
|
||||
if (!text.ok) return text
|
||||
return success(commonMarkText(fencedCodeBlock(info, text.value)))
|
||||
return success(commonMarkText(fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
|
||||
}
|
||||
|
||||
function emitCodeDirective(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath): Result<EmittedBlock> {
|
||||
const info = fenceInfo(node.attrs?.['language'])
|
||||
const header = spellDirectiveHeader(node, directive, info === undefined ? [] : ['language'])
|
||||
const slot = languageSlot(node.attrs?.['language'])
|
||||
const header = spellDirectiveHeader(node, directive, slot.kind === 'attribute' ? [] : ['language'])
|
||||
if (header === undefined) return commonMarkLine(carriedBlock(node, path))
|
||||
const text = codeBlockText(node, path)
|
||||
if (!text.ok) return text
|
||||
return success({ fenceColons: 3, spelling: 'directive', text: `:::${header}\n${fencedCodeBlock(info ?? '', text.value)}\n:::` })
|
||||
const info = slot.kind === 'fence' ? slot.info : ''
|
||||
return success({ fenceColons: 3, spelling: 'directive', text: `:::${header}\n${fencedCodeBlock(info, text.value)}\n:::` })
|
||||
}
|
||||
|
||||
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
@@ -191,14 +185,6 @@ function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
|
||||
return success(text)
|
||||
}
|
||||
|
||||
// spec/flavour.md, The CommonMark blocks.
|
||||
function fenceInfo(language: JsonValue | undefined): string | undefined {
|
||||
if (language === undefined) return ''
|
||||
if (typeof language !== 'string' || language === '' || language === carryName) return undefined
|
||||
if (/[`\\]/.test(language) || holdsControlCharacter(language) || language !== language.trim() || holdsEntityReference(language)) return undefined
|
||||
return language
|
||||
}
|
||||
|
||||
function emitHeading(node: AdfNode, path: ConvertErrorPath): Result<EmittedBlock> | undefined {
|
||||
if (!carriesOnly(node, ['level'])) return undefined
|
||||
const level = node.attrs?.['level']
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import { carriesOnly } from '../../adf/document.ts'
|
||||
import { spellPipeDelimiter, spellPipeRow } from '../pipe-table-syntax.ts'
|
||||
import { tryPipeCell } from './inline-line.ts'
|
||||
import type { ConvertErrorPath } from '../../result.ts'
|
||||
|
||||
@@ -15,8 +16,8 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | un
|
||||
if (line === undefined) return undefined
|
||||
cells.push(line)
|
||||
}
|
||||
lines.push(`| ${cells.join(' | ')} |`)
|
||||
if (rowIndex === 0) lines.push(`| ${cells.map(() => '---').join(' | ')} |`)
|
||||
lines.push(spellPipeRow(cells))
|
||||
if (rowIndex === 0) lines.push(spellPipeDelimiter(cells.length))
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
openingHtmlBlock,
|
||||
setextHeadingLevel,
|
||||
} from '../commonmark-grammar.ts'
|
||||
import { isPipeAlignment, isPipeDelimiter, malformedPipeTable, pipeCells } from '../pipe-table-syntax.ts'
|
||||
import { malformedDirective, readDirectiveLine } from '../directive-syntax.ts'
|
||||
import { readLinkDefinitions } from './link-reference-definitions.ts'
|
||||
|
||||
@@ -28,6 +29,7 @@ export type Block =
|
||||
| { kind: 'heading'; level: number; text: string }
|
||||
| { kind: 'paragraph'; text: string }
|
||||
| { kind: 'rule' }
|
||||
| { kind: 'table'; rows: string[][] }
|
||||
|
||||
export type ParsedBlocks = { blocks: Block[]; definitions: Map<string, LinkDefinition> }
|
||||
|
||||
@@ -40,15 +42,16 @@ type OpenDirective = { blocks: Block[]; colons: number; index: number; kind: 'di
|
||||
type OpenContainer =
|
||||
| Extract<Block, { kind: 'blockquote' }>
|
||||
| OpenDirective
|
||||
| { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock; marker: string }
|
||||
| { blocks: Block[]; indentation: number; kind: 'item'; list: ListBlock }
|
||||
|
||||
type OpenLeaf =
|
||||
| { closer: RegExp | undefined; construct: string; kind: 'html' }
|
||||
| { held: string[]; kind: 'indented-code'; lines: string[] }
|
||||
| { indentation: number; info: string; kind: 'fenced-code'; lines: string[]; marker: string }
|
||||
| { kind: 'paragraph'; lines: string[] }
|
||||
| { kind: 'pipe-table'; rows: [string[], ...string[][]] }
|
||||
|
||||
type ContainerStart = { kind: 'blockquote'; rest: Line } | { fresh: boolean; indentation: number; kind: 'item'; list: ListBlock; marker: string; rest: Line }
|
||||
type ContainerStart = { kind: 'blockquote'; rest: Line } | { fresh: boolean; indentation: number; kind: 'item'; list: ListBlock; rest: Line }
|
||||
|
||||
// The line from an absolute column on: a tab a cut splits keeps the stop it is measured against.
|
||||
type Line = { column: number; text: string }
|
||||
@@ -151,13 +154,12 @@ function itemStart(line: Line, opener: Line, paragraphOpen: boolean, enclosing:
|
||||
const spaces = leadingColumns(after)
|
||||
const padding = blank || spaces > indentedCodeColumns ? 1 : spaces
|
||||
const kind = marker.start === undefined ? 'bulletList' : 'orderedList'
|
||||
const continued = enclosing?.kind === 'item' && enclosing.list.kind === kind && enclosing.marker === marker.delimiter
|
||||
const continued = enclosing?.kind === 'item' && enclosing.list.kind === kind
|
||||
return {
|
||||
fresh: !continued,
|
||||
indentation: leadingColumns(line) + marker.width + padding,
|
||||
kind: 'item',
|
||||
list: continued ? enclosing.list : openList(marker.start),
|
||||
marker: marker.delimiter,
|
||||
rest: blank ? after : removeColumns(after, padding),
|
||||
}
|
||||
}
|
||||
@@ -174,9 +176,19 @@ function openContainer(walk: Walk, start: ContainerStart): void {
|
||||
walk.stack.push(blockquote)
|
||||
return
|
||||
}
|
||||
if (start.fresh) currentBlocks(walk).push(start.list)
|
||||
start.list.items.push(blocks)
|
||||
walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list: start.list, marker: start.marker })
|
||||
const list = openedList(walk, start)
|
||||
list.items.push(blocks)
|
||||
walk.stack.push({ blocks, indentation: start.indentation, kind: 'item', list })
|
||||
}
|
||||
|
||||
// Two lists of a kind never sit adjacent: one `- ` spelling reads them back as one (spec/flavour.md).
|
||||
function openedList(walk: Walk, start: Extract<ContainerStart, { kind: 'item' }>): ListBlock {
|
||||
if (!start.fresh) return start.list
|
||||
const blocks = currentBlocks(walk)
|
||||
const previous = blocks.at(-1)
|
||||
if ((previous?.kind === 'bulletList' || previous?.kind === 'orderedList') && previous.kind === start.list.kind) return previous
|
||||
blocks.push(start.list)
|
||||
return start.list
|
||||
}
|
||||
|
||||
function closeContainers(walk: Walk, depth: number): void {
|
||||
@@ -262,6 +274,14 @@ function readBlockLine(walk: Walk, line: Line): void {
|
||||
if (leaf.closer === undefined ? blankLine.test(line.text) : leaf.closer.test(line.text)) closeLeaf(walk)
|
||||
return
|
||||
}
|
||||
if (leaf?.kind === 'pipe-table') {
|
||||
const cells = pipeCells(removeColumns(line, largestOpenerIndentation).text)
|
||||
if (cells !== undefined) {
|
||||
leaf.rows.push(cells)
|
||||
return
|
||||
}
|
||||
closeLeaf(walk)
|
||||
}
|
||||
if (leaf?.kind === 'indented-code') {
|
||||
if (readIndentedCodeLine(leaf, line)) return
|
||||
closeLeaf(walk)
|
||||
@@ -297,9 +317,10 @@ function openLeaf(walk: Walk, line: Line): void {
|
||||
else pushFault(walk, directive.fault)
|
||||
return
|
||||
}
|
||||
if (claimsPipeLine(opener)) {
|
||||
const cells = pipeCells(opener)
|
||||
if (cells !== undefined) {
|
||||
closeLeaf(walk)
|
||||
pushFault(walk, { code: 'malformed-pipe-table', message: 'the line claims a pipe table and parses as none' })
|
||||
walk.leaf = { kind: 'pipe-table', rows: [cells] }
|
||||
return
|
||||
}
|
||||
if (readLineBlock(walk, opener)) return
|
||||
@@ -358,9 +379,27 @@ function closeLeaf(walk: Walk): void {
|
||||
}
|
||||
walk.leaf = undefined
|
||||
if (leaf.kind === 'html') currentBlocks(walk).push({ construct: leaf.construct, kind: 'html' })
|
||||
else if (leaf.kind === 'pipe-table') currentBlocks(walk).push(pipeTableBlock(leaf.rows))
|
||||
else currentBlocks(walk).push({ kind: 'code', language: leaf.kind === 'fenced-code' ? decodeTextEscapes(leaf.info) : '', text: leaf.lines.join('\n') })
|
||||
}
|
||||
|
||||
function pipeTableBlock(rows: readonly [string[], ...string[][]]): Block {
|
||||
const [header, delimiter, ...body] = rows
|
||||
if (delimiter !== undefined && delimiter.some(isPipeAlignment)) {
|
||||
return faultedBlock('a pipe table carries no column alignment ADF could hold')
|
||||
}
|
||||
if (delimiter === undefined || !delimiter.every(isPipeDelimiter)) {
|
||||
return faultedBlock('a pipe table underlines its header with a row of `-` runs')
|
||||
}
|
||||
const ragged = [delimiter, ...body].find((row) => row.length !== header.length)
|
||||
if (ragged !== undefined) return faultedBlock(`a pipe table row holds ${ragged.length} cells where its header holds ${header.length}`)
|
||||
return { kind: 'table', rows: [header, ...body] }
|
||||
}
|
||||
|
||||
function faultedBlock(message: string): Block {
|
||||
return { fault: malformedPipeTable(message), kind: 'fault' }
|
||||
}
|
||||
|
||||
function takeParagraph(walk: Walk): string | undefined {
|
||||
const leaf = walk.leaf
|
||||
if (leaf?.kind !== 'paragraph') return undefined
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { EmphasisPairing } from '../emphasis-matching.ts'
|
||||
import type { LinkDefinition } from '../link-syntax.ts'
|
||||
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink } from '../commonmark-grammar.ts'
|
||||
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark-grammar.ts'
|
||||
import { backtickRun, closingBacktickRun } from '../backtick-runs.ts'
|
||||
import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
@@ -28,9 +28,7 @@ type Run = { canClose: boolean; canOpen: boolean; character: string; index: numb
|
||||
|
||||
type Scan = { definitions: LinkDefinitions; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string }
|
||||
|
||||
const hardBreakSpaces = / {2,}$/
|
||||
const imageAlone = 'an image fits only as a paragraph of its own'
|
||||
const trailingSpace = /[ \t]+$/
|
||||
|
||||
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<InlineContent> {
|
||||
const scan: Scan = { definitions, path, pending: '', pieces: [], source }
|
||||
@@ -95,7 +93,7 @@ function readBackslash(scan: Scan, index: number): number {
|
||||
}
|
||||
|
||||
function readLineEnding(scan: Scan, index: number): number {
|
||||
const hard = hardBreakSpaces.test(scan.pending)
|
||||
const hard = scan.pending.endsWith(' ')
|
||||
flush(scan, true)
|
||||
if (hard) pushNode(scan, { type: 'hardBreak' })
|
||||
else scan.pending = ' '
|
||||
@@ -154,7 +152,7 @@ function readDirective(scan: Scan, index: number): Result<number> {
|
||||
}
|
||||
|
||||
function flush(scan: Scan, strip: boolean): void {
|
||||
const raw = strip ? scan.pending.replace(trailingSpace, '') : scan.pending
|
||||
const raw = strip ? trimTrailingSpace(scan.pending) : scan.pending
|
||||
scan.pending = ''
|
||||
if (raw !== '') scan.pieces.push({ kind: 'nodes', nodes: [{ text: decodeTextEscapes(raw), type: 'text' }] })
|
||||
}
|
||||
|
||||
@@ -67,6 +67,18 @@ function image(url: string, alt?: string): AdfNode {
|
||||
return { attrs: { layout: 'center' }, content: [media], type: 'mediaSingle' }
|
||||
}
|
||||
|
||||
function cell(type: string, ...content: AdfNode[]): AdfNode {
|
||||
return { content: [content.length === 0 ? { type: 'paragraph' } : { content, type: 'paragraph' }], type }
|
||||
}
|
||||
|
||||
function row(...cells: AdfNode[]): AdfNode {
|
||||
return { content: cells, type: 'tableRow' }
|
||||
}
|
||||
|
||||
function table(...rows: AdfNode[]): AdfNode {
|
||||
return { content: rows, type: 'table' }
|
||||
}
|
||||
|
||||
test('builds an empty document from input holding no block', () => {
|
||||
assert.deepEqual(markdownToAdf(''), { ok: true, value: { type: 'doc', version: 1 } })
|
||||
assert.deepEqual(content(markdownToAdf('\n \n\t\n')), [])
|
||||
@@ -114,6 +126,70 @@ test('reads a fenced code block, its info string the language', () => {
|
||||
assert.deepEqual(content(markdownToAdf('```\n- x\n> y\n```\n')), [{ content: [text('- x\n> y')], type: 'codeBlock' }])
|
||||
})
|
||||
|
||||
test('reads the codeBlock directive body as the node content, the info string its language', () => {
|
||||
const fenced = ':::codeBlock {wrap=true}\n```rust\nfn main() {}\n```\n:::\n'
|
||||
assert.deepEqual(content(markdownToAdf(fenced)), [
|
||||
{ attrs: { language: 'rust', wrap: true }, content: [text('fn main() {}')], type: 'codeBlock' },
|
||||
])
|
||||
assert.deepEqual(content(markdownToAdf(':::codeBlock {wrap=true}\n```\n```\n:::\n')), [{ attrs: { wrap: true }, type: 'codeBlock' }])
|
||||
assert.deepEqual(content(markdownToAdf(':::codeBlock {language=""}\n```\nx\n```\n:::\n')), [
|
||||
{ attrs: { language: '' }, content: [text('x')], type: 'codeBlock' },
|
||||
])
|
||||
assert.deepEqual(content(markdownToAdf(':::codeBlock {wrap=true}\n fn()\n:::\n')), [
|
||||
{ attrs: { wrap: true }, content: [text('fn()')], type: 'codeBlock' },
|
||||
])
|
||||
// The body is a CommonMark fence, so its info string decodes escapes the way any other fence's does.
|
||||
assert.deepEqual(content(markdownToAdf(':::codeBlock {wrap=true}\n```\\#c\nx\n```\n:::\n')), [
|
||||
{ attrs: { language: '#c', wrap: true }, content: [text('x')], type: 'codeBlock' },
|
||||
])
|
||||
})
|
||||
|
||||
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'
|
||||
assert.equal(content(markdownToAdf(':::codeBlock {language=rust wrap=true}\n```\nx\n```\n:::\n')), slot)
|
||||
assert.equal(content(markdownToAdf(':::codeBlock {language=rust}\n```sql\nx\n```\n:::\n')), slot)
|
||||
assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\n```adf\nx\n```\n:::\n')), slot)
|
||||
assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\n```a\\b\nx\n```\n:::\n')), slot)
|
||||
assert.equal(content(markdownToAdf('::codeBlock {wrap=true}\n')), 'unsupported-node-shape: codeBlock spells its body in the container form, :::')
|
||||
})
|
||||
|
||||
test('reads a pipe table into the header row and the body rows under it', () => {
|
||||
const pipes = '| Part | Note |\n| --- | --- |\n| Nut \\| washer | `8.8` |\n| Spare | |\n'
|
||||
assert.deepEqual(content(markdownToAdf(pipes)), [
|
||||
table(
|
||||
row(cell('tableHeader', text('Part')), cell('tableHeader', text('Note'))),
|
||||
row(cell('tableCell', text('Nut | washer')), cell('tableCell', codeSpan('8.8'))),
|
||||
row(cell('tableCell', text('Spare')), cell('tableCell')),
|
||||
),
|
||||
])
|
||||
assert.deepEqual(content(markdownToAdf('| Part\n| -\n')), [table(row(cell('tableHeader', text('Part'))))])
|
||||
assert.deepEqual(content(markdownToAdf(' | Part |\n | --- |\n')), [table(row(cell('tableHeader', text('Part'))))])
|
||||
})
|
||||
|
||||
test('claims the line a pipe opens and gives the rest back to the block walk', () => {
|
||||
const header = table(row(cell('tableHeader', text('a'))))
|
||||
assert.deepEqual(content(markdownToAdf('Part.\n| a |\n| --- |\n')), [paragraph('Part.'), header])
|
||||
assert.deepEqual(content(markdownToAdf('| a |\n| --- |\nPart.\n')), [header, paragraph('Part.')])
|
||||
assert.deepEqual(content(markdownToAdf('> | a |\n> | --- |\n')), [quote(header)])
|
||||
assert.deepEqual(content(markdownToAdf('- | a |\n | --- |\n')), [bulletList(item(header))])
|
||||
assert.deepEqual(content(markdownToAdf('| a |\n| --- |\n x\n')), [header, { content: [text('x')], type: 'codeBlock' }])
|
||||
assert.deepEqual(content(markdownToAdf('\\| a |\n')), [paragraph('| a |')])
|
||||
})
|
||||
|
||||
test('names the pipe table a claimed line does not spell', () => {
|
||||
assert.equal(content(markdownToAdf('| a | b |\n')), 'malformed-pipe-table: a pipe table underlines its header with a row of `-` runs')
|
||||
assert.equal(content(markdownToAdf('| a |\n| x |\n')), 'malformed-pipe-table: a pipe table underlines its header with a row of `-` runs')
|
||||
assert.equal(content(markdownToAdf('| a | b |\n| :--- | ---: |\n')), 'malformed-pipe-table: a pipe table carries no column alignment ADF could hold')
|
||||
assert.equal(content(markdownToAdf('| a | b |\n| --- |\n')), 'malformed-pipe-table: a pipe table row holds 1 cells where its header holds 2')
|
||||
assert.equal(content(markdownToAdf('| a |\n| --- |\n| b | c |\n')), 'malformed-pipe-table: a pipe table row holds 2 cells where its header holds 1')
|
||||
assert.deepEqual(path(markdownToAdf('Part.\n\n| a |\n')), ['content', 1])
|
||||
})
|
||||
|
||||
test('refuses the image a pipe cell holds no ADF node for', () => {
|
||||
assert.equal(content(markdownToAdf('| a |\n| --- |\n|  |\n')), 'unmappable-image: no ADF node carries an image inside a paragraph')
|
||||
assert.deepEqual(path(markdownToAdf('| a |\n| --- |\n|  |\n')), ['content', 0, 'content', 1, 'content', 0, 'content', 0])
|
||||
})
|
||||
|
||||
test('strips the opening fence indentation from the content lines it holds', () => {
|
||||
assert.deepEqual(content(markdownToAdf(' ```\n x\n y\n ```\n')), [{ content: [text(' x\ny')], type: 'codeBlock' }])
|
||||
assert.deepEqual(content(markdownToAdf(' ```\n\tx\n ```\n')), [{ content: [text(' x')], type: 'codeBlock' }])
|
||||
@@ -161,8 +237,8 @@ test('names the directive form a node CommonMark spells refuses', () => {
|
||||
|
||||
// The spelling the emitter refuses gives the emitter's own error, never a second name for it.
|
||||
test('gives back the refusal the CommonMark spelling itself raises', () => {
|
||||
const nested = '::::::::bulletList\n:::::::listItem\n---\n\n::::::bulletList\n:::::listItem\n---\n\n::::bulletList\n:::listItem\n---\n:::\n::::\n:::::\n::::::\n:::::::\n::::::::\n'
|
||||
assert.equal(code(markdownToAdf(nested)), 'unspelled-block-separation')
|
||||
const destination = ':::blockquote\n[t](https://example.com/a\\b)\n:::\n'
|
||||
assert.equal(content(markdownToAdf(destination)), 'unspellable-link-destination: no canonical escape spells a backslash in a link destination')
|
||||
})
|
||||
|
||||
test('names the directive name no node reads back to', () => {
|
||||
@@ -232,7 +308,7 @@ test('names the argument and the body a node takes no reading for', () => {
|
||||
assert.equal(content(markdownToAdf(':::paragraph\n:::\n')), 'unsupported-node-shape: an empty paragraph takes the leaf form, ::')
|
||||
assert.equal(content(markdownToAdf(':::paragraph\nOne.\n\nTwo.\n:::\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body')
|
||||
assert.equal(content(markdownToAdf(':::paragraph\n---\n:::\n')), 'unsupported-node-shape: paragraph takes one paragraph as its body')
|
||||
assert.equal(content(markdownToAdf(':::codeBlock\n```\nx\n```\n:::\n')), 'unsupported-node-shape: the fenced body of codeBlock is unsupported')
|
||||
assert.equal(content(markdownToAdf(':::codeBlock {wrap=true}\nx\n:::\n')), 'unsupported-node-shape: codeBlock takes one code block as its body')
|
||||
assert.equal(content(markdownToAdf(':::paragraph\n\n:::\n')), 'unmappable-image: no ADF node carries an image inside a paragraph')
|
||||
assert.equal(content(markdownToAdf('Part :date[now]{timestamp=1}.\n')), 'unsupported-node-shape: date takes no content')
|
||||
assert.equal(
|
||||
@@ -315,14 +391,18 @@ test('reads a bullet list, the marker width setting the continuation', () => {
|
||||
assert.deepEqual(content(markdownToAdf('-\n')), [bulletList(item())])
|
||||
assert.deepEqual(content(markdownToAdf('- One\n\n Two.\n')), [bulletList(item(paragraph('One'), paragraph('Two.')))])
|
||||
assert.deepEqual(content(markdownToAdf('- Code.\n')), [bulletList(item({ content: [text('Code.')], type: 'codeBlock' }))])
|
||||
assert.deepEqual(content(markdownToAdf('- a\n* b\n')), [bulletList(item(paragraph('a'))), bulletList(item(paragraph('b')))])
|
||||
assert.deepEqual(content(markdownToAdf('- a\n* b\n')), [bulletList(item(paragraph('a')), item(paragraph('b')))])
|
||||
assert.deepEqual(content(markdownToAdf('- a\n\n+ b\n')), [bulletList(item(paragraph('a')), item(paragraph('b')))])
|
||||
assert.deepEqual(content(markdownToAdf('- a\n-\n\n- c\n')), [bulletList(item(paragraph('a')), item(), item(paragraph('c')))])
|
||||
assert.deepEqual(content(markdownToAdf('- a\n1. b\n')), [bulletList(item(paragraph('a'))), orderedList(1, item(paragraph('b')))])
|
||||
assert.deepEqual(content(markdownToAdf('- a\n\n[r]: /u\n\n- b\n')), [bulletList(item(paragraph('a')), item(paragraph('b')))])
|
||||
assert.deepEqual(content(markdownToAdf('-\n\n Part.\n')), [bulletList(item()), paragraph('Part.')])
|
||||
})
|
||||
|
||||
test('reads an ordered list, its first marker the order attribute', () => {
|
||||
assert.deepEqual(content(markdownToAdf('9. Bolt M8\n10. Nut M8\n')), [orderedList(9, item(paragraph('Bolt M8')), item(paragraph('Nut M8')))])
|
||||
assert.deepEqual(content(markdownToAdf('1) Loosen the clamp\n')), [orderedList(1, item(paragraph('Loosen the clamp')))])
|
||||
assert.deepEqual(content(markdownToAdf('1. a\n1) b\n')), [orderedList(1, item(paragraph('a'))), orderedList(1, item(paragraph('b')))])
|
||||
assert.deepEqual(content(markdownToAdf('1. a\n1) b\n')), [orderedList(1, item(paragraph('a')), item(paragraph('b')))])
|
||||
assert.deepEqual(content(markdownToAdf('0. Zero\n')), [orderedList(0, item(paragraph('Zero')))])
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { LinkDefinitions } from './inline-content.ts'
|
||||
import { carryName } from '../opaque-carry.ts'
|
||||
import { commonMarkSpelling } from '../emit/adf-to-markdown.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
import { parseInlineContent } from './inline-content.ts'
|
||||
@@ -50,6 +51,8 @@ function blockNode(block: Block, definitions: LinkDefinitions, path: ConvertErro
|
||||
return paragraphNode(block.text, definitions, path)
|
||||
case 'rule':
|
||||
return success({ type: 'rule' })
|
||||
case 'table':
|
||||
return tableNode(block.rows, definitions, path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,11 +74,39 @@ function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, de
|
||||
return failure('unsupported-node-shape', `${node.type} spells its body in the container form, :::`, path)
|
||||
}
|
||||
if (contentModel === 'none') return failure('unsupported-node-shape', `${node.type} holds no content`, path)
|
||||
if (contentModel === 'code') return failure('unsupported-node-shape', `the fenced body of ${node.type} is unsupported`, path)
|
||||
if (contentModel === 'code') return codeDirectiveNode(node, blocks, path)
|
||||
if (contentModel === 'block') return containerNode(node, blocks, definitions, path, depth)
|
||||
return inlineBodyNode(node, blocks, definitions, path)
|
||||
}
|
||||
|
||||
function codeDirectiveNode(node: AdfNode, blocks: readonly Block[], path: ConvertErrorPath): Result<AdfNode> {
|
||||
const only = blocks.length === 1 ? blocks[0] : undefined
|
||||
if (only?.kind !== 'code') return failure('unsupported-node-shape', `${node.type} takes one code block as its body`, path)
|
||||
const attribute = node.attrs?.['language']
|
||||
const fromFence = only.language !== ''
|
||||
const slot = languageSlot(fromFence ? only.language : attribute)
|
||||
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)
|
||||
}
|
||||
const spelled = fromFence ? { ...node, attrs: { ...node.attrs, language: only.language } } : node
|
||||
return success(withContent(spelled, only.text === '' ? [] : [{ text: only.text, type: 'text' }]))
|
||||
}
|
||||
|
||||
function tableNode(rows: readonly string[][], definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
|
||||
const content: AdfNode[] = []
|
||||
for (const [rowIndex, cells] of rows.entries()) {
|
||||
const type = rowIndex === 0 ? 'tableHeader' : 'tableCell'
|
||||
const row: AdfNode[] = []
|
||||
for (const [cellIndex, cell] of cells.entries()) {
|
||||
const paragraph = contentNode({ type: 'paragraph' }, cell, definitions, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0])
|
||||
if (!paragraph.ok) return paragraph
|
||||
row.push({ content: [paragraph.value], type })
|
||||
}
|
||||
content.push({ content: row, type: 'tableRow' })
|
||||
}
|
||||
return success({ content, type: 'table' })
|
||||
}
|
||||
|
||||
function inlineBodyNode(node: AdfNode, blocks: readonly Block[], definitions: LinkDefinitions, path: ConvertErrorPath): Result<AdfNode> {
|
||||
if (blocks.length === 0) return failure('unsupported-node-shape', `an empty ${node.type} takes the leaf form, ::`, path)
|
||||
const only = blocks.length === 1 ? blocks[0] : undefined
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ConvertFault } from '../result.ts'
|
||||
import { backslashEscape, claimsPipeLine, trimSpace } from './commonmark-grammar.ts'
|
||||
|
||||
const alignmentCell = /^:-+:?$|^-+:$/
|
||||
const delimiterCell = /^-+$/
|
||||
|
||||
export function isPipeAlignment(cell: string): boolean {
|
||||
return alignmentCell.test(cell)
|
||||
}
|
||||
|
||||
export function isPipeDelimiter(cell: string): boolean {
|
||||
return delimiterCell.test(cell)
|
||||
}
|
||||
|
||||
export function malformedPipeTable(message: string): ConvertFault {
|
||||
return { code: 'malformed-pipe-table', message }
|
||||
}
|
||||
|
||||
// spec/flavour.md, Tables: the cells of a claimed row, the closing `|` the spelling writes optional here.
|
||||
export function pipeCells(line: string): string[] | undefined {
|
||||
if (!claimsPipeLine(line)) return undefined
|
||||
const cells: string[] = []
|
||||
let start = 1
|
||||
let index = 1
|
||||
while (index < line.length) {
|
||||
if (backslashEscape(line, index) !== undefined) {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (line.charAt(index) === '|') {
|
||||
cells.push(trimSpace(line.slice(start, index)))
|
||||
start = index + 1
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
cells.push(trimSpace(line.slice(start)))
|
||||
if (cells.length > 1 && cells.at(-1) === '') cells.pop()
|
||||
return cells
|
||||
}
|
||||
|
||||
export function spellPipeDelimiter(columns: number): string {
|
||||
return spellPipeRow(Array.from({ length: columns }, () => '---'))
|
||||
}
|
||||
|
||||
export function spellPipeRow(cells: readonly string[]): string {
|
||||
return `| ${cells.join(' | ')} |`
|
||||
}
|
||||
@@ -11,7 +11,6 @@ export type ConvertErrorCode =
|
||||
| 'unspellable-link-destination'
|
||||
| 'unspellable-link-title'
|
||||
| 'unspellable-whitespace'
|
||||
| 'unspelled-block-separation'
|
||||
| 'unsupported-document-version'
|
||||
| 'unsupported-nesting-depth'
|
||||
| 'unsupported-node-shape'
|
||||
|
||||
@@ -309,3 +309,29 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
|
||||
path and returns `Result`, so no second reader took it. The drift guard earned itself on
|
||||
the way in: the spec's `text` attribute was missing from three inline table entries, which
|
||||
the content slot spells and the vocabulary walk already passes over.
|
||||
- [x] **3h — The block nodes.** `block-nodes/` reads back: the `codeBlock` directive's fenced
|
||||
body and the `language` attribute a bare fence leaves it; the media family's composition;
|
||||
and both table forms, the pipe table's cell split and its named errors. `fenceInfo` is a
|
||||
rule both directions answer alike and moves to the `markdown/` root with the language
|
||||
attribute.
|
||||
**Settled** (the maintainer, 2026-08-27): 1d's last pick, the one
|
||||
`container-block-separation` holds — a CommonMark block and a directive block sit adjacent
|
||||
in a container body with no blank line between them. That reduces the three cases to one
|
||||
rule, separation only where its absence would merge the blocks: the `:::` fence is
|
||||
separation already, and 3c's claim ends the lazy continuation that would otherwise swallow
|
||||
it. The fixture becomes a round-trip pair, and with `nested-list-separation` and 3e's pair
|
||||
that empties `corpus/unspellable/`: this chunk settles the directory's own guard in
|
||||
`corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here.
|
||||
The emitter's other refusals survive on causes no fixture in that directory covers, so
|
||||
3k's one-list pass is where they get fixtures or the directory goes.
|
||||
**Settled** (the maintainer, 2026-09-01): losing that cause closed one of the shapes input
|
||||
accepted and emit refused, not the last. Two adjacent lists of a kind are what
|
||||
`adfToMarkdown` refuses and one `- ` spelling cannot hold apart, and the walk reached them
|
||||
two ways — a marker change, which CommonMark opens a second list on, and an empty last item,
|
||||
whose blank line pops the container the list's identity hung from. The parser opens no list
|
||||
beside one of its own kind instead, the way it already drops the blank lines between items;
|
||||
3k owes the CommonMark suite an exception where the reference HTML holds two `<ul>`. The
|
||||
`normalization/` arm emits each document and reads it back from here, so the population that
|
||||
class lives in is checked rather than read. The README's canonical-fixpoint sentence still
|
||||
claims more than the parser keeps — 3e's three shapes — which stays milestone 5's to
|
||||
narrow.
|
||||
|
||||
@@ -5,8 +5,8 @@ milestone. A done item shrinks to its title here; its full text moves to `todo-h
|
||||
|
||||
## Milestones
|
||||
|
||||
Shipping order: 3h, 3i, 3j, 5a, 5 → `0.1.0`; 4b → `0.1.1`; 4, 3k → `0.2.0`; 6, 7 → `0.3.0`. The
|
||||
numbering is the order the work was planned in, not the order it ships.
|
||||
Shipping order: 3h, 3i, 3j, 5a, 5 → `0.1.0`; 4b and 4c → `0.1.1`; 4, 3k → `0.2.0`; 6, 7 → `0.3.0`.
|
||||
The numbering is the order the work was planned in, not the order it ships.
|
||||
|
||||
- [x] **0 — Scaffold.**
|
||||
- [x] **1a — The directive grammar.**
|
||||
@@ -49,23 +49,7 @@ numbering is the order the work was planned in, not the order it ships.
|
||||
- [x] **3e — Emphasis and links.**
|
||||
- [x] **3f — The directive grammar.**
|
||||
- [x] **3g — The node tables read backwards.**
|
||||
- [ ] **3h — The block nodes.** `block-nodes/` reads back: the `codeBlock` directive's fenced body and the
|
||||
`language` attribute a bare fence leaves it; the media family's composition; and both
|
||||
table forms, the pipe table's cell split and its named errors. `fenceInfo` is a rule both
|
||||
directions answer alike and moves to the `markdown/` root with the language attribute.
|
||||
**Settled** (the maintainer, 2026-08-27): 1d's last pick, the one
|
||||
`container-block-separation` holds — a CommonMark block and a directive block sit adjacent
|
||||
in a container body with no blank line between them. That reduces the three cases to one
|
||||
rule, separation only where its absence would merge the blocks: the `:::` fence is
|
||||
separation already, and 3c's claim ends the lazy continuation that would otherwise swallow
|
||||
it. The fixture becomes a round-trip pair, and with `nested-list-separation` and 3e's pair that
|
||||
empties `corpus/unspellable/`: this chunk settles the directory's own guard in
|
||||
`corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here.
|
||||
The emitter's other refusals survive on causes no fixture in that directory covers, so
|
||||
3k's one-list pass is where they get fixtures or the directory goes.
|
||||
Losing that cause closes the last shape input accepts and emit refuses — a CommonMark block
|
||||
beside a directive one inside a list item — so the parse-then-emit fixpoint the README
|
||||
promises holds from here rather than only for what the emitter wrote.
|
||||
- [x] **3h — The block nodes.**
|
||||
- [ ] **3i — The inline nodes and the marks.** `inline-nodes/` reads back: the content slot's
|
||||
`text` attribute and the error a slot holding anything but one unmarked text node is; the
|
||||
`:text{text="…"}` whitespace spelling; the four directive marks and their nesting order,
|
||||
@@ -77,9 +61,10 @@ numbering is the order the work was planned in, not the order it ships.
|
||||
parsing the slot inside it is a cycle; the four directive marks get `parse/directive-marks.ts`
|
||||
that `inline-content.ts` tries ahead of the node reader, as `mark-spellings.ts` sits apart
|
||||
from `emit/inline-directive-spelling.ts`; and the five markdown-spelled mark names in inline
|
||||
directive position want a claim code — `:em[x]` is an error forever, so
|
||||
`unknown-directive-name`'s "a later MINOR may give the name meaning" is the wrong signal,
|
||||
as it was for `adf`. `corpus/errors/directive-content-slot` goes when the slot opens.
|
||||
directive position take `unsupported-node-shape` rather than a code of their own — §8
|
||||
already answers a well-formed directive the node tables refuse, and the message names the
|
||||
spelling to use (`*x*`), while `unknown-directive-name`'s "a later MINOR may give the name
|
||||
meaning" stays the wrong signal, as it was for `adf`. `corpus/errors/directive-content-slot` goes when the slot opens.
|
||||
The marks a spelling wraps answer the same question 3g settled for a block's form: only the
|
||||
nesting the emitter writes parses back.
|
||||
- [ ] **3j — The carry and the combinations.** `opaque-carry/` and `combinations/` read back:
|
||||
@@ -102,10 +87,12 @@ numbering is the order the work was planned in, not the order it ships.
|
||||
3e collapses a spelling nested inside its own kind and `*(*a*)*` is two `<em>` against one
|
||||
`em`. The fixpoint alone is self-consistency a parser
|
||||
returning the empty document passes, and the text alone one dropping every emphasis; the
|
||||
counts close both. The exception list stays the maintainer's. One outcome is no
|
||||
counts close both. The exception list stays the maintainer's, and one entry is owed
|
||||
already: 3h continues a list across the marker change CommonMark splits on, so an example
|
||||
the reference HTML gives two `<ul>` counts one `bulletList`. One outcome is no
|
||||
exception and must not be filed as one: valid CommonMark parsing to a document
|
||||
`adfToMarkdown` refuses is a §2 hole, which is what three of `corpus/unspellable/`'s four
|
||||
hold until 3c, 3e and 3h land their answers.
|
||||
`adfToMarkdown` refuses is a §2 hole, which is what `corpus/unspellable/` held until 3c,
|
||||
3e and 3h landed their answers and emptied it.
|
||||
- [ ] **4 — Round-trip property tests (`0.2.0`)**, widening 3j's corpus round-trip past the
|
||||
documents a human wrote — the thing that proves 2 and 3 beyond them. Editor-normal (§2) is
|
||||
finished here, on 3i's merging — `toEditorNormal(doc)` and the equality the round-trip
|
||||
@@ -132,12 +119,23 @@ numbering is the order the work was planned in, not the order it ships.
|
||||
caller may hold one node object at two positions, where the cached depth and path are
|
||||
another node's. `0.1.0` ships with the retry in it, so a deep document is slow rather than
|
||||
wrong until the patch.
|
||||
- [ ] **4c — The scanning rule's remaining sites (`0.1.1`).** A trailing-anchored regex re-walks
|
||||
its run from every start position, so an interior whitespace run costs quadratic time rather
|
||||
than linear — 3h measured 80k spaces inside an ATX heading at 11.3s, and 3ms once the walk
|
||||
replaced the regex. Three sites the same sweep did not reach: `normalizeLabel` in
|
||||
`link-syntax.ts`, whose shortcut-reference input is `scan.source.slice(...)` rather than the
|
||||
999-capped `readLabel` value, and two in `emit/inline-line.ts`. The fix is the one 3h used —
|
||||
an index walk, `trimTrailingSpace` where the ends match. §11's scanning rule is the whole
|
||||
argument; the pipeline persona feeds documents nobody typed.
|
||||
- [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret,
|
||||
the repo made public first (§6). The `ConvertErrorCode` freeze (§8) is checkable here: every
|
||||
`corpus/unspellable/` document is a decision or a deferred trigger this file names, so the
|
||||
directory empties as they land and whatever survives is permanent. The parser's own code
|
||||
the repo made public first (§6). The `ConvertErrorCode` freeze (§8) is checkable here: 3h
|
||||
landed the last decision `corpus/unspellable/` held and the directory went with it, so what
|
||||
the code list holds from here is permanent. The parser's own code
|
||||
additions are read here as one list before that freeze — nine sessions mint them
|
||||
independently, and one cause wearing two codes is breaking to undo after `0.1.0`. `0.1.0`
|
||||
independently, and one cause wearing two codes is breaking to undo after `0.1.0`. That read
|
||||
gets a test rather than an eye — every `ConvertErrorCode` member named at a production call
|
||||
site, the way `spec.test.ts` guards the node tables — since `unspelled-block-separation`
|
||||
outlived its cause until 3h went looking. `0.1.0`
|
||||
is the markdown round-trip: both markdown directions, the types, `isAdfDocument`. The build
|
||||
lands here: `tsconfig.build.json` gains emit of JS and `.d.ts` to `dist/` (its own
|
||||
`allowImportingTsExtensions` forces `noEmit`, so `rewriteRelativeImportExtensions` lands
|
||||
|
||||
Reference in New Issue
Block a user