Read the leaf blocks, ahead of any inline parsing #31
@@ -82,8 +82,9 @@ closed list a consumer may switch exhaustively, the message free text, the path
|
||||
from the document root. Adding, removing or renaming a code is breaking, so a milestone meeting a
|
||||
new failure cause reuses a code where one fits; the list is complete at `0.1.0`. A code names the
|
||||
cause; where one cause recurs across node types, one code covers them all and `path` and `message`
|
||||
say which. A cause the carry answers gets no code: a mark no spelling writes rides the carry with
|
||||
its node.
|
||||
say which. A claim code names the spelling claimed, never the node that spelling would have built:
|
||||
a malformed `:::table` is a `malformed-directive`. A cause the carry answers gets no code: a mark no
|
||||
spelling writes rides the carry with its node.
|
||||
|
||||
## 9. Release automation
|
||||
|
||||
@@ -139,7 +140,8 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga
|
||||
unrepresentable.
|
||||
- `src/adf/` holds ADF's own knowledge and imports no format. Each format directory (`markdown/`,
|
||||
`html/`) parts into `emit/` (ADF→format) and `parse/` (format→ADF), its root holding what both
|
||||
directions read.
|
||||
directions read. A construct's reader lives in that root beside the regex the emitter escapes
|
||||
against, so the two cannot drift; a reader with no emit counterpart goes in `parse/`.
|
||||
- The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a
|
||||
format spells the narrowed value. A spelling that re-checks the type is the check's second copy.
|
||||
- Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`); a
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
malformed-directive
|
||||
@@ -0,0 +1 @@
|
||||
:::
|
||||
@@ -0,0 +1 @@
|
||||
unmappable-html
|
||||
@@ -0,0 +1 @@
|
||||
<!-- Draft: the collation note -->
|
||||
@@ -0,0 +1 @@
|
||||
malformed-pipe-table
|
||||
@@ -0,0 +1 @@
|
||||
| Part | Qty |
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"text": "SELECT id\nFROM part",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "codeBlock"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
SELECT id
|
||||
FROM part
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"content": [
|
||||
{
|
||||
"attrs": {
|
||||
"level": 1
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Assembly",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "heading"
|
||||
},
|
||||
{
|
||||
"attrs": {
|
||||
"level": 2
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"text": "Parts",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"type": "heading"
|
||||
}
|
||||
],
|
||||
"type": "doc",
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
Assembly
|
||||
========
|
||||
|
||||
Parts
|
||||
-----
|
||||
+42
-10
@@ -1,15 +1,18 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
|
||||
import { isAdfDocument } from './adf/document.ts'
|
||||
import { isJsonValue } from './json-value.ts'
|
||||
import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts'
|
||||
import { serializeCanonicalJson } from './canonical-json.ts'
|
||||
|
||||
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')
|
||||
|
||||
@@ -33,6 +36,21 @@ function names(root: string, extension: string): string[] {
|
||||
.sort()
|
||||
}
|
||||
|
||||
// One kind's fixture pairs, its two tests declared with them.
|
||||
function pairedNames(root: string, first: string, second: string): string[] {
|
||||
const kind = basename(root)
|
||||
|
||||
test(`${kind} pairs every ${first} with a ${second}`, () => {
|
||||
assert.deepEqual(names(root, first), names(root, second))
|
||||
})
|
||||
|
||||
test(`${kind} holds fixtures`, () => {
|
||||
assert.ok(names(root, first).length > 0)
|
||||
})
|
||||
|
||||
return names(root, first)
|
||||
}
|
||||
|
||||
function corpusJsonPaths(): string[] {
|
||||
return readdirSync(corpusRoot, { encoding: 'utf8', recursive: true })
|
||||
.filter((name) => name.endsWith('.json'))
|
||||
@@ -40,6 +58,10 @@ function corpusJsonPaths(): string[] {
|
||||
.sort()
|
||||
}
|
||||
|
||||
test('every corpus directory is a kind the runner reads', () => {
|
||||
assert.deepEqual(directoryNames(corpusRoot), ['errors', 'normalization', 'round-trip', 'unspellable'])
|
||||
})
|
||||
|
||||
test('every round-trip directory emits', () => {
|
||||
assert.deepEqual(directoryNames(roundTripRoot), [...emittingDirectories].sort())
|
||||
})
|
||||
@@ -144,15 +166,7 @@ for (const directory of emittingDirectories) {
|
||||
}
|
||||
}
|
||||
|
||||
test('unspellable pairs every .json with an .error', () => {
|
||||
assert.deepEqual(names(unspellableRoot, '.json'), names(unspellableRoot, '.error'))
|
||||
})
|
||||
|
||||
test('unspellable holds fixtures', () => {
|
||||
assert.ok(names(unspellableRoot, '.json').length > 0)
|
||||
})
|
||||
|
||||
for (const name of names(unspellableRoot, '.json')) {
|
||||
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`)
|
||||
@@ -162,6 +176,24 @@ for (const name of names(unspellableRoot, '.json')) {
|
||||
})
|
||||
}
|
||||
|
||||
for (const name of pairedNames(normalizationRoot, '.md', '.json')) {
|
||||
test(`normalization/${name} parses to the document beside it`, () => {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
for (const name of pairedNames(errorsRoot, '.md', '.error')) {
|
||||
test(`errors/${name} is refused with the error it names`, () => {
|
||||
const result = markdownToAdf(readFileSync(join(errorsRoot, `${name}.md`), 'utf8'))
|
||||
assert.ok(!result.ok, result.ok ? `built ${JSON.stringify(result.value)}` : '')
|
||||
assert.equal(result.error.code, readFileSync(join(errorsRoot, `${name}.error`), 'utf8').trimEnd())
|
||||
})
|
||||
}
|
||||
|
||||
test('the corpus holds JSON to gate', () => {
|
||||
assert.ok(corpusJsonPaths().length > 0)
|
||||
})
|
||||
|
||||
@@ -12,17 +12,43 @@ const controlCharacter = new RegExp(`[${controlCharacterRange}]`)
|
||||
const entityReference = new RegExp(entityReferenceSource)
|
||||
const nullCharacter = new RegExp(nullCharacterSource)
|
||||
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
|
||||
const firstCharacterOpeners = [/^#{1,6}(?:[ \t]|$)/, /^>/, /^[*+-](?:[ \t]|$)/, /^`{3,}/, /^~{3,}/, /^:{2,}/, /^\|/]
|
||||
const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/
|
||||
const codeFenceOpener = /^(`{3,}|~{3,})/
|
||||
const directiveClaim = /^:{2,}(?:[A-Za-z0-9]|[ \t]*$)/
|
||||
const pipeClaim = /^\|/
|
||||
// A superset of what the parser claims: over-escaping a line is safe, under-escaping one breaks the round-trip.
|
||||
const firstCharacterOpeners = [atxHeadingOpener, /^>/, /^[*+-](?:[ \t]|$)/, codeFenceOpener, /^:{2,}/, pipeClaim]
|
||||
const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/]
|
||||
const orderedListOpener = /^(\d{1,9})[.)](?:[ \t]|$)/
|
||||
const setextUnderline = /^(?:=+|-+)$/
|
||||
const setextUnderline = /^(=+|-+)[ \t]*$/
|
||||
const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/
|
||||
const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u
|
||||
|
||||
export function atxHeading(line: string): { level: number; text: string } | undefined {
|
||||
const hashes = atxHeadingOpener.exec(line)?.[1]
|
||||
if (hashes === undefined) return undefined
|
||||
const text = trimSpace(line.slice(hashes.length))
|
||||
return { level: hashes.length, text: trimSpace(text.replace(/(?:^|(?<=[ \t]))#+$/, '')) }
|
||||
}
|
||||
|
||||
export function claimsDirectiveLine(line: string): boolean {
|
||||
return directiveClaim.test(line)
|
||||
}
|
||||
|
||||
export function claimsLine(line: string, position: LinePosition): boolean {
|
||||
return escapesLineClaim(line, 0, position) || orderedListOpener.test(line)
|
||||
}
|
||||
|
||||
export function claimsPipeLine(line: string): boolean {
|
||||
return pipeClaim.test(line)
|
||||
}
|
||||
|
||||
export function closingCodeFence(line: string, marker: string): boolean {
|
||||
const closing = codeFenceOpener.exec(line)?.[1]
|
||||
if (closing === undefined || closing.charAt(0) !== marker.charAt(0) || closing.length < marker.length) return false
|
||||
return /^[ \t]*$/.test(line.slice(closing.length))
|
||||
}
|
||||
|
||||
export function escapesLineClaim(line: string, offset: number, position: LinePosition): boolean {
|
||||
if (offset === 0) {
|
||||
if (firstCharacterOpeners.some((opener) => opener.test(line)) || thematicBreak.test(line)) return true
|
||||
@@ -60,6 +86,13 @@ export function isUnicodeWhitespace(character: string): boolean {
|
||||
return unicodeWhitespace.test(character)
|
||||
}
|
||||
|
||||
export function openingCodeFence(line: string): { info: string; marker: string } | undefined {
|
||||
const marker = codeFenceOpener.exec(line)?.[1]
|
||||
if (marker === undefined) return undefined
|
||||
const info = trimSpace(line.slice(marker.length))
|
||||
return marker.startsWith('`') && info.includes('`') ? undefined : { info, marker }
|
||||
}
|
||||
|
||||
export function opensBracketedAutolink(text: string): boolean {
|
||||
return bracketedAutolink.test(text)
|
||||
}
|
||||
@@ -68,6 +101,16 @@ export function opensHtmlConstruct(text: string): boolean {
|
||||
return htmlConstructs.some((construct) => construct.test(text))
|
||||
}
|
||||
|
||||
export function setextHeadingLevel(line: string): number | undefined {
|
||||
const underline = setextUnderline.exec(line)?.[1]
|
||||
if (underline === undefined) return undefined
|
||||
return underline.startsWith('=') ? 1 : 2
|
||||
}
|
||||
|
||||
export function startsEntityReference(text: string): boolean {
|
||||
return anchoredEntityReference.test(text)
|
||||
}
|
||||
|
||||
export function trimSpace(text: string): string {
|
||||
return text.replace(/^[ \t]+|[ \t]+$/g, '')
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; deli
|
||||
|
||||
const delimiters = ['*', '_', '`', '~']
|
||||
|
||||
// The `:` keeps a `[label]: url` line escaped: unescaped, the parser swallows it as a link reference definition.
|
||||
const followsLinkText = /[([:]/
|
||||
|
||||
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { LinkDefinition } from './link-reference-definitions.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
|
||||
function definitions(markdown: string): [string, LinkDefinition][] {
|
||||
return [...parseBlocks(markdown).definitions]
|
||||
}
|
||||
|
||||
function kinds(markdown: string): string[] {
|
||||
return parseBlocks(markdown).blocks.map((block) => block.kind)
|
||||
}
|
||||
|
||||
test('keeps the link reference definitions a paragraph gives up, the first of a label winning', () => {
|
||||
assert.deepEqual(definitions('[a]: /url\n'), [['a', { destination: '/url' }]])
|
||||
assert.deepEqual(definitions('[Foo Bar]:\n<the url>\n"Title"\n'), [['foo bar', { destination: 'the url', title: 'Title' }]])
|
||||
assert.deepEqual(definitions("[a]: /url 'One'\n[a]: /other (Two)\n[b]: /b\n"), [
|
||||
['a', { destination: '/url', title: 'One' }],
|
||||
['b', { destination: '/b' }],
|
||||
])
|
||||
assert.deepEqual(definitions('[a\\]b]: /url\n'), [['a\\]b', { destination: '/url' }]])
|
||||
assert.deepEqual(definitions('[a]: /url(x)y\n'), [['a', { destination: '/url(x)y' }]])
|
||||
assert.deepEqual(definitions('[a]: /url\\(x\n'), [['a', { destination: '/url\\(x' }]])
|
||||
assert.deepEqual(definitions('[a]: <>\n'), [['a', { destination: '' }]])
|
||||
assert.deepEqual(definitions('[a]: /url "He said \\"hi\\""\n'), [['a', { destination: '/url', title: 'He said \\"hi\\"' }]])
|
||||
assert.deepEqual(definitions('[a]: /url\\\n[b]: /b\n'), [
|
||||
['a', { destination: '/url\\' }],
|
||||
['b', { destination: '/b' }],
|
||||
])
|
||||
assert.deepEqual(definitions('[\u00a0a]: /one\n[a]: /two\n'), [
|
||||
['\u00a0a', { destination: '/one' }],
|
||||
['a', { destination: '/two' }],
|
||||
])
|
||||
})
|
||||
|
||||
test('leaves the paragraph a line no definition spells', () => {
|
||||
assert.deepEqual(definitions('[]: /url\n'), [])
|
||||
assert.deepEqual(definitions('[ ]: /url\n'), [])
|
||||
assert.deepEqual(definitions('[a]: <un>closed>\n'), [])
|
||||
assert.deepEqual(definitions('[a]: <unclosed\n'), [])
|
||||
assert.deepEqual(definitions('[a]: /url)x\n'), [])
|
||||
assert.deepEqual(definitions('[a]: /url "One" and more\n'), [])
|
||||
assert.deepEqual(definitions('[a]:\n'), [])
|
||||
assert.deepEqual(definitions('[a]: /url "unclosed\n'), [])
|
||||
assert.deepEqual(kinds('[a]: /url\nPart.\n'), ['paragraph'])
|
||||
})
|
||||
|
||||
test('swallows an HTML block to the end condition its start sets', () => {
|
||||
assert.deepEqual(kinds('<div>\nx\n\nPart.\n'), ['html', 'paragraph'])
|
||||
assert.deepEqual(kinds('<!--\n:::\n-->\nPart.\n'), ['html', 'paragraph'])
|
||||
assert.deepEqual(kinds('<pre>x</pre>\nPart.\n'), ['html', 'paragraph'])
|
||||
assert.deepEqual(kinds('<div>\nx\n'), ['html'])
|
||||
assert.deepEqual(kinds('Part.\n<div>\n'), ['paragraph', 'html'])
|
||||
})
|
||||
|
||||
test('carries a claimed line as the block it opens, the refusal the node layer builds', () => {
|
||||
assert.deepEqual(kinds(':::\nPart.\n'), ['claim', 'paragraph'])
|
||||
assert.deepEqual(kinds('Part.\n| x |\n'), ['paragraph', 'claim'])
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { LinkDefinition } from './link-reference-definitions.ts'
|
||||
import type { OpenHtmlBlock } from './html-blocks.ts'
|
||||
import { atxHeading, claimsDirectiveLine, claimsPipeLine, closingCodeFence, isThematicBreak, openingCodeFence, setextHeadingLevel } from '../commonmark-grammar.ts'
|
||||
import { openingHtmlBlock } from './html-blocks.ts'
|
||||
import { readLinkDefinitions } from './link-reference-definitions.ts'
|
||||
|
||||
export type ClaimedConstruct = 'directive' | 'pipe-table'
|
||||
|
||||
export type LeafBlock =
|
||||
| { construct: ClaimedConstruct; kind: 'claim' }
|
||||
| { construct: string; kind: 'html' }
|
||||
| { kind: 'code'; language: string; text: string }
|
||||
| { kind: 'heading'; level: number; text: string }
|
||||
| { kind: 'paragraph'; text: string }
|
||||
| { kind: 'rule' }
|
||||
|
||||
export type ParsedBlocks = { blocks: LeafBlock[]; definitions: Map<string, LinkDefinition> }
|
||||
|
||||
type Walk = ParsedBlocks & { paragraph: string[] }
|
||||
|
||||
const blankLine = /^[ \t]*$/
|
||||
const indentedCodeColumns = 4
|
||||
const largestOpenerIndentation = 3
|
||||
const tabStop = 4
|
||||
|
||||
export function parseBlocks(markdown: string): ParsedBlocks {
|
||||
const lines = normalizeInput(markdown).split('\n')
|
||||
const walk: Walk = { blocks: [], definitions: new Map(), paragraph: [] }
|
||||
let index = 0
|
||||
while (index < lines.length) {
|
||||
const line = lines[index] ?? ''
|
||||
if (blankLine.test(line)) {
|
||||
closeParagraph(walk)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (leadingColumns(line) >= indentedCodeColumns && walk.paragraph.length === 0) {
|
||||
index = readIndentedCode(walk, lines, index)
|
||||
continue
|
||||
}
|
||||
const opened = openBlock(walk, lines, index, line)
|
||||
if (opened !== undefined) {
|
||||
index = opened
|
||||
continue
|
||||
}
|
||||
walk.paragraph.push(line.replace(/^[ \t]+/, ''))
|
||||
index += 1
|
||||
}
|
||||
closeParagraph(walk)
|
||||
return { blocks: walk.blocks, definitions: walk.definitions }
|
||||
}
|
||||
|
||||
function openBlock(walk: Walk, lines: readonly string[], index: number, line: string): number | undefined {
|
||||
const opener = removeColumns(line, largestOpenerIndentation)
|
||||
const claimed = claimedConstruct(opener)
|
||||
if (claimed !== undefined) {
|
||||
closeParagraph(walk)
|
||||
walk.blocks.push({ construct: claimed, kind: 'claim' })
|
||||
return index + 1
|
||||
}
|
||||
if (readLineBlock(walk, opener)) return index + 1
|
||||
const fence = openingCodeFence(opener)
|
||||
if (fence !== undefined) {
|
||||
closeParagraph(walk)
|
||||
return readFencedCode(walk, lines, index + 1, fence, leadingColumns(line))
|
||||
}
|
||||
const html = openingHtmlBlock(opener, walk.paragraph.length > 0)
|
||||
if (html === undefined) return undefined
|
||||
closeParagraph(walk)
|
||||
return readHtmlBlock(walk, lines, index, html)
|
||||
}
|
||||
|
||||
function normalizeInput(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replaceAll('\u0000', '\ufffd')
|
||||
.replace(/\n$/, '')
|
||||
}
|
||||
|
||||
function claimedConstruct(opener: string): ClaimedConstruct | undefined {
|
||||
if (claimsDirectiveLine(opener)) return 'directive'
|
||||
return claimsPipeLine(opener) ? 'pipe-table' : undefined
|
||||
}
|
||||
|
||||
// A setext underline over a paragraph the definitions emptied is no heading: it opens the next block.
|
||||
function readLineBlock(walk: Walk, opener: string): boolean {
|
||||
const level = walk.paragraph.length === 0 ? undefined : setextHeadingLevel(opener)
|
||||
if (level !== undefined) {
|
||||
const text = takeParagraph(walk)
|
||||
if (text !== undefined) {
|
||||
walk.blocks.push({ kind: 'heading', level, text })
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (isThematicBreak(opener)) {
|
||||
closeParagraph(walk)
|
||||
walk.blocks.push({ kind: 'rule' })
|
||||
return true
|
||||
}
|
||||
const heading = atxHeading(opener)
|
||||
if (heading === undefined) return false
|
||||
closeParagraph(walk)
|
||||
walk.blocks.push({ kind: 'heading', level: heading.level, text: heading.text })
|
||||
return true
|
||||
}
|
||||
|
||||
function readFencedCode(walk: Walk, lines: readonly string[], start: number, fence: { info: string; marker: string }, indentation: number): number {
|
||||
const collected: string[] = []
|
||||
let index = start
|
||||
while (index < lines.length) {
|
||||
const line = lines[index] ?? ''
|
||||
index += 1
|
||||
if (closingCodeFence(removeColumns(line, largestOpenerIndentation), fence.marker)) break
|
||||
collected.push(removeColumns(line, indentation))
|
||||
}
|
||||
walk.blocks.push({ kind: 'code', language: fence.info, text: collected.join('\n') })
|
||||
return index
|
||||
}
|
||||
|
||||
function readIndentedCode(walk: Walk, lines: readonly string[], start: number): number {
|
||||
const collected: string[] = []
|
||||
const held: string[] = []
|
||||
let index = start
|
||||
let end = start
|
||||
while (index < lines.length) {
|
||||
const line = lines[index] ?? ''
|
||||
index += 1
|
||||
if (blankLine.test(line)) {
|
||||
held.push(removeColumns(line, indentedCodeColumns))
|
||||
continue
|
||||
}
|
||||
if (leadingColumns(line) < indentedCodeColumns) break
|
||||
collected.push(...held, removeColumns(line, indentedCodeColumns))
|
||||
held.length = 0
|
||||
end = index
|
||||
}
|
||||
walk.blocks.push({ kind: 'code', language: '', text: collected.join('\n') })
|
||||
return end
|
||||
}
|
||||
|
||||
function readHtmlBlock(walk: Walk, lines: readonly string[], start: number, html: OpenHtmlBlock): number {
|
||||
let index = start
|
||||
while (index < lines.length) {
|
||||
const line = lines[index] ?? ''
|
||||
if (html.closer === undefined && blankLine.test(line)) break
|
||||
index += 1
|
||||
if (html.closer !== undefined && html.closer.test(line)) break
|
||||
}
|
||||
walk.blocks.push({ construct: html.construct, kind: 'html' })
|
||||
return index
|
||||
}
|
||||
|
||||
function closeParagraph(walk: Walk): void {
|
||||
const text = takeParagraph(walk)
|
||||
if (text !== undefined) walk.blocks.push({ kind: 'paragraph', text })
|
||||
}
|
||||
|
||||
function takeParagraph(walk: Walk): string | undefined {
|
||||
const text = readLinkDefinitions(walk.definitions, walk.paragraph.join('\n'))
|
||||
walk.paragraph = []
|
||||
return text === '' ? undefined : text
|
||||
}
|
||||
|
||||
function leadingColumns(line: string): number {
|
||||
let columns = 0
|
||||
for (const character of line) {
|
||||
if (character === ' ') columns += 1
|
||||
else if (character === '\t') columns += tabStop - (columns % tabStop)
|
||||
else break
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
// CommonMark's tab stops: a tab the cut splits gives the columns it holds past the cut back as spaces.
|
||||
function removeColumns(line: string, columns: number): string {
|
||||
let removed = 0
|
||||
let index = 0
|
||||
while (removed < columns && index < line.length) {
|
||||
const character = line.charAt(index)
|
||||
if (character !== ' ' && character !== '\t') break
|
||||
const width = character === ' ' ? 1 : tabStop - (removed % tabStop)
|
||||
index += 1
|
||||
if (removed + width > columns) return ' '.repeat(removed + width - columns) + line.slice(index)
|
||||
removed += width
|
||||
}
|
||||
return line.slice(index)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type OpenHtmlBlock = { closer: RegExp | undefined; construct: string }
|
||||
|
||||
type HtmlBlockCondition = { closer: RegExp | undefined; construct: string | undefined; interrupts: boolean; start: RegExp }
|
||||
|
||||
// CommonMark 0.31.2, HTML blocks: the tag names start condition 6 lists.
|
||||
const blockTagNames =
|
||||
'address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul'
|
||||
const attributeSource = '(?:[ \\t]+[A-Za-z_:][A-Za-z0-9_.:-]*(?:[ \\t]*=[ \\t]*(?:[^ \\t"\'=<>`]+|\'[^\']*\'|"[^"]*"))?)'
|
||||
const completeTag = new RegExp(`^(?:<[A-Za-z][A-Za-z0-9-]*${attributeSource}*[ \\t]*/?>|</[A-Za-z][A-Za-z0-9-]*[ \\t]*>)[ \\t]*$`)
|
||||
const tagName = /^<\/?([A-Za-z][A-Za-z0-9-]*).*$/
|
||||
|
||||
const conditions: HtmlBlockCondition[] = [
|
||||
{ closer: /<\/(?:pre|script|style|textarea)>/i, construct: undefined, interrupts: true, start: /^<(?:pre|script|style|textarea)(?:[ \t>]|$)/i },
|
||||
{ closer: /-->/, construct: 'an HTML comment', interrupts: true, start: /^<!--/ },
|
||||
{ closer: /\?>/, construct: 'an HTML processing instruction', interrupts: true, start: /^<\?/ },
|
||||
{ closer: />/, construct: 'an HTML declaration', interrupts: true, start: /^<![A-Za-z]/ },
|
||||
{ closer: /\]\]>/, construct: 'a CDATA section', interrupts: true, start: /^<!\[CDATA\[/ },
|
||||
{ closer: undefined, construct: undefined, interrupts: true, start: new RegExp(`^</?(?:${blockTagNames})(?:[ \\t>]|/>|$)`, 'i') },
|
||||
{ closer: undefined, construct: undefined, interrupts: false, start: completeTag },
|
||||
]
|
||||
|
||||
export function openingHtmlBlock(line: string, interrupting: boolean): OpenHtmlBlock | undefined {
|
||||
for (const condition of conditions) {
|
||||
if ((interrupting && !condition.interrupts) || !condition.start.test(line)) continue
|
||||
return { closer: condition.closer, construct: condition.construct ?? line.replace(tagName, '<$1>') }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { holdsControlCharacter, isAsciiPunctuation } from '../commonmark-grammar.ts'
|
||||
|
||||
export type LinkDefinition = { destination: string; title?: string }
|
||||
|
||||
type ReadDefinition = { definition: LinkDefinition; label: string; length: number }
|
||||
type ReadValue = { length: number; value: string }
|
||||
|
||||
const bracketedDestination = /^<((?:[^\n<>\\]|\\[^\n])*)>/
|
||||
const label = /^\[((?:[^[\]\\]|\\[\s\S]){1,999})\]:/
|
||||
const restOfLine = /^[ \t]*(?:\n|$)/
|
||||
const titleClosers: Readonly<Record<string, string>> = { '"': '"', "'": "'", '(': ')' }
|
||||
|
||||
export function readLinkDefinitions(definitions: Map<string, LinkDefinition>, text: string): string {
|
||||
let rest = text
|
||||
let read = readDefinition(rest)
|
||||
while (read !== undefined) {
|
||||
if (!definitions.has(read.label)) definitions.set(read.label, read.definition)
|
||||
rest = rest.slice(read.length)
|
||||
read = readDefinition(rest)
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
function readDefinition(text: string): ReadDefinition | undefined {
|
||||
const matched = label.exec(text)
|
||||
if (matched === null) return undefined
|
||||
const name = normalizeLabel(matched[1] ?? '')
|
||||
if (name === '') return undefined
|
||||
const afterLabel = skipSpace(text, matched[0].length)
|
||||
const destination = readDestination(text, afterLabel)
|
||||
if (destination === undefined) return undefined
|
||||
const afterDestination = afterLabel + destination.length
|
||||
const titled = readTitledEnd(text, afterDestination)
|
||||
if (titled !== undefined) return { definition: { destination: destination.value, title: titled.value }, label: name, length: titled.length }
|
||||
const plain = endOfLine(text, afterDestination)
|
||||
if (plain === undefined) return undefined
|
||||
return { definition: { destination: destination.value }, label: name, length: plain }
|
||||
}
|
||||
|
||||
function normalizeLabel(raw: string): string {
|
||||
return raw
|
||||
.replace(/^[ \t\n]+|[ \t\n]+$/g, '')
|
||||
.replace(/[ \t\n]+/g, ' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function readTitledEnd(text: string, offset: number): ReadValue | undefined {
|
||||
const afterSpace = skipSpace(text, offset)
|
||||
if (afterSpace === offset) return undefined
|
||||
const title = readTitle(text, afterSpace)
|
||||
if (title === undefined) return undefined
|
||||
const end = endOfLine(text, afterSpace + title.length)
|
||||
return end === undefined ? undefined : { length: end, value: title.value }
|
||||
}
|
||||
|
||||
function readDestination(text: string, offset: number): ReadValue | undefined {
|
||||
const bracketed = bracketedDestination.exec(text.slice(offset))
|
||||
if (bracketed !== null) return { length: bracketed[0].length, value: bracketed[0].slice(1, -1) }
|
||||
if (text.charAt(offset) === '<') return undefined
|
||||
let depth = 0
|
||||
let index = offset
|
||||
while (index < text.length) {
|
||||
const character = text.charAt(index)
|
||||
if (character === ' ' || holdsControlCharacter(character)) break
|
||||
if (escapesNext(text, index)) {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (character === '(') depth += 1
|
||||
if (character === ')') {
|
||||
depth -= 1
|
||||
if (depth < 0) break
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return index <= offset ? undefined : { length: index - offset, value: text.slice(offset, index) }
|
||||
}
|
||||
|
||||
function readTitle(text: string, offset: number): ReadValue | undefined {
|
||||
const opener = text.charAt(offset)
|
||||
const closer = titleClosers[opener]
|
||||
if (closer === undefined) return undefined
|
||||
let index = offset + 1
|
||||
while (index < text.length) {
|
||||
const character = text.charAt(index)
|
||||
if (escapesNext(text, index)) {
|
||||
index += 2
|
||||
continue
|
||||
}
|
||||
if (character === closer) return { length: index + 1 - offset, value: text.slice(offset + 1, index) }
|
||||
if (character === opener) return undefined
|
||||
index += 1
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// A backslash escapes ASCII punctuation only, so a line ending always ends the destination it follows.
|
||||
function escapesNext(text: string, index: number): boolean {
|
||||
return text.charAt(index) === '\\' && isAsciiPunctuation(text.charAt(index + 1))
|
||||
}
|
||||
|
||||
// The label, the destination and the title each take at most one line ending with them.
|
||||
function skipSpace(text: string, offset: number): number {
|
||||
const rest = text.slice(offset)
|
||||
return offset + rest.length - rest.replace(/^[ \t]*\n?[ \t]*/, '').length
|
||||
}
|
||||
|
||||
function endOfLine(text: string, offset: number): number | undefined {
|
||||
const rest = restOfLine.exec(text.slice(offset))?.[0]
|
||||
return rest === undefined ? undefined : offset + rest.length
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { Result } from '../../result.ts'
|
||||
import { markdownToAdf } from './markdown-to-adf.ts'
|
||||
|
||||
function code(result: Result<AdfDocument>): string {
|
||||
return result.ok ? `built ${JSON.stringify(result.value)}` : result.error.code
|
||||
}
|
||||
|
||||
function content(result: Result<AdfDocument>): AdfNode[] | string {
|
||||
return result.ok ? (result.value.content ?? []) : `${result.error.code}: ${result.error.message}`
|
||||
}
|
||||
|
||||
function path(result: Result<AdfDocument>): readonly (number | string)[] {
|
||||
return result.ok ? ['built'] : result.error.path
|
||||
}
|
||||
|
||||
function text(value: string): AdfNode {
|
||||
return { text: value, type: 'text' }
|
||||
}
|
||||
|
||||
function paragraph(value: string): AdfNode {
|
||||
return { content: [text(value)], type: 'paragraph' }
|
||||
}
|
||||
|
||||
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')), [])
|
||||
})
|
||||
|
||||
test('builds one paragraph from the lines a blank line does not part', () => {
|
||||
assert.deepEqual(content(markdownToAdf('One\ntwo.\n\nThree.\n')), [paragraph('One two.'), paragraph('Three.')])
|
||||
})
|
||||
|
||||
test('reads an ATX heading and its level', () => {
|
||||
assert.deepEqual(content(markdownToAdf('# Assembly\n')), [{ attrs: { level: 1 }, content: [text('Assembly')], type: 'heading' }])
|
||||
assert.deepEqual(content(markdownToAdf('###### M8\n')), [{ attrs: { level: 6 }, content: [text('M8')], type: 'heading' }])
|
||||
assert.deepEqual(content(markdownToAdf(' ## Parts ##\n')), [{ attrs: { level: 2 }, content: [text('Parts')], type: 'heading' }])
|
||||
assert.deepEqual(content(markdownToAdf('#\n')), [{ attrs: { level: 1 }, type: 'heading' }])
|
||||
assert.deepEqual(content(markdownToAdf('####### Seven\n')), [paragraph('####### Seven')])
|
||||
assert.deepEqual(content(markdownToAdf('#hashtag\n')), [paragraph('#hashtag')])
|
||||
})
|
||||
|
||||
test('reads a setext underline as the heading level it spells', () => {
|
||||
assert.deepEqual(content(markdownToAdf('Assembly\n===\n')), [{ attrs: { level: 1 }, content: [text('Assembly')], type: 'heading' }])
|
||||
assert.deepEqual(content(markdownToAdf('One\ntwo\n-\n')), [{ attrs: { level: 2 }, content: [text('One two')], type: 'heading' }])
|
||||
assert.deepEqual(content(markdownToAdf('===\n')), [paragraph('===')])
|
||||
})
|
||||
|
||||
test('reads a thematic break, the dashed one only where no paragraph is open', () => {
|
||||
assert.deepEqual(content(markdownToAdf('---\n')), [{ type: 'rule' }])
|
||||
assert.deepEqual(content(markdownToAdf('Part.\n\n * * *\n')), [paragraph('Part.'), { type: 'rule' }])
|
||||
assert.deepEqual(content(markdownToAdf('___\n')), [{ type: 'rule' }])
|
||||
assert.deepEqual(content(markdownToAdf('Part.\n***\n')), [paragraph('Part.'), { type: 'rule' }])
|
||||
assert.deepEqual(content(markdownToAdf('Part.\n---\n')), [{ attrs: { level: 2 }, content: [text('Part.')], type: 'heading' }])
|
||||
})
|
||||
|
||||
test('reads a fenced code block, its info string the language', () => {
|
||||
assert.deepEqual(content(markdownToAdf('```sql\nSELECT id\nFROM part\n```\n')), [
|
||||
{ attrs: { language: 'sql' }, content: [text('SELECT id\nFROM part')], type: 'codeBlock' },
|
||||
])
|
||||
assert.deepEqual(content(markdownToAdf('```\nx\n```\n')), [{ content: [text('x')], type: 'codeBlock' }])
|
||||
assert.deepEqual(content(markdownToAdf('``` rust \nx\n```\n')), [{ attrs: { language: 'rust' }, content: [text('x')], type: 'codeBlock' }])
|
||||
assert.deepEqual(content(markdownToAdf('```\n```\n')), [{ type: 'codeBlock' }])
|
||||
assert.deepEqual(content(markdownToAdf('```\nx\n')), [{ content: [text('x')], type: 'codeBlock' }])
|
||||
assert.deepEqual(content(markdownToAdf('````\n```\n````\n')), [{ content: [text('```')], type: 'codeBlock' }])
|
||||
assert.deepEqual(content(markdownToAdf('~~~ a`b\n```\n~~~\n')), [{ attrs: { language: 'a`b' }, content: [text('```')], type: 'codeBlock' }])
|
||||
assert.deepEqual(content(markdownToAdf('``` a`b\n')), [paragraph('``` a`b')])
|
||||
assert.deepEqual(content(markdownToAdf('```\n``` x\n```\n')), [{ content: [text('``` x')], type: 'codeBlock' }])
|
||||
})
|
||||
|
||||
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' }])
|
||||
})
|
||||
|
||||
test('reads an indented code block where no paragraph is open', () => {
|
||||
assert.deepEqual(content(markdownToAdf(' SELECT id\n')), [{ content: [text('SELECT id')], type: 'codeBlock' }])
|
||||
assert.deepEqual(content(markdownToAdf('\tSELECT id\n')), [{ content: [text('SELECT id')], type: 'codeBlock' }])
|
||||
assert.deepEqual(content(markdownToAdf(' x\n')), [{ content: [text(' x')], type: 'codeBlock' }])
|
||||
assert.deepEqual(content(markdownToAdf(' a\n\n b\n\nPart.\n')), [{ content: [text('a\n\nb')], type: 'codeBlock' }, paragraph('Part.')])
|
||||
assert.deepEqual(content(markdownToAdf('Part.\n more\n')), [paragraph('Part. more')])
|
||||
})
|
||||
|
||||
test('claims a block-level colon run with no directive to parse it', () => {
|
||||
assert.equal(code(markdownToAdf(':::\n')), 'malformed-directive')
|
||||
assert.equal(code(markdownToAdf('::panel\n')), 'malformed-directive')
|
||||
assert.equal(code(markdownToAdf(' :::panel info\nx\n:::\n')), 'malformed-directive')
|
||||
assert.deepEqual(path(markdownToAdf('Part.\n:::x\n')), ['content', 1])
|
||||
assert.deepEqual(content(markdownToAdf(':10:30\n')), [paragraph(':10:30')])
|
||||
assert.deepEqual(content(markdownToAdf(':: two\n')), [paragraph(':: two')])
|
||||
})
|
||||
|
||||
test('claims a block-level pipe with no table to parse it', () => {
|
||||
assert.equal(code(markdownToAdf('| Part | Qty |\n')), 'malformed-pipe-table')
|
||||
assert.deepEqual(content(markdownToAdf('\\| Part\n')), [paragraph('\\| Part')])
|
||||
})
|
||||
|
||||
test('refuses the raw HTML no element mapping carries', () => {
|
||||
assert.equal(code(markdownToAdf('<!-- note -->\n')), 'unmappable-html')
|
||||
assert.equal(code(markdownToAdf('<div>\nx\n</div>\n')), 'unmappable-html')
|
||||
assert.equal(code(markdownToAdf('<?php ?>\n')), 'unmappable-html')
|
||||
assert.equal(code(markdownToAdf('<!DOCTYPE html>\n')), 'unmappable-html')
|
||||
assert.equal(code(markdownToAdf('<![CDATA[x]]>\n')), 'unmappable-html')
|
||||
assert.equal(code(markdownToAdf('<pre>\nx\n</pre>\n')), 'unmappable-html')
|
||||
assert.equal(code(markdownToAdf('<span foo="bar">\n')), 'unmappable-html')
|
||||
assert.deepEqual(path(markdownToAdf('Part.\n\n<div>\n')), ['content', 1])
|
||||
assert.equal(code(markdownToAdf('<div>\nx\n\n:::\n')), 'unmappable-html')
|
||||
})
|
||||
|
||||
test('swallows an HTML block ahead of the claim a line inside it would make', () => {
|
||||
assert.equal(code(markdownToAdf('<!--\n:::\n-->\n')), 'unmappable-html')
|
||||
assert.equal(code(markdownToAdf('<div>\n| x |\n</div>\n')), 'unmappable-html')
|
||||
})
|
||||
|
||||
test('leaves a tag that opens no HTML block to the paragraph it sits in', () => {
|
||||
assert.deepEqual(content(markdownToAdf('Part.\n<span>\n')), [paragraph('Part. <span>')])
|
||||
assert.deepEqual(content(markdownToAdf('3 < 4\n')), [paragraph('3 < 4')])
|
||||
})
|
||||
|
||||
test('gives up the link reference definitions a paragraph opens with', () => {
|
||||
assert.deepEqual(content(markdownToAdf('[a]: /url\n')), [])
|
||||
assert.deepEqual(content(markdownToAdf('[a]: /url\n[b]: /other\nPart.\n')), [paragraph('Part.')])
|
||||
assert.deepEqual(content(markdownToAdf('[a]: /url\n"Title"\n\nPart.\n')), [paragraph('Part.')])
|
||||
assert.deepEqual(content(markdownToAdf('[a]: /url and more\n')), [paragraph('[a]: /url and more')])
|
||||
assert.deepEqual(content(markdownToAdf('Part.\n[a]: /url\n')), [paragraph('Part. [a]: /url')])
|
||||
assert.deepEqual(content(markdownToAdf('[a]: /url\n===\n')), [paragraph('===')])
|
||||
})
|
||||
|
||||
test('keeps the whitespace CommonMark strips no more of than a space or a tab', () => {
|
||||
assert.deepEqual(content(markdownToAdf('\u00a0Part.\u00a0\n')), [paragraph('\u00a0Part.\u00a0')])
|
||||
assert.deepEqual(content(markdownToAdf(' \u3000Part.\t\n')), [paragraph('\u3000Part.')])
|
||||
})
|
||||
|
||||
test('normalizes the line endings and the null character CommonMark replaces', () => {
|
||||
assert.deepEqual(content(markdownToAdf('One\r\ntwo.\r\n')), [paragraph('One two.')])
|
||||
assert.deepEqual(content(markdownToAdf('One\rtwo.\r')), [paragraph('One two.')])
|
||||
assert.deepEqual(content(markdownToAdf('```\r\nx\r\n```\r\n')), [{ content: [text('x')], type: 'codeBlock' }])
|
||||
assert.deepEqual(content(markdownToAdf('a\u0000b\n')), [paragraph('a\ufffdb')])
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { ClaimedConstruct, LeafBlock } from './blocks.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
import { trimSpace } from '../commonmark-grammar.ts'
|
||||
|
||||
export function markdownToAdf(markdown: string): Result<AdfDocument> {
|
||||
const content: AdfNode[] = []
|
||||
for (const [index, block] of parseBlocks(markdown).blocks.entries()) {
|
||||
const node = blockNode(block, ['content', index])
|
||||
if (!node.ok) return node
|
||||
content.push(node.value)
|
||||
}
|
||||
return success(content.length === 0 ? { type: 'doc', version: 1 } : { content, type: 'doc', version: 1 })
|
||||
}
|
||||
|
||||
function blockNode(block: LeafBlock, path: ConvertErrorPath): Result<AdfNode> {
|
||||
if (block.kind === 'claim') return claimFailure(block.construct, path)
|
||||
if (block.kind === 'code') return success(codeBlockNode(block.language, block.text))
|
||||
if (block.kind === 'heading') return success(withContent({ attrs: { level: block.level }, type: 'heading' }, block.text))
|
||||
if (block.kind === 'html') return failure('unmappable-html', `no ADF node carries ${block.construct}`, path)
|
||||
if (block.kind === 'paragraph') return success(withContent({ type: 'paragraph' }, block.text))
|
||||
return success({ type: 'rule' })
|
||||
}
|
||||
|
||||
function claimFailure(construct: ClaimedConstruct, path: ConvertErrorPath): Result<AdfNode> {
|
||||
if (construct === 'directive') return failure('malformed-directive', 'the line claims a directive and parses as none', path)
|
||||
return failure('malformed-pipe-table', 'the line claims a pipe table and parses as none', path)
|
||||
}
|
||||
|
||||
function codeBlockNode(language: string, text: string): AdfNode {
|
||||
const node: AdfNode = language === '' ? { type: 'codeBlock' } : { attrs: { language }, type: 'codeBlock' }
|
||||
return text === '' ? node : { ...node, content: [{ text, type: 'text' }] }
|
||||
}
|
||||
|
||||
function withContent(node: AdfNode, text: string): AdfNode {
|
||||
const content = inlineContent(text)
|
||||
return content.length === 0 ? node : { ...node, content }
|
||||
}
|
||||
|
||||
function inlineContent(text: string): AdfNode[] {
|
||||
const line = text
|
||||
.split('\n')
|
||||
.map((part) => trimSpace(part))
|
||||
.join(' ')
|
||||
return line === '' ? [] : [{ text: line, type: 'text' }]
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
export type ConvertErrorCode =
|
||||
| 'malformed-directive'
|
||||
| 'malformed-pipe-table'
|
||||
| 'not-an-adf-document'
|
||||
| 'unmappable-html'
|
||||
| 'unspellable-adjacent-lists'
|
||||
| 'unspellable-character'
|
||||
| 'unspellable-line-start'
|
||||
|
||||
@@ -196,7 +196,7 @@ detail is settled at its own milestone.
|
||||
`name (type)` list and asserts it equals the table, leaving the spec the source a human
|
||||
writes with no build step and no generated file. It is built at 3g, where a wrong entry
|
||||
starts refusing documents.
|
||||
- [ ] **3b — The leaf blocks.** The line walk that opens and closes a block, ahead of any inline
|
||||
- [x] **3b — The leaf blocks.** The line walk that opens and closes a block, ahead of any inline
|
||||
parsing: paragraph, ATX and setext heading, thematic break, fenced and indented code
|
||||
block, the HTML block whose lines it swallows whether or not the construct then errors,
|
||||
the link reference definitions a closing paragraph gives up, and the blank lines between
|
||||
@@ -210,7 +210,11 @@ detail is settled at its own milestone.
|
||||
rather than editing them.
|
||||
- [ ] **3c — The container blocks.** Blockquote, bullet and ordered list: the continuation a
|
||||
marker's width sets, lazy continuation, and the tightness ADF does not record — `> `
|
||||
repeated being two bytes a level, so this is the cheapest way to reach §11's 500.
|
||||
repeated being two bytes a level, so this is the cheapest way to reach §11's 500. 3b's leaf
|
||||
readers scan the physical line themselves, so a container re-cuts the walk rather than adding
|
||||
to it: the open containers' prefix comes off the line first and the readers take one line at a
|
||||
time, `LeafBlock` renamed with the union they join and `blockNode`'s chain gaining their
|
||||
branches.
|
||||
**Settled** (the maintainer, 2026-08-27): a claimed line ends lazy continuation, so a
|
||||
closing fence on the line after a blockquote's open paragraph closes its container instead
|
||||
of continuing the paragraph CommonMark would fold it into. Claiming at block level is
|
||||
@@ -222,13 +226,13 @@ detail is settled at its own milestone.
|
||||
into the separation it names, and `spec/flavour.md`'s "none between a nested list and a
|
||||
CommonMark block above it" gaining that exception. Every fixture spelled tight today keeps
|
||||
its bytes, and `nested-list-tight` becomes a round-trip pair.
|
||||
- [ ] **3d — Inline text.** The inline scanner over a block's content: backslash escapes,
|
||||
entity references decoding to their characters, code spans and the literal they hold —
|
||||
directive syntax and `~~` included — CommonMark's own hard breaks, a trailing backslash
|
||||
and two trailing spaces alike, a soft line break as one space, and the raw inline tag,
|
||||
comment and processing instruction refused by name, recognized by the
|
||||
`commonmark-grammar.ts` predicates the emitter already escapes against, under 3b's
|
||||
one-table rule.
|
||||
- [ ] **3d — Inline text.** The inline scanner over a block's content: backslash escapes, entity
|
||||
references decoding to their characters, code spans and the literal they hold — directive
|
||||
syntax and `~~` included — CommonMark's own hard breaks, a trailing backslash and two
|
||||
trailing spaces alike, a soft line break as one space, the fenced info string's own decoding
|
||||
the block walk leaves raw, and the raw inline tag, comment and processing instruction
|
||||
refused by name, recognized by the `commonmark-grammar.ts` predicates the emitter already
|
||||
escapes against, under 3b's one-table rule.
|
||||
- [ ] **3e — Emphasis and links.** `_`, `*` and `~~` runs through `matchEmphasis` to the `em`,
|
||||
`strong` and `strike` marks; links inline and reference, 3b's definitions resolved here,
|
||||
autolinks, and the image gap's named errors — a titled image, and one amid other text.
|
||||
@@ -244,7 +248,8 @@ detail is settled at its own milestone.
|
||||
- [ ] **3f — The directive grammar.** The three forms — inline `:name[content]{attrs}`,
|
||||
container `:::name arg {attrs}`, leaf `::name arg {attrs}` — the attribute grammar with
|
||||
its quoting and escapes, the fence-length and nesting rules, and the malformed list
|
||||
`spec/flavour.md` spells, each a named error.
|
||||
`spec/flavour.md` spells, each a named error. `corpus.test.ts`'s `fenceNestingFault` stays a
|
||||
second reading of the fence rule over emitted bytes: the double entry is the check.
|
||||
**Settled** (the maintainer, 2026-08-27): the code span, the entity and raw HTML bind
|
||||
first in input, as 2e3 already assumed of the emitted side — a raw `` ` ``, `&`, `<` or
|
||||
`|` inside `{attrs}` breaks the directive and is a named error, the author writing the
|
||||
@@ -281,7 +286,8 @@ detail is settled at its own milestone.
|
||||
named error, a carry inside a mark spelling another, and the three carve-outs' escapes
|
||||
reading as the literal text they hold.
|
||||
- [ ] **3k — The CommonMark spec suite.** Checked in at `corpus/commonmark-spec/`, pinned to
|
||||
the version it ships, `corpus/README.md` gaining the kind.
|
||||
the version it ships — the one `html-blocks.ts` names for its start conditions —
|
||||
`corpus/README.md` gaining the kind.
|
||||
**Settled** (the maintainer, 2026-08-27): three checks an example must pass, the reference
|
||||
HTML each ships read as corpus data — which adds no format and no direction (§1). §2's
|
||||
canonical fixpoint: a named error, or markdown that parses and emits to itself byte for
|
||||
|
||||
Reference in New Issue
Block a user