Answer the architecture review: the claim rides a block, and the path comes from the node layer
CI / gate (push) Successful in 5s
CI / gate (push) Successful in 5s
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -2,21 +2,14 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { LinkDefinition } from './link-reference-definitions.ts'
|
||||
import type { ParsedBlocks } from './blocks.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
|
||||
function walk(markdown: string): ParsedBlocks {
|
||||
const result = parseBlocks(markdown)
|
||||
assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`)
|
||||
return result.value
|
||||
}
|
||||
|
||||
function definitions(markdown: string): [string, LinkDefinition][] {
|
||||
return [...walk(markdown).definitions]
|
||||
return [...parseBlocks(markdown).definitions]
|
||||
}
|
||||
|
||||
function kinds(markdown: string): string[] {
|
||||
return walk(markdown).blocks.map((block) => block.kind)
|
||||
return parseBlocks(markdown).blocks.map((block) => block.kind)
|
||||
}
|
||||
|
||||
test('keeps the link reference definitions a paragraph gives up, the first of a label winning', () => {
|
||||
@@ -51,3 +44,8 @@ test('swallows an HTML block to the end condition its start sets', () => {
|
||||
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'])
|
||||
})
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
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 { failure, success, type Result } from '../../result.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: 'html'; name: string }
|
||||
| { kind: 'paragraph'; text: string }
|
||||
| { kind: 'rule' }
|
||||
|
||||
@@ -21,41 +23,51 @@ const indentedCodeColumns = 4
|
||||
const largestOpenerIndentation = 3
|
||||
const tabStop = 4
|
||||
|
||||
export function parseBlocks(markdown: string): Result<ParsedBlocks> {
|
||||
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] ?? ''
|
||||
index += 1
|
||||
if (blankLine.test(line)) {
|
||||
closeParagraph(walk)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (leadingColumns(line) >= indentedCodeColumns && walk.paragraph.length === 0) {
|
||||
index = readIndentedCode(walk, lines, index - 1)
|
||||
index = readIndentedCode(walk, lines, index)
|
||||
continue
|
||||
}
|
||||
const opener = removeColumns(line, largestOpenerIndentation)
|
||||
const claim = claimedLine(walk, opener)
|
||||
if (claim !== undefined) return claim
|
||||
if (readLineBlock(walk, opener)) continue
|
||||
const fence = openingCodeFence(opener)
|
||||
if (fence !== undefined) {
|
||||
closeParagraph(walk)
|
||||
index = readFencedCode(walk, lines, index, fence, leadingColumns(line))
|
||||
continue
|
||||
}
|
||||
const html = openingHtmlBlock(opener, walk.paragraph.length > 0)
|
||||
if (html !== undefined) {
|
||||
closeParagraph(walk)
|
||||
index = readHtmlBlock(walk, lines, index - 1, html)
|
||||
const opened = openBlock(walk, lines, index, line)
|
||||
if (opened !== undefined) {
|
||||
index = opened
|
||||
continue
|
||||
}
|
||||
walk.paragraph.push(line.replace(/^[ \t]+/, ''))
|
||||
index += 1
|
||||
}
|
||||
closeParagraph(walk)
|
||||
return success({ blocks: walk.blocks, definitions: walk.definitions })
|
||||
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)
|
||||
}
|
||||
|
||||
// The document's last line ending closes its line rather than opening an empty one.
|
||||
@@ -66,13 +78,9 @@ function normalizeInput(markdown: string): string {
|
||||
.replace(/\n$/, '')
|
||||
}
|
||||
|
||||
function claimedLine(walk: Walk, opener: string): Result<ParsedBlocks> | undefined {
|
||||
const directive = claimsDirectiveLine(opener)
|
||||
if (!directive && !claimsPipeLine(opener)) return undefined
|
||||
closeParagraph(walk)
|
||||
const path = ['content', walk.blocks.length]
|
||||
if (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 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.
|
||||
@@ -139,7 +147,7 @@ function readHtmlBlock(walk: Walk, lines: readonly string[], start: number, html
|
||||
index += 1
|
||||
if (html.closer !== undefined && html.closer.test(line)) break
|
||||
}
|
||||
walk.blocks.push({ kind: 'html', name: html.name })
|
||||
walk.blocks.push({ construct: html.construct, kind: 'html' })
|
||||
return index
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type OpenHtmlBlock = { closer: RegExp | undefined; name: string }
|
||||
export type OpenHtmlBlock = { closer: RegExp | undefined; construct: string }
|
||||
|
||||
type HtmlBlockCondition = { closer: RegExp | undefined; interrupts: boolean; name: string | undefined; start: RegExp }
|
||||
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 =
|
||||
@@ -10,19 +10,19 @@ const completeTag = new RegExp(`^(?:<[A-Za-z][A-Za-z0-9-]*${attributeSource}*[ \
|
||||
const tagName = /^<\/?([A-Za-z][A-Za-z0-9-]*).*$/
|
||||
|
||||
const conditions: HtmlBlockCondition[] = [
|
||||
{ closer: /<\/(?:pre|script|style|textarea)>/i, interrupts: true, name: undefined, start: /^<(?:pre|script|style|textarea)(?:[ \t>]|$)/i },
|
||||
{ closer: /-->/, interrupts: true, name: 'an HTML comment', start: /^<!--/ },
|
||||
{ closer: /\?>/, interrupts: true, name: 'an HTML processing instruction', start: /^<\?/ },
|
||||
{ closer: />/, interrupts: true, name: 'an HTML declaration', start: /^<![A-Za-z]/ },
|
||||
{ closer: /\]\]>/, interrupts: true, name: 'a CDATA section', start: /^<!\[CDATA\[/ },
|
||||
{ closer: undefined, interrupts: true, name: undefined, start: new RegExp(`^</?(?:${blockTagNames})(?:[ \\t>]|/>|$)`, 'i') },
|
||||
{ closer: undefined, interrupts: false, name: undefined, start: completeTag },
|
||||
{ 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, name: condition.name ?? line.replace(tagName, '<$1>') }
|
||||
return { closer: condition.closer, construct: condition.construct ?? line.replace(tagName, '<$1>') }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ test('refuses the raw HTML no element mapping carries', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import type { LeafBlock } from './blocks.ts'
|
||||
import type { ClaimedConstruct, LeafBlock } from './blocks.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { parseBlocks } from './blocks.ts'
|
||||
|
||||
export function markdownToAdf(markdown: string): Result<AdfDocument> {
|
||||
const parsed = parseBlocks(markdown)
|
||||
if (!parsed.ok) return parsed
|
||||
const content: AdfNode[] = []
|
||||
for (const [index, block] of parsed.value.blocks.entries()) {
|
||||
for (const [index, block] of parseBlocks(markdown).blocks.entries()) {
|
||||
const node = blockNode(block, ['content', index])
|
||||
if (!node.ok) return node
|
||||
content.push(node.value)
|
||||
@@ -16,13 +14,19 @@ export function markdownToAdf(markdown: string): Result<AdfDocument> {
|
||||
}
|
||||
|
||||
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 element mapping carries ${block.name}`, path)
|
||||
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' }] }
|
||||
|
||||
Reference in New Issue
Block a user