From e92484eb85e110fb9e9e396cb5026b4e10519172 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 24 Aug 2026 16:18:47 +0200 Subject: [PATCH] Share the CommonMark grammar, close the guard's hole, refuse the lists no marker spells --- AGENTS.md | 5 ++ package.json | 2 +- src/adf-document.ts | 4 +- src/adf-to-markdown.test.ts | 23 ++++++-- src/adf-to-markdown.ts | 106 ++++++++++++++++++++---------------- src/canonical-json.ts | 11 +--- src/commonmark-grammar.ts | 31 +++++++++++ src/corpus.test.ts | 3 +- src/index.ts | 1 + src/json-value.ts | 10 ++++ src/markdown-escaping.ts | 57 +++++++++---------- src/markdown-inline.ts | 57 ++++++++++--------- src/result.ts | 1 + todo.md | 20 +++++-- 14 files changed, 205 insertions(+), 126 deletions(-) create mode 100644 src/commonmark-grammar.ts create mode 100644 src/json-value.ts diff --git a/AGENTS.md b/AGENTS.md index ab0d639..f5aaaf1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,11 @@ Test for the behaviour wanted first, then implement until green. `node --test`, Node, tsc and npm never run on the host — only via the pinned images (§9). Tests are independent, coverage does not decline, containers are torn down after a run. +The floors live in the `test` script, so `npm test` and the gate are one path: 100% of lines and +functions, and a branch floor that only ever moves upward. It sits below 100 because the guards +`noUncheckedIndexedAccess` and ADF's optional keys force — `?? []`, `?? {}`, `?.`, an index +compared against `undefined` — have a half no valid document reaches. + The corpus, all checked in: hand-built fixtures per node and combination; real sanitized ADF from live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite against `markdownToAdf` and `markdownToHtml`. diff --git a/package.json b/package.json index 29f0456..ce73436 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "node": ">=24" }, "scripts": { - "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=89 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", + "test": "node --test --experimental-test-coverage --test-coverage-exclude=\"src/**/*.test.ts\" --test-coverage-branches=91 --test-coverage-functions=100 --test-coverage-lines=100 \"src/**/*.test.ts\"", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/src/adf-document.ts b/src/adf-document.ts index bbe333f..0f37cc6 100644 --- a/src/adf-document.ts +++ b/src/adf-document.ts @@ -1,4 +1,4 @@ -import { isJsonValue, type JsonValue } from './canonical-json.ts' +import { isJsonValue, type JsonValue } from './json-value.ts' export type AdfAttributes = { [key: string]: JsonValue } @@ -48,7 +48,7 @@ function isAdfNode(value: unknown): value is AdfNode { } function isArrayOf(value: unknown, guard: (item: unknown) => item is T): value is T[] { - return Array.isArray(value) && value.every(guard) + return Array.isArray(value) && [...value].every(guard) } function isAttributes(value: unknown): value is AdfAttributes { diff --git a/src/adf-to-markdown.test.ts b/src/adf-to-markdown.test.ts index ec6ad2b..f37a886 100644 --- a/src/adf-to-markdown.test.ts +++ b/src/adf-to-markdown.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' -import type { AdfDocument, AdfNode } from './adf-document.ts' +import type { AdfDocument, AdfMark, AdfNode } from './adf-document.ts' import type { Result } from './result.ts' import { adfToMarkdown } from './index.ts' @@ -109,11 +109,17 @@ test('refuses a node whose content model the canonical form cannot emit', () => assert.equal(code(adfToMarkdown(document({ type: 'listItem' }))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'codeBlock' }))), 'unsupported-node-shape') assert.equal(code(adfToMarkdown(document({ content: [paragraph()], type: 'bulletList' }))), 'unsupported-node-shape') + assert.equal(code(adfToMarkdown(document({ type: 'bulletList' }))), 'unsupported-node-shape') + assert.equal(code(adfToMarkdown(document({ attrs: { order: 2 }, content: [], type: 'orderedList' }))), 'unsupported-node-shape') }) -test('refuses an ordered list start no marker spells', () => { - const items: AdfNode[] = [{ content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' }] - assert.equal(code(adfToMarkdown(document({ attrs: { order: 1.5 }, content: items, type: 'orderedList' }))), 'unsupported-node-shape') +test('refuses an ordered list no marker spells', () => { + const item: AdfNode = { content: [paragraph({ text: 'x', type: 'text' })], type: 'listItem' } + const list = (order: number, items: number): AdfDocument => + document({ attrs: { order }, content: Array.from({ length: items }, () => item), type: 'orderedList' }) + assert.equal(code(adfToMarkdown(list(1.5, 1))), 'unsupported-node-shape') + assert.equal(markdown(adfToMarkdown(list(999999999, 1))), '999999999. x\n') + assert.equal(code(adfToMarkdown(list(999999999, 2))), 'unspellable-list-marker') }) test('refuses a code span over anything but one text node', () => { @@ -150,6 +156,15 @@ test('escapes a heading closing sequence', () => { assert.equal(heading('#tag first'), '## #tag first\n') }) +test('wraps adjacent nodes carrying one mark once, and a differing mark twice', () => { + const marked = (text: string, ...marks: AdfMark[]): AdfNode => ({ marks, text, type: 'text' }) + const emitted = (...content: AdfNode[]): string => markdown(adfToMarkdown(document(paragraph(...content)))) + assert.equal(emitted(marked('a', { type: 'strong' }), marked('b', { type: 'strong' }, { type: 'em' })), '**a*b***\n') + assert.equal(emitted(marked('a', { type: 'strong' }), marked('b', { type: 'em' })), '**a**_b_\n') + const link = (href: string): AdfMark => ({ attrs: { href }, type: 'link' }) + assert.equal(emitted(marked('a', link('http://x')), marked('b', link('http://y'))), '[a](http://x)[b](http://y)\n') +}) + test('emits an empty list item without trailing whitespace', () => { assert.equal(markdown(adfToMarkdown(document({ content: [{ type: 'listItem' }], type: 'bulletList' }))), '-\n') }) diff --git a/src/adf-to-markdown.ts b/src/adf-to-markdown.ts index eeaa908..e92abf9 100644 --- a/src/adf-to-markdown.ts +++ b/src/adf-to-markdown.ts @@ -1,9 +1,11 @@ import type { AdfDocument, AdfNode } from './adf-document.ts' +import type { JsonValue } from './json-value.ts' import { emitInlineLine } from './markdown-inline.ts' import { failure, success, type Result } from './result.ts' import { isAdfDocument } from './adf-document.ts' import { longestBacktickRun } from './backtick-runs.ts' +const largestListMarker = 999999999 const listTypes = ['bulletList', 'orderedList'] export function adfToMarkdown(document: AdfDocument): Result { @@ -16,10 +18,8 @@ export function adfToMarkdown(document: AdfDocument): Result { function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean): Result { let output = '' - for (let index = 0; index < nodes.length; index += 1) { - const node = nodes[index] - if (node === undefined) return failure('unsupported-node-shape', 'the block content holds a hole') - const previous = nodes[index - 1] + let previous: AdfNode | undefined + for (const node of nodes) { if (previous !== undefined) { if (listTypes.includes(node.type) && previous.type === node.type) { return failure('unspellable-adjacent-lists', `two adjacent ${node.type} nodes read back as one list`) @@ -29,6 +29,7 @@ function emitBlocks(nodes: readonly AdfNode[], inListItem: boolean): Result { } function emitBlockquote(node: AdfNode): Result { - const invalid = validateBlockNode(node, []) - if (!invalid.ok) return invalid + const validation = validateBlockNode(node, []) + if (!validation.ok) return validation const inner = emitBlocks(node.content ?? [], false) if (!inner.ok) return inner return success( @@ -60,17 +61,10 @@ function emitBlockquote(node: AdfNode): Result { } function emitCodeBlock(node: AdfNode): Result { - const invalid = validateBlockNode(node, ['language']) - if (!invalid.ok) return invalid - const language = node.attrs?.['language'] - if (language !== undefined) { - if (typeof language !== 'string') return failure('unsupported-node-shape', 'a codeBlock language is no string') - if (language === '') return failure('ambiguous-empty-code-block-language', 'an empty codeBlock language and an absent one share one markdown spelling') - if (language === 'adf') return failure('reserved-adf-language', 'the adf info string is reserved for the opaque carry') - if (/[`\n\r]/.test(language) || language !== language.trim()) { - return failure('unspellable-code-block-language', 'a fence info string holds no backtick and no edge whitespace') - } - } + const validation = validateBlockNode(node, ['language']) + if (!validation.ok) return validation + const info = spellCodeFenceInfo(node.attrs?.['language']) + if (!info.ok) return info let text = '' for (const child of node.content ?? []) { if (child.type !== 'text' || typeof child.text !== 'string' || (child.marks ?? []).length > 0 || Object.keys(child.attrs ?? {}).length > 0) { @@ -79,13 +73,24 @@ function emitCodeBlock(node: AdfNode): Result { text += child.text } const fence = '`'.repeat(Math.max(3, longestBacktickRun(text) + 1)) - const opening = `${fence}${language ?? ''}` + const opening = `${fence}${info.value}` return success(text === '' ? `${opening}\n${fence}` : `${opening}\n${text}\n${fence}`) } +function spellCodeFenceInfo(language: JsonValue | undefined): Result { + if (language === undefined) return success('') + if (typeof language !== 'string') return failure('unsupported-node-shape', 'a codeBlock language is no string') + if (language === '') return failure('ambiguous-empty-code-block-language', 'an empty codeBlock language and an absent one share one markdown spelling') + if (language === 'adf') return failure('reserved-adf-language', 'the adf info string is reserved for the opaque carry') + if (/[`\n\r]/.test(language) || language !== language.trim()) { + return failure('unspellable-code-block-language', 'a fence info string holds no backtick and no edge whitespace') + } + return success(language) +} + function emitHeading(node: AdfNode): Result { - const invalid = validateBlockNode(node, ['level']) - if (!invalid.ok) return invalid + const validation = validateBlockNode(node, ['level']) + if (!validation.ok) return validation const level = node.attrs?.['level'] if (typeof level !== 'number' || !Number.isInteger(level) || level < 1 || level > 6) { return failure('unsupported-heading-level', `no ATX heading spells level ${JSON.stringify(level ?? null)}`) @@ -100,49 +105,56 @@ function emitHeading(node: AdfNode): Result { function emitList(node: AdfNode): Result { const ordered = node.type === 'orderedList' - const invalid = validateBlockNode(node, ordered ? ['order'] : []) - if (!invalid.ok) return invalid + const validation = validateBlockNode(node, ordered ? ['order'] : []) + if (!validation.ok) return validation + const items = node.content ?? [] + if (items.length === 0) return failure('unsupported-node-shape', `a ${node.type} holds at least one listItem`) const start = ordered ? node.attrs?.['order'] : 0 if (ordered && (start === undefined || start === 1)) { return failure('ambiguous-ordered-list-start', 'an orderedList starting at 1 and one with no order share one markdown spelling') } - if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > 999999999) { + if (typeof start !== 'number' || !Number.isInteger(start) || start < 0 || start > largestListMarker) { return failure('unsupported-node-shape', `no list marker spells the order ${JSON.stringify(start ?? null)}`) } - const items: string[] = [] - for (const [offset, item] of (node.content ?? []).entries()) { - if (item.type !== 'listItem') return failure('unsupported-node-shape', `a ${node.type} holds listItem nodes only`) - const invalidItem = validateBlockNode(item, []) - if (!invalidItem.ok) return invalidItem - const inner = emitBlocks(item.content ?? [], true) - if (!inner.ok) return inner - const marker = ordered ? `${start + offset}. ` : '- ' - if (inner.value === '') { - items.push(marker.trimEnd()) - continue - } - const indent = ' '.repeat(marker.length) - items.push( - inner.value - .split('\n') - .map((line, lineIndex) => (lineIndex === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) - .join('\n'), - ) + if (start + items.length - 1 > largestListMarker) { + return failure('unspellable-list-marker', `no list marker spells the ${items.length} items an orderedList starting at ${start} needs`) } - return success(items.join('\n')) + const lines: string[] = [] + for (const [offset, item] of items.entries()) { + if (item.type !== 'listItem') return failure('unsupported-node-shape', `a ${node.type} holds listItem nodes only`) + const emitted = emitListItem(item, ordered ? `${start + offset}. ` : '- ') + if (!emitted.ok) return emitted + lines.push(emitted.value) + } + return success(lines.join('\n')) +} + +function emitListItem(item: AdfNode, marker: string): Result { + const validation = validateBlockNode(item, []) + if (!validation.ok) return validation + const inner = emitBlocks(item.content ?? [], true) + if (!inner.ok) return inner + if (inner.value === '') return success(marker.trimEnd()) + const indent = ' '.repeat(marker.length) + return success( + inner.value + .split('\n') + .map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) + .join('\n'), + ) } function emitParagraph(node: AdfNode): Result { - const invalid = validateBlockNode(node, []) - if (!invalid.ok) return invalid + const validation = validateBlockNode(node, []) + if (!validation.ok) return validation const content = node.content ?? [] if (content.length === 0) return success('::paragraph') return emitInlineLine(content, 'paragraph') } function emitRule(node: AdfNode): Result { - const invalid = validateBlockNode(node, []) - if (!invalid.ok) return invalid + const validation = validateBlockNode(node, []) + if (!validation.ok) return validation if ((node.content ?? []).length > 0) return failure('unsupported-node-shape', 'a rule holds no content') return success('---') } diff --git a/src/canonical-json.ts b/src/canonical-json.ts index e19b8c3..2f08e20 100644 --- a/src/canonical-json.ts +++ b/src/canonical-json.ts @@ -1,16 +1,7 @@ -export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue } +import type { JsonValue } from './json-value.ts' export type JsonSpelling = 'compact' | 'two-space' -export function isJsonValue(value: unknown): value is JsonValue { - if (value === null) return true - if (typeof value === 'boolean' || typeof value === 'string') return true - if (typeof value === 'number') return Number.isFinite(value) - if (Array.isArray(value)) return value.every(isJsonValue) - if (typeof value === 'object') return Object.values(value).every(isJsonValue) - return false -} - export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling): string { return serialize(value, spelling === 'compact' ? '' : ' ', 0) } diff --git a/src/commonmark-grammar.ts b/src/commonmark-grammar.ts new file mode 100644 index 0000000..db6dbcf --- /dev/null +++ b/src/commonmark-grammar.ts @@ -0,0 +1,31 @@ +export type LinePosition = 'first' | 'later' + +const entityReferenceSource = '&(?:[A-Za-z][A-Za-z0-9]{1,31}|#\\d{1,7}|#[Xx][A-Fa-f0-9]{1,6});' + +const anchoredEntityReference = new RegExp(`^(?:${entityReferenceSource})`) +const entityReference = new RegExp(entityReferenceSource) +const firstCharacterOpeners = [/^#{1,6}(?:[ \t]|$)/, /^>/, /^[*+-](?:[ \t]|$)/, /^`{3,}/, /^~{3,}/, /^:{2,}/, /^\|/] +const orderedListOpener = /^(\d{1,9})[.)](?:[ \t]|$)/ +const setextUnderline = /^=+$/ +const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/ + +export function claimsLine(line: string, position: LinePosition): boolean { + return escapesLineClaim(line, 0, position) || orderedListOpener.test(line) +} + +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 + return position === 'later' && setextUnderline.test(line) + } + const digits = orderedListOpener.exec(line)?.[1] + return digits !== undefined && offset === digits.length +} + +export function holdsEntityReference(text: string): boolean { + return entityReference.test(text) +} + +export function startsEntityReference(text: string): boolean { + return anchoredEntityReference.test(text) +} diff --git a/src/corpus.test.ts b/src/corpus.test.ts index d22e715..18345e6 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -6,7 +6,8 @@ import { fileURLToPath } from 'node:url' import { adfToMarkdown } from './adf-to-markdown.ts' import { isAdfDocument } from './adf-document.ts' -import { isJsonValue, serializeCanonicalJson } from './canonical-json.ts' +import { isJsonValue } from './json-value.ts' +import { serializeCanonicalJson } from './canonical-json.ts' const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '..', 'corpus') const roundTripRoot = join(corpusRoot, 'round-trip') diff --git a/src/index.ts b/src/index.ts index 8384354..6afbe03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf-document.ts' export type { ConvertError, ConvertErrorCode, Result } from './result.ts' +export type { JsonValue } from './json-value.ts' export { adfToMarkdown } from './adf-to-markdown.ts' export { isAdfDocument } from './adf-document.ts' diff --git a/src/json-value.ts b/src/json-value.ts new file mode 100644 index 0000000..d90553c --- /dev/null +++ b/src/json-value.ts @@ -0,0 +1,10 @@ +export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue } + +export function isJsonValue(value: unknown): value is JsonValue { + if (value === null) return true + if (typeof value === 'boolean' || typeof value === 'string') return true + if (typeof value === 'number') return Number.isFinite(value) + if (Array.isArray(value)) return [...value].every(isJsonValue) + if (typeof value === 'object') return Object.values(value).every(isJsonValue) + return false +} diff --git a/src/markdown-escaping.ts b/src/markdown-escaping.ts index 4ec7805..138c142 100644 --- a/src/markdown-escaping.ts +++ b/src/markdown-escaping.ts @@ -1,3 +1,5 @@ +import { escapesLineClaim, startsEntityReference, type LinePosition } from './commonmark-grammar.ts' + export type InlineSegment = { kind: 'emphasis-close' | 'emphasis-open' | 'link-text' | 'literal' | 'syntax' text: string @@ -6,33 +8,19 @@ export type InlineSegment = { export type LineContainer = 'heading' | 'paragraph' const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/ -const entityReference = /^&(?:[A-Za-z][A-Za-z0-9]{1,31}|#\d{1,7}|#[Xx][A-Fa-f0-9]{1,6});/ const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>]*>/, /^<[^\s<>@]+@[^\s<>@]+>/] const inlineDirective = /^:[a-z][A-Za-z0-9]*[[{]/ const linkOpener = /\](?=[([:])/ -const orderedListMarker = /^\d{1,9}$/ -const setextUnderline = /^=+$/ -const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/ const unicodePunctuation = /[\p{P}\p{S}]/u const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u -const escapableOpeners = [/^#{1,6}(?:[ \t]|$)/, /^>/, /^[*+-](?:[ \t]|$)/, /^`{3,}/, /^~{3,}/, /^:{2,}/, /^\|/] -const blockOpeners = [...escapableOpeners, /^\d{1,9}[.)](?:[ \t]|$)/] - export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): string { return escape(resolveEmphasis(segments), container) } -export function lineOpensBlock(line: string): boolean { - return blockOpeners.some((opener) => opener.test(line)) || thematicBreak.test(line) -} - -export function isWordCharacter(character: string): boolean { - return character !== '' && !unicodeWhitespace.test(character) && !unicodePunctuation.test(character) -} - function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] { const resolved = segments.map((segment) => ({ ...segment })) + // Offsets index the pre-swap text: every emphasis spelling this swaps between is one character wide. const scan = resolved.map((segment) => segment.text).join('') const offsets: number[] = [] let offset = 0 @@ -77,19 +65,29 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): s } function opensConstruct(scan: string, index: number, inLinkText: boolean, container: LineContainer, escaped: ReadonlySet): boolean { + const claimsLine = container === 'heading' ? closesHeading(scan, index) : claimsLineStart(scan, index) + return claimsLine || claimsCharacter(scan, index, inLinkText, escaped) +} + +function claimsLineStart(scan: string, index: number): boolean { + const start = scan.lastIndexOf('\n', index - 1) + 1 + const end = scan.indexOf('\n', index) + const line = scan.slice(start, end === -1 ? undefined : end) + const position: LinePosition = start === 0 ? 'first' : 'later' + return escapesLineClaim(line, index - start, position) +} + +function closesHeading(scan: string, index: number): boolean { + if (scan.charAt(index) !== '#' || !/^#+$/.test(scan.slice(index))) return false + return index === 0 || /[ \t]/.test(scan.charAt(index - 1)) +} + +function claimsCharacter(scan: string, index: number, inLinkText: boolean, escaped: ReadonlySet): boolean { const character = scan.charAt(index) const rest = scan.slice(index) - const line = rest.split('\n')[0] ?? '' - if (container === 'paragraph' && (index === 0 || scan.charAt(index - 1) === '\n')) { - if (escapableOpeners.some((opener) => opener.test(rest))) return true - if (thematicBreak.test(line)) return true - if (index > 0 && setextUnderline.test(line)) return true - } - if (container === 'paragraph' && (character === '.' || character === ')') && closesOrderedListMarker(scan, index)) return true - if (container === 'heading' && character === '#' && /^#+$/.test(rest) && (index === 0 || /[ \t]/.test(scan.charAt(index - 1)))) return true if (inLinkText && (character === '[' || character === ']')) return true if (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1)) - if (character === '&') return entityReference.test(rest) + if (character === '&') return startsEntityReference(rest) if (character === '<') return htmlConstructs.some((construct) => construct.test(rest)) if (character === ':') return inlineDirective.test(rest) if (character === '[') return linkOpener.test(rest) @@ -98,13 +96,6 @@ function opensConstruct(scan: string, index: number, inLinkText: boolean, contai return false } -function closesOrderedListMarker(scan: string, index: number): boolean { - const lineStart = scan.lastIndexOf('\n', index - 1) + 1 - if (!orderedListMarker.test(scan.slice(lineStart, index))) return false - const following = scan.charAt(index + 1) - return following === '' || following === ' ' || following === '\t' || following === '\n' -} - function opensCodeSpan(scan: string, index: number, escaped: ReadonlySet): boolean { if (!startsRun(scan, index, escaped)) return false const length = runLength(scan, index) @@ -155,6 +146,10 @@ function isWhitespace(character: string): boolean { return character === '' || unicodeWhitespace.test(character) } +function isWordCharacter(character: string): boolean { + return character !== '' && !unicodeWhitespace.test(character) && !unicodePunctuation.test(character) +} + function charAt(text: string, index: number): string { return index < 0 ? '' : text.charAt(index) } diff --git a/src/markdown-inline.ts b/src/markdown-inline.ts index 739682f..777137b 100644 --- a/src/markdown-inline.ts +++ b/src/markdown-inline.ts @@ -1,5 +1,6 @@ import type { AdfMark, AdfNode } from './adf-document.ts' -import { assembleInlineLine, lineOpensBlock, type InlineSegment, type LineContainer } from './markdown-escaping.ts' +import { assembleInlineLine, type InlineSegment, type LineContainer } from './markdown-escaping.ts' +import { claimsLine, holdsEntityReference } from './commonmark-grammar.ts' import { failure, success, type Result } from './result.ts' import { longestBacktickRun } from './backtick-runs.ts' import { serializeCanonicalJson } from './canonical-json.ts' @@ -10,20 +11,21 @@ type InlineContext = { inLinkText: boolean } +type InlineRun = { kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { kind: 'plain'; node: AdfNode } + const autolink = /^[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>\u0000-\u001f\u007f]*$/ const controlCharacter = /[\u0000-\u001f\u007f]/ -const entityReference = /&(?:[A-Za-z][A-Za-z0-9]{1,31}|#\d{1,7}|#[Xx][A-Fa-f0-9]{1,6});/ const linkAttributes = ['href', 'title'] export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer): Result { const segments = emitRun(nodes, 0, { atBlockEnd: true, container, inLinkText: false }) if (!segments.ok) return segments const line = assembleInlineLine(segments.value, container) - for (const single of line.split('\n')) { + for (const [index, single] of line.split('\n').entries()) { if (/^[ \t]|[ \t]$/.test(single)) { return failure('unspellable-whitespace', 'a line begins or ends with whitespace CommonMark strips') } - if (container === 'paragraph' && lineOpensBlock(single)) { + if (container === 'paragraph' && claimsLine(single, index === 0 ? 'first' : 'later')) { return failure('unspellable-line-start', `block parsing would claim the emitted line ${JSON.stringify(single)}`) } } @@ -31,29 +33,32 @@ export function emitInlineLine(nodes: readonly AdfNode[], container: LineContain } function emitRun(nodes: readonly AdfNode[], depth: number, context: InlineContext): Result { + const runs = inlineRuns(nodes, depth) const segments: InlineSegment[] = [] - let index = 0 - while (index < nodes.length) { - const node = nodes[index] - if (node === undefined) return failure('unsupported-node-shape', 'the inline content holds a hole') - const mark = (node.marks ?? [])[depth] - if (mark === undefined) { - const leaf = emitLeaf(node, { ...context, atBlockEnd: context.atBlockEnd && index === nodes.length - 1 }) - if (!leaf.ok) return leaf - segments.push(...leaf.value) - index += 1 - continue - } - let end = index + 1 - while (end < nodes.length && sameMark((nodes[end]?.marks ?? [])[depth], mark)) end += 1 - const wrapped = emitMarkedRun(nodes.slice(index, end), mark, depth, { ...context, atBlockEnd: context.atBlockEnd && end === nodes.length }) - if (!wrapped.ok) return wrapped - segments.push(...wrapped.value) - index = end + for (const [index, run] of runs.entries()) { + const runContext = { ...context, atBlockEnd: context.atBlockEnd && index === runs.length - 1 } + const emitted = run.kind === 'plain' ? emitLeaf(run.node, runContext) : emitMarkedRun(run.nodes, run.mark, depth, runContext) + if (!emitted.ok) return emitted + segments.push(...emitted.value) } return success(segments) } +function inlineRuns(nodes: readonly AdfNode[], depth: number): InlineRun[] { + const runs: InlineRun[] = [] + for (const node of nodes) { + const mark = (node.marks ?? [])[depth] + if (mark === undefined) { + runs.push({ kind: 'plain', node }) + continue + } + const previous = runs[runs.length - 1] + if (previous?.kind === 'marked' && sameMark(previous.mark, mark)) previous.nodes.push(node) + else runs.push({ kind: 'marked', mark, nodes: [node] }) + } + return runs +} + function emitLeaf(node: AdfNode, context: InlineContext): Result { if (node.type !== 'hardBreak' && node.type !== 'text') { return failure('unsupported-node-type', `the canonical form spells no inline node of type ${node.type}`) @@ -127,7 +132,7 @@ function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, conte function spellDestination(href: string): Result { if (controlCharacter.test(href)) return failure('unspellable-link-destination', 'a link destination holds a control character') if (href.includes('\\')) return failure('unspellable-link-destination', 'no canonical escape spells a backslash in a link destination') - if (entityReference.test(href)) { + if (holdsEntityReference(href)) { return failure('unspellable-link-destination', 'a link destination shaped like an entity reference decodes on the way back') } if (href.includes(' ')) { @@ -143,7 +148,7 @@ function spellDestination(href: string): Result { function spellTitle(title: string): Result { if (/["\n\r\\]/.test(title)) return failure('unspellable-link-title', 'no canonical escape spells a quote, backslash or newline in a link title') - if (entityReference.test(title)) return failure('unspellable-link-title', 'a link title shaped like an entity reference decodes on the way back') + if (holdsEntityReference(title)) return failure('unspellable-link-title', 'a link title shaped like an entity reference decodes on the way back') return success(` "${title}"`) } @@ -157,7 +162,7 @@ function balanced(href: string): boolean { return depth === 0 } -function sameMark(candidate: AdfMark | undefined, mark: AdfMark): boolean { - if (candidate === undefined || candidate.type !== mark.type) return false +function sameMark(candidate: AdfMark, mark: AdfMark): boolean { + if (candidate.type !== mark.type) return false return serializeCanonicalJson(candidate.attrs ?? {}, 'compact') === serializeCanonicalJson(mark.attrs ?? {}, 'compact') } diff --git a/src/result.ts b/src/result.ts index 75fc667..576c045 100644 --- a/src/result.ts +++ b/src/result.ts @@ -8,6 +8,7 @@ export type ConvertErrorCode = | 'unspellable-line-start' | 'unspellable-link-destination' | 'unspellable-link-title' + | 'unspellable-list-marker' | 'unspellable-mark' | 'unspellable-whitespace' | 'unspelled-block-marks' diff --git a/todo.md b/todo.md index b198e56..614aa94 100644 --- a/todo.md +++ b/todo.md @@ -60,8 +60,15 @@ detail is settled at its own milestone. itself under the library's own canonical serializer — one implementation, keys sorted, two spellings: two-space indent for the corpus files and the block carry's body, compact for the inline carry. `commonmark-subset/` green. - - [ ] **2b — Block nodes.** `block-nodes/` green. - - [ ] **2c — Inline nodes and marks.** `inline-nodes/` green. + - [ ] **2b — Block nodes.** `block-nodes/` green. Block separation becomes + `separationBetween(previous, next, container)` here — a boolean cannot hold the third case + `spec/flavour.md` states for two directive blocks in a container body, and the maintainer's + answer on a CommonMark block beside a directive block (1d) drops into the same seam. Give + the emitter's refusals a corpus home while the directories grow: `corpus/unspellable/`, + a `.json` beside the `ConvertErrorCode` it must return, the emitter half of `corpus/errors/`. + - [ ] **2c — Inline nodes and marks.** `inline-nodes/` green. `InlineSegment.kind` splits into + its two axes here — escapability (`attribute` for `:text{text="…"}`, `backslash`, `none`) + and the emphasis role — rather than gaining a third value that means one of each. - [ ] **2d — The opaque carry** (§3). Fixtures and emitter together, into `corpus/round-trip/opaque-carry/`: an unknown node in both positions, the reserved `adf` info string, and the `codeBlock` whose language is `adf`. @@ -80,9 +87,14 @@ detail is settled at its own milestone. line that does not parse, the content slot, raw HTML with no mapping — each with the error it must return). The raw-HTML element mapping is empty until milestone 6, so at `0.1.0` every raw-HTML construct in input is an error result. The CommonMark spec suite runs - against it from here (§10). + against it from here (§10). `src/` gets its hierarchy at the same split — `adf/`, + `markdown/`, `html/`, the grammar module shared inside `markdown/` — while the rename is + still mechanical. - [ ] **4 — Round-trip property tests** over the corpus, both ways — the thing that proves 2 and - 3. Generators emit editor-normal ADF (§2). Real sanitized ADF from live Atlassian APIs lands + 3. Editor-normal (§2) gets its implementation here — `toEditorNormal(doc)` and the equality + the round-trip asserts, which over normalized input is the canonical serializer's compact + spelling — rather than staying spelled inline as `?? []` at every reader. + Generators emit editor-normal ADF (§2). Real sanitized ADF from live Atlassian APIs lands here too (§10), in `corpus/real-payloads/`: an ADF→markdown→ADF check with no expected markdown, the payloads supplied by the maintainer. - [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret,