From eafd863a32d2e6126578d7bf71f90e913fe0ce00 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 20:58:20 +0200 Subject: [PATCH 01/10] 4.2: an empty link destination beside a title spells <> --- .../link-empty-destination.json | 24 +++++++++++++++++++ .../link-empty-destination.md | 1 + spec/flavour.md | 9 +++---- src/markdown/emit/inline-line.ts | 10 ++++---- src/markdown/link-syntax.ts | 10 +++++++- 5 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 corpus/round-trip/commonmark-subset/link-empty-destination.json create mode 100644 corpus/round-trip/commonmark-subset/link-empty-destination.md diff --git a/corpus/round-trip/commonmark-subset/link-empty-destination.json b/corpus/round-trip/commonmark-subset/link-empty-destination.json new file mode 100644 index 0000000..9cad883 --- /dev/null +++ b/corpus/round-trip/commonmark-subset/link-empty-destination.json @@ -0,0 +1,24 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "attrs": { + "href": "", + "title": "" + }, + "type": "link" + } + ], + "text": "a", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/commonmark-subset/link-empty-destination.md b/corpus/round-trip/commonmark-subset/link-empty-destination.md new file mode 100644 index 0000000..95ed36f --- /dev/null +++ b/corpus/round-trip/commonmark-subset/link-empty-destination.md @@ -0,0 +1 @@ +[a](<> "") diff --git a/spec/flavour.md b/spec/flavour.md index 853f94b..8ae4022 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -43,10 +43,11 @@ normalizes to it through the round-trip. CommonMark admits no spelling — the end of a block, inside an ATX heading — or where the node carries an attribute, it is the inline directive. - An empty paragraph — real payloads carry them — is `::paragraph`. -- Links `[text](url)`; `<…>` around a destination containing spaces; title in double quotes. A - backslash escapes a parenthesis the destination leaves unbalanced, and a quote inside the title; - a balanced pair stays bare. `` autolink form only when the text equals the destination and - the destination is a valid CommonMark autolink (absolute URI). +- Links `[text](url)`; `<…>` around a destination containing spaces, `<>` an empty one beside a + title; title in double quotes. A backslash escapes a parenthesis the destination leaves + unbalanced, and a quote inside the title; a balanced pair stays bare. `` autolink form only + when the text equals the destination and the destination is a valid CommonMark autolink + (absolute URI). - Paragraphs on one line — no soft wrapping; a soft line break in input becomes a single space. - Entity references in input decode to their characters; output backslash-escapes only where text would otherwise parse as syntax, scanning the assembled line rather than each text node: escape diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 71aaaa1..807357e 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -12,7 +12,7 @@ import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { sameMark } from '../../adf/editor-normal.ts' import { slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts' -import { spellDestination, spellTitle } from '../link-syntax.ts' +import { spellDestination, spellLinkTarget } from '../link-syntax.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' import { spellTextDirective } from '../text-directive.ts' @@ -285,13 +285,11 @@ function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range const node = nodes[0] const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1 if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] }) - const destination = spellDestination(href, path) - if (!destination.ok) return destination - const spelledTitle = typeof title === 'string' ? spellTitle(title, path) : success('') - if (!spelledTitle.ok) return spelledTitle + const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined, path) + if (!target.ok) return target const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true }) if (!inner.ok) return inner if (inner.value.carry !== undefined) return inner - return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${destination.value}${spelledTitle.value})`)] }) + return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${target.value})`)] }) } diff --git a/src/markdown/link-syntax.ts b/src/markdown/link-syntax.ts index 488011e..7be2a26 100644 --- a/src/markdown/link-syntax.ts +++ b/src/markdown/link-syntax.ts @@ -106,7 +106,15 @@ export function spellDestination(href: string, path: ConvertErrorPath): Result { +export function spellLinkTarget(href: string, title: string | undefined, path: ConvertErrorPath): Result { + const destination = spellDestination(href, path) + if (!destination.ok || title === undefined) return destination + const spelledTitle = spellTitle(title, path) + if (!spelledTitle.ok) return spelledTitle + return success(`${destination.value === '' ? '<>' : destination.value}${spelledTitle.value}`) +} + +function spellTitle(title: string, path: ConvertErrorPath): Result { if (/[\n\r\\]/.test(title)) { return failure('unspellable-link', 'no canonical escape spells a backslash or newline in a link title', path) } -- 2.52.0 From 9303235434e0d3dcdbb08c00368f649d3a8c195e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 21:00:15 +0200 Subject: [PATCH 02/10] 4.2: a link's brackets balance inside a directive's content --- .../combinations/directive-link-brackets.json | 111 ++++++++++++++++++ .../combinations/directive-link-brackets.md | 7 ++ spec/flavour.md | 8 +- src/markdown/emit/inline-line.ts | 8 +- src/markdown/link-syntax.ts | 30 ++--- 5 files changed, 143 insertions(+), 21 deletions(-) create mode 100644 corpus/round-trip/combinations/directive-link-brackets.json create mode 100644 corpus/round-trip/combinations/directive-link-brackets.md diff --git a/corpus/round-trip/combinations/directive-link-brackets.json b/corpus/round-trip/combinations/directive-link-brackets.json new file mode 100644 index 0000000..850d741 --- /dev/null +++ b/corpus/round-trip/combinations/directive-link-brackets.json @@ -0,0 +1,111 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "type": "subsup" + }, + { + "attrs": { + "href": "[" + }, + "type": "link" + } + ], + "text": "a", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "]" + }, + "type": "link" + } + ], + "text": "b", + "type": "text" + }, + { + "marks": [ + { + "type": "underline" + } + ], + "text": " ", + "type": "text" + }, + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "x", + "title": "[" + }, + "type": "link" + } + ], + "text": "c", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "e[f]" + }, + "type": "link" + } + ], + "text": "d", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "http://a[b" + }, + "type": "link" + } + ], + "text": "http://a[b", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/directive-link-brackets.md b/corpus/round-trip/combinations/directive-link-brackets.md new file mode 100644 index 0000000..e677943 --- /dev/null +++ b/corpus/round-trip/combinations/directive-link-brackets.md @@ -0,0 +1,7 @@ +:subsup[[a](\[)] + +:underline[[b](\]) [c](x "\[")] + +:underline[[d](e[f])] + +:underline[[http://a\[b](http://a\[b)] diff --git a/spec/flavour.md b/spec/flavour.md index 8ae4022..98bfb8a 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -129,9 +129,11 @@ quoted where bare carries it, an escape longer than it need be, an empty `{attrs each a named error naming the spelling to write instead. **Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as -directive syntax — the leading `:` of a would-be directive, `]` inside content, a `{` right -after a directive's closing `]`, which would otherwise be read as the attributes it has none -of; outside code spans and code blocks, a backslash before `:` in input yields a literal colon. +directive syntax — the leading `:` of a would-be directive, `]` inside content, a bracket a link's +destination and title inside content leave unbalanced (an autolink holding one takes the +`[text](url)` form), a `{` right after a directive's closing `]`, which would otherwise be read as +the attributes it has none of; outside code spans and code blocks, a backslash before `:` in input +yields a literal colon. **Malformed directives are error results**, named: an unclosed container at end of input, a body fence line of the container's length or longer, a bare colon-run line outside any container or diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 807357e..e889383 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -3,6 +3,7 @@ import type { InlineDirective } from '../../adf/inline-directives.ts' import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts' import { carriedInline } from '../opaque-carry.ts' import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts' +import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../link-syntax.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { holdsEntityReference } from '../entity-references.ts' import { inlineDirective } from '../../adf/inline-directives.ts' @@ -12,7 +13,6 @@ import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { sameMark } from '../../adf/editor-normal.ts' import { slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts' -import { spellDestination, spellLinkTarget } from '../link-syntax.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' import { spellTextDirective } from '../text-directive.ts' @@ -284,12 +284,14 @@ function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range if (typeof href !== 'string') return success({ carry: range }) const node = nodes[0] const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1 - if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] }) + const balanced = !context.bracketed || escapeUnbalanced(href, '[', ']') === href + if (bare && balanced && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] }) const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined, path) if (!target.ok) return target const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true }) if (!inner.ok) return inner if (inner.value.carry !== undefined) return inner - return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${target.value})`)] }) + const spelledTarget = context.bracketed ? escapeUnbalanced(target.value, '[', ']') : target.value + return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${spelledTarget})`)] }) } diff --git a/src/markdown/link-syntax.ts b/src/markdown/link-syntax.ts index 7be2a26..38aff4d 100644 --- a/src/markdown/link-syntax.ts +++ b/src/markdown/link-syntax.ts @@ -103,7 +103,7 @@ export function spellDestination(href: string, path: ConvertErrorPath): Result`) } if (href.startsWith('<')) return failure('unspellable-link', 'a bare link destination cannot begin with an angle bracket', path) - return success(escapeUnbalanced(href)) + return success(escapeUnbalanced(href, '(', ')')) } export function spellLinkTarget(href: string, title: string | undefined, path: ConvertErrorPath): Result { @@ -114,6 +114,20 @@ export function spellLinkTarget(href: string, title: string | undefined, path: C return success(`${destination.value === '' ? '<>' : destination.value}${spelledTitle.value}`) } +export function escapeUnbalanced(spelling: string, opener: string, closer: string): string { + const open: number[] = [] + const unbalanced = new Set() + for (let index = 0; index < spelling.length; index += backslashEscape(spelling, index) === undefined ? 1 : 2) { + const character = spelling.charAt(index) + if (character === opener) open.push(index) + if (character === closer && open.pop() === undefined) unbalanced.add(index) + } + for (const index of open) unbalanced.add(index) + let spelled = '' + for (let index = 0; index < spelling.length; index += 1) spelled += (unbalanced.has(index) ? '\\' : '') + spelling.charAt(index) + return spelled +} + function spellTitle(title: string, path: ConvertErrorPath): Result { if (/[\n\r\\]/.test(title)) { return failure('unspellable-link', 'no canonical escape spells a backslash or newline in a link title', path) @@ -121,17 +135,3 @@ function spellTitle(title: string, path: ConvertErrorPath): Result { if (holdsEntityReference(title)) return failure('unspellable-link', 'a link title holds an entity reference that decodes on the way back', path) return success(` "${title.replaceAll('"', '\\"')}"`) } - -function escapeUnbalanced(href: string): string { - const open: number[] = [] - const unbalanced = new Set() - for (let index = 0; index < href.length; index += 1) { - const character = href.charAt(index) - if (character === '(') open.push(index) - if (character === ')' && open.pop() === undefined) unbalanced.add(index) - } - for (const index of open) unbalanced.add(index) - let spelled = '' - for (let index = 0; index < href.length; index += 1) spelled += (unbalanced.has(index) ? '\\' : '') + href.charAt(index) - return spelled -} -- 2.52.0 From a2672bdc5b1685d884ed19e9903ae58e48455f66 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 21:10:10 +0200 Subject: [PATCH 03/10] 4.2: a list whose item holds a whitespace-only line takes the directive form --- .../list-item-whitespace-line.json | 57 +++++++++++++++++++ .../combinations/list-item-whitespace-line.md | 20 +++++++ spec/flavour.md | 3 +- src/markdown/commonmark-grammar.ts | 4 ++ src/markdown/emit/adf-to-markdown.ts | 6 +- src/markdown/parse/blocks.ts | 14 ++--- 6 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 corpus/round-trip/combinations/list-item-whitespace-line.json create mode 100644 corpus/round-trip/combinations/list-item-whitespace-line.md diff --git a/corpus/round-trip/combinations/list-item-whitespace-line.json b/corpus/round-trip/combinations/list-item-whitespace-line.json new file mode 100644 index 0000000..e1ffdb8 --- /dev/null +++ b/corpus/round-trip/combinations/list-item-whitespace-line.json @@ -0,0 +1,57 @@ +{ + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "text": " \na", + "type": "text" + } + ], + "type": "codeBlock" + } + ], + "type": "listItem" + } + ], + "type": "bulletList" + }, + { + "attrs": { + "order": 1 + }, + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "content": [ + { + "text": "\t", + "type": "text" + } + ], + "type": "codeBlock" + } + ], + "type": "listItem" + } + ], + "type": "bulletList" + } + ], + "type": "listItem" + } + ], + "type": "orderedList" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/list-item-whitespace-line.md b/corpus/round-trip/combinations/list-item-whitespace-line.md new file mode 100644 index 0000000..f4b0d02 --- /dev/null +++ b/corpus/round-trip/combinations/list-item-whitespace-line.md @@ -0,0 +1,20 @@ +::::bulletList +:::listItem +``` + +a +``` +::: +:::: + +::::::orderedList {order=1} +:::::listItem +::::bulletList +:::listItem +``` + +``` +::: +:::: +::::: +:::::: diff --git a/spec/flavour.md b/spec/flavour.md index 98bfb8a..9f8a961 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -28,7 +28,8 @@ normalizes to it through the round-trip. adjacent lists of a kind back as one. The leaf `::listBreak` parts them, taking the separation any directive block takes where it sits. It builds no node, and it reads only between two adjacent lists of one type: elsewhere, or carrying an argument, `{attrs}` or a body, it is a - named error. + named error. A list whose item holds a line of spaces or tabs alone, which a list item reads + back empty, takes the directive form. - Blockquotes prefix lines with `> `; a blank line inside a blockquote is a bare `>`. - ATX headings (`#` … `######`); setext input normalizes to ATX. - Code fences ``` with the node's language as info string, the fence lengthened past any backtick diff --git a/src/markdown/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts index 0fbefe1..9ed129b 100644 --- a/src/markdown/commonmark-grammar.ts +++ b/src/markdown/commonmark-grammar.ts @@ -167,6 +167,10 @@ export function isAutolink(text: string): boolean { return autolink.test(text) } +export function isBlankLine(line: string): boolean { + return /^[ \t]*$/.test(line) +} + export function isThematicBreak(line: string): boolean { return thematicBreak.test(line) } diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 7f861b8..dfd8827 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -6,7 +6,7 @@ import { carriedBlock } from '../opaque-carry.ts' import { emitInlineLine } from './inline-line.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { fencedCodeBlock } from '../backtick-runs.ts' -import { holdsNullCharacter, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts' +import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark-grammar.ts' import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { listBreakSpelling } from '../list-break.ts' @@ -225,8 +225,10 @@ function emitListItem(item: AdfNode, marker: string, path: ConvertErrorPath, dep const inner = emitBlocks(nodeContent(item), 'list-item', path, depth + 1) if (!inner.ok) return inner if (inner.value.text === '') return success({ fenceColons: 0, text: marker.trimEnd() }) + const body = inner.value.text.split('\n') + if (body.some((line) => line !== '' && isBlankLine(line))) return undefined const indent = ' '.repeat(marker.length) - const lines = inner.value.text.split('\n').map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) + const lines = body.map((line, index) => (index === 0 ? `${marker}${line}` : line === '' ? '' : `${indent}${line}`)) if (isThematicBreak(lines[0] ?? '')) return undefined return success({ fenceColons: inner.value.fenceColons, text: lines.join('\n') }) } diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index 866ff31..7527d38 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -7,6 +7,7 @@ import { claimsPipeLine, closingCodeFence, decodeTextEscapes, + isBlankLine, isThematicBreak, listMarker, markerInterruptsParagraph, @@ -61,7 +62,6 @@ type Line = { column: number; text: string } type Walk = ParsedBlocks & { leaf: OpenLeaf | undefined; position: SourcePosition; stack: OpenContainer[] } -const blankLine = /^[ \t]*$/ const indentedCodeColumns = 4 const largestOpenerIndentation = 3 const leafColons = 2 @@ -117,7 +117,7 @@ function continuesContainer(walk: Walk, container: OpenContainer, line: Line): L // A directive container has no continuation marker: only its own fence closes it. if (container.kind === 'directive') return line // A list item begins with at most one blank line: an empty one gives the second up. - if (blankLine.test(line.text)) { + if (isBlankLine(line.text)) { return container.blocks.length === 0 && walk.leaf === undefined ? undefined : { column: line.column, text: '' } } return leadingColumns(line) < container.indentation ? undefined : removeColumns(line, container.indentation) @@ -155,7 +155,7 @@ function itemStart(line: Line, opener: Line, paragraphOpen: boolean, enclosing: const marker = listMarker(opener.text) if (marker === undefined) return undefined const after: Line = { column: opener.column + marker.width, text: opener.text.slice(marker.width) } - const blank = blankLine.test(after.text) + const blank = isBlankLine(after.text) if (paragraphOpen && !markerInterruptsParagraph(marker.start, blank)) return undefined const spaces = leadingColumns(after) const padding = blank || spaces > indentedCodeColumns ? 1 : spaces @@ -268,7 +268,7 @@ function pushFault(walk: Walk, fault: ConvertFault): void { // A claimed line ends the lazy continuation CommonMark would fold it into (spec/flavour.md). function continuesLazily(walk: Walk, line: Line): boolean { - if (walk.leaf?.kind !== 'paragraph' || blankLine.test(line.text)) return false + if (walk.leaf?.kind !== 'paragraph' || isBlankLine(line.text)) return false if (leadingColumns(line) >= indentedCodeColumns) return true const opener = removeColumns(line, largestOpenerIndentation).text if (claimsDirectiveLine(opener) || claimsPipeLine(opener) || isThematicBreak(opener)) return false @@ -283,7 +283,7 @@ function readBlockLine(walk: Walk, line: Line): void { return } if (leaf?.kind === 'html') { - if (leaf.closer === undefined ? blankLine.test(line.text) : leaf.closer.test(line.text)) closeLeaf(walk) + if (leaf.closer === undefined ? isBlankLine(line.text) : leaf.closer.test(line.text)) closeLeaf(walk) return } if (leaf?.kind === 'pipe-table') { @@ -298,7 +298,7 @@ function readBlockLine(walk: Walk, line: Line): void { if (readIndentedCodeLine(leaf, line)) return closeLeaf(walk) } - if (blankLine.test(line.text)) { + if (isBlankLine(line.text)) { closeLeaf(walk) return } @@ -310,7 +310,7 @@ function readBlockLine(walk: Walk, line: Line): void { } function readIndentedCodeLine(leaf: Extract, line: Line): boolean { - if (blankLine.test(line.text)) { + if (isBlankLine(line.text)) { leaf.held.push(removeColumns(line, indentedCodeColumns).text) return true } -- 2.52.0 From 507f68aa13c38da8160265c099f5be8bd7aa5ee6 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 21:16:08 +0200 Subject: [PATCH 04/10] 4.2: a link target's backtick escapes where it would open a code span inside a directive's content --- .../directive-link-backticks.json | 83 +++++++++++++++++++ .../combinations/directive-link-backticks.md | 5 ++ spec/flavour.md | 6 +- src/markdown/emit/inline-line.ts | 8 +- src/markdown/emit/line-escaping.ts | 15 ++-- 5 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 corpus/round-trip/combinations/directive-link-backticks.json create mode 100644 corpus/round-trip/combinations/directive-link-backticks.md diff --git a/corpus/round-trip/combinations/directive-link-backticks.json b/corpus/round-trip/combinations/directive-link-backticks.json new file mode 100644 index 0000000..00c8b53 --- /dev/null +++ b/corpus/round-trip/combinations/directive-link-backticks.json @@ -0,0 +1,83 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "attrs": { + "color": "" + }, + "type": "textColor" + }, + { + "attrs": { + "href": "`" + }, + "type": "link" + } + ], + "text": " ", + "type": "text" + }, + { + "text": "`a", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "c", + "title": "`" + }, + "type": "link" + } + ], + "text": "b", + "type": "text" + }, + { + "marks": [ + { + "type": "code" + } + ], + "text": "c", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "e`f" + }, + "type": "link" + } + ], + "text": "d", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/directive-link-backticks.md b/corpus/round-trip/combinations/directive-link-backticks.md new file mode 100644 index 0000000..85d19f6 --- /dev/null +++ b/corpus/round-trip/combinations/directive-link-backticks.md @@ -0,0 +1,5 @@ +:textColor[[ ](\`)]{color=""}`a + +:underline[[b](c "\`")]`c` + +:underline[[d](e`f)] diff --git a/spec/flavour.md b/spec/flavour.md index 9f8a961..d503374 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -132,9 +132,9 @@ each a named error naming the spelling to write instead. **Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as directive syntax — the leading `:` of a would-be directive, `]` inside content, a bracket a link's destination and title inside content leave unbalanced (an autolink holding one takes the -`[text](url)` form), a `{` right after a directive's closing `]`, which would otherwise be read as -the attributes it has none of; outside code spans and code blocks, a backslash before `:` in input -yields a literal colon. +`[text](url)` form) and a backtick there that would open a code span, a `{` right after a +directive's closing `]`, which would otherwise be read as the attributes it has none of; outside +code spans and code blocks, a backslash before `:` in input yields a literal colon. **Malformed directives are error results**, named: an unclosed container at end of input, a body fence line of the container's length or longer, a bare colon-run line outside any container or diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index e889383..deed5ff 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -1,6 +1,6 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { InlineDirective } from '../../adf/inline-directives.ts' -import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts' +import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts' import { carriedInline } from '../opaque-carry.ts' import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts' import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../link-syntax.ts' @@ -41,7 +41,7 @@ export function emitInlineLine(nodes: readonly AdfNode[], container: LineContain export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath): string | undefined { const emitted = emitLine(nodes, 'table-cell', path) if (!emitted.ok) return undefined - if (emitted.value.segments.some((segment) => segment.escaping === 'none' && segment.text.includes('|'))) return undefined + if (emitted.value.segments.some((segment) => isSyntax(segment.escaping) && segment.text.includes('|'))) return undefined return emitted.value.line } @@ -291,7 +291,7 @@ function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true }) if (!inner.ok) return inner if (inner.value.carry !== undefined) return inner - const spelledTarget = context.bracketed ? escapeUnbalanced(target.value, '[', ']') : target.value - return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${spelledTarget})`)] }) + const spelledTarget: InlineSegment = context.bracketed ? { escaping: 'bracketed-link-target', text: escapeUnbalanced(target.value, '[', ']') } : syntax(target.value) + return success({ segments: [syntax('['), ...inner.value.segments, syntax(']('), spelledTarget, syntax(')')] }) } diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 4861771..f73b331 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -7,7 +7,7 @@ import { readEntityReference } from '../entity-references.ts' export type EmphasisRole = 'close' | 'open' -export type InlineEscaping = 'backslash' | 'bracketed' | 'none' +export type InlineEscaping = 'backslash' | 'bracketed' | 'bracketed-link-target' | 'none' export type NodeRange = { first: number; last: number } @@ -77,10 +77,11 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A const escaping = escapings[index] const escapable = escaping === 'backslash' || escaping === 'bracketed' if ( - escapable && - (claimsLineStart(line, index, container) || - mergesWithSyntax(scan, escapings, index) || - opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped)) + (escapable && + (claimsLineStart(line, index, container) || + mergesWithSyntax(scan, escapings, index) || + opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))) || + (escaping === 'bracketed-link-target' && scan.charAt(index) === '`' && opensCodeSpan(scan, index, escaped)) ) { output += '\\' escaped.add(index) @@ -181,8 +182,8 @@ function touchesSyntax(scan: string, escapings: readonly (InlineEscaping | undef return scan.charAt(cursor) === character && isSyntax(escapings[cursor]) } -function isSyntax(escaping: InlineEscaping | undefined): boolean { - return escaping === 'none' +export function isSyntax(escaping: InlineEscaping | undefined): boolean { + return escaping === 'none' || escaping === 'bracketed-link-target' } function opensConstruct( -- 2.52.0 From e166e969cb1335bbe1bf1471dd2c8200cc350ca5 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 21:26:36 +0200 Subject: [PATCH 05/10] 4.2: the ADF property --- AGENTS.md | 7 +- ci.sh | 2 +- package-lock.json | 41 +++++++++++ package.json | 1 + src/adf-property.test.ts | 154 +++++++++++++++++++++++++++++++++++++++ src/corpus.test.ts | 12 --- 6 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 src/adf-property.test.ts diff --git a/AGENTS.md b/AGENTS.md index f3017b0..aaaa56a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,8 @@ deliberate re-pin, exceptions re-derived by hand beside it. Atlassian's ADF JSON vendored the same way, at `spec/adf-schema/`, rather than as the `@atlaskit/adf-schema` dev dependency — CommonJS-only, some fifty packages with React among them, and a release most days for Renovate to automerge — re-pinned by hand when a payload or a report shows the need. -`devDependencies`: few, each earning its keep; they never reach a consumer. +`devDependencies`: few, each earning its keep; they never reach a consumer. `fast-check` earns its +place shrinking a failing generated document to the nodes that break it. ## 6. The package contract @@ -225,6 +226,10 @@ 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; the CommonMark spec suite against `markdownToAdf` and `markdownToHtml`. +Beside the corpus, properties run over documents generated from the node tables, on a fixed seed in +the gate; `PROPERTY_RUNS=` raises the runs and randomizes the seed for local digging, and a +counterexample found becomes a round-trip fixture. + `spec/flavour.md` is read as a source too, so the node tables cannot drift from the prose they copy: each `- ` bullet in `## Block nodes`, `## Inline nodes` and `## Marks` declares the nodes named before its first em dash, with the attributes following `Attributes: ` — a parenthesized diff --git a/ci.sh b/ci.sh index 5ab9650..dd3d712 100755 --- a/ci.sh +++ b/ci.sh @@ -16,7 +16,7 @@ if printf '%s' "$test_output" | grep -q 'ℹ tests 0'; then exit 1 fi -in_image "$deno_image" deno test --allow-read --no-check src/ +in_image "$deno_image" deno test --allow-env=PROPERTY_RUNS --allow-read --no-check src/ in_image "$bun_image" bun test src/ in_image "$node_image" npm run build diff --git a/package-lock.json b/package-lock.json index 54054ed..d7eeb19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "devDependencies": { "@types/node": "24.13.3", + "fast-check": "4.10.0", "typescript": "7.0.2" }, "engines": { @@ -366,6 +367,46 @@ "node": ">=16.20.0" } }, + "node_modules/fast-check": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz", + "integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", diff --git a/package.json b/package.json index 7a6670c..a188be2 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@types/node": "24.13.3", + "fast-check": "4.10.0", "typescript": "7.0.2" } } diff --git a/src/adf-property.test.ts b/src/adf-property.test.ts new file mode 100644 index 0000000..bdc8eda --- /dev/null +++ b/src/adf-property.test.ts @@ -0,0 +1,154 @@ +import fc from 'fast-check' +import assert from 'node:assert/strict' +import { env } from 'node:process' +import test from 'node:test' + +import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts' +import type { Arbitrary } from 'fast-check' +import type { AttributeKind, AttributeVocabulary } from './adf/attribute-vocabulary.ts' +import type { JsonValue } from './json-value.ts' +import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' +import { blockArgument } from './markdown/block-directive-arguments.ts' +import { blockDirectives } from './adf/block-directives.ts' +import { inlineDirectives } from './adf/inline-directives.ts' +import { isJsonValue } from './json-value.ts' +import { markAttributes } from './adf/mark-attributes.ts' +import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' +import { toEditorNormal } from './adf/editor-normal.ts' + +type Positions = { block: AdfNode; inline: AdfNode } + +const deepRunsVariable = 'PROPERTY_RUNS' +const gateRuns = 3000 +const gateSeed = 20260914 + +const depthIdentifier = fc.createDepthIdentifier() +const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' } +const markdownCharacters = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é 🎉') +const spelledTypes = new Set(['text', ...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes)]) + +function textOf(minLength: number): Arbitrary { + return fc.oneof( + { arbitrary: fc.string({ maxLength: 12, minLength, unit: markdownCharacters }), weight: 4 }, + { arbitrary: fc.string({ maxLength: 6, minLength, unit: 'grapheme' }), weight: 1 }, + ) +} + +const jsonValue = fc.jsonValue({ maxDepth: 2 }).filter(isJsonValue) +const text = textOf(1) +const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type)) + +const valueByKind: Readonly>> = { + boolean: fc.boolean(), + json: jsonValue, + number: fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 }), + string: textOf(0), +} + +function attributes(vocabulary: AttributeVocabulary): Arbitrary { + const model = Object.fromEntries( + Object.entries(vocabulary).map(([key, kind]) => [key, fc.oneof({ arbitrary: fc.constant(undefined), weight: 2 }, { arbitrary: valueByKind[kind], weight: 1 })]), + ) + return fc.record(model).map(heldAttributes) +} + +function heldAttributes(held: Readonly>): AdfAttributes { + const attrs: AdfAttributes = {} + for (const [key, value] of Object.entries(held)) if (value !== undefined) attrs[key] = value + return attrs +} + +function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): AdfNode { + const rows = [header, ...body.map((cells) => header.map((_, index) => cells[index] ?? emptyCell))] + return { content: rows.map((content): AdfNode => ({ content, type: 'tableRow' })), type: 'table' } +} + +const mark: Arbitrary = fc.oneof( + { arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 }, + { arbitrary: fc.record({ attrs: fc.dictionary(text, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 }, +) +const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type }) + +const textNode = fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' })) + +const inlineNodes = Object.entries(inlineDirectives).map(([type, directive]) => + fc.record({ attrs: attributes(directive.attributes), marks }).map((held): AdfNode => ({ ...held, type })), +) + +const positions = fc.letrec((tie) => { + const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 }) + const inlineContent = fc.array(tie('inline'), { depthIdentifier, maxLength: 4 }) + const contentByModel = { + block: blockContent, + code: fc.array(text.map((held): AdfNode => ({ text: held, type: 'text' })), { maxLength: 2 }), + inline: inlineContent, + none: fc.constant([]), + } + const blockMarks = fc.oneof({ arbitrary: fc.constant([]), weight: 4 }, { arbitrary: marks, weight: 1 }) + const blockNodes = Object.entries(blockDirectives).map(([type, directive]) => { + const argument = blockArgument(type) + const vocabulary: AttributeVocabulary = argument === undefined ? directive.attributes : { ...directive.attributes, [argument]: 'string' } + const node = fc.record({ attrs: attributes(vocabulary), content: contentByModel[directive.contentModel], marks: blockMarks }).map((held): AdfNode => ({ ...held, type })) + return { leaf: directive.contentModel === 'code' || directive.contentModel === 'none', node } + }) + const unknownNode = fc + .record({ attrs: fc.dictionary(text, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType }) + .map((held): AdfNode => held) + const leafBlocks = blockNodes.filter((entry) => entry.leaf).map((entry) => entry.node) + const containerBlocks = blockNodes.filter((entry) => !entry.leaf).map((entry) => entry.node) + const misplacedWeight = 7 + const paragraph = inlineContent.map((content): AdfNode => ({ content, type: 'paragraph' })) + const cell = (type: string) => paragraph.map((held): AdfNode => ({ content: [held], type })) + const listItems = fc.array( + blockContent.map((content): AdfNode => ({ content, type: 'listItem' })), + { depthIdentifier, maxLength: 3, minLength: 1 }, + ) + const commonMarkShapes = [ + blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })), + listItems.map((content): AdfNode => ({ content, type: 'bulletList' })), + fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })), + fc + .record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) }) + .map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })), + paragraph, + fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable), + ] + return { + block: fc.oneof( + { depthIdentifier, depthSize: 'small', maxDepth: 4 }, + { arbitrary: fc.oneof(...leafBlocks), weight: leafBlocks.length * 2 }, + { arbitrary: fc.oneof(...containerBlocks), weight: containerBlocks.length * 2 }, + { arbitrary: fc.oneof(textNode, ...inlineNodes, unknownNode), weight: misplacedWeight }, + { arbitrary: fc.oneof(...commonMarkShapes), weight: blockNodes.length * 2 + misplacedWeight }, + ), + inline: fc.oneof( + { depthIdentifier, depthSize: 'small', maxDepth: 4 }, + { arbitrary: textNode, weight: 12 }, + { arbitrary: fc.oneof(...inlineNodes), weight: 7 }, + { arbitrary: fc.oneof(...blockNodes.map((entry) => entry.node), unknownNode), weight: 2 }, + ), + } +}) + +const adfDocument = fc.array(positions.block, { depthIdentifier, maxLength: 4, minLength: 1 }).map((content): AdfDocument => toEditorNormal({ content, type: 'doc', version: 1 })) + +function runParameters(): { numRuns: number; seed?: number } { + const deepRuns = env[deepRunsVariable] + if (deepRuns === undefined) return { numRuns: gateRuns, seed: gateSeed } + const numRuns = Number(deepRuns) + assert.ok(Number.isSafeInteger(numRuns) && numRuns > 0, `${deepRunsVariable} holds a whole number of runs: found ${deepRuns}`) + return { numRuns } +} + +test('a generated document refuses to emit, or its markdown reads back to it', () => { + fc.assert( + fc.property(adfDocument, (document) => { + const emitted = adfToMarkdown(document) + if (!emitted.ok) return + const read = markdownToAdf(emitted.value) + assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(emitted.value)}`) + assert.deepEqual(toEditorNormal(read.value), document, `reading ${JSON.stringify(emitted.value)}`) + }), + runParameters(), + ) +}) diff --git a/src/corpus.test.ts b/src/corpus.test.ts index 31a7f1d..33d752d 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -107,18 +107,6 @@ function roundTripFixtures(): { name: string; path: string }[] { ) } -test('no two round-trip documents share one markdown spelling', () => { - const spellings = new Map() - for (const fixture of roundTripFixtures()) { - const parsed: unknown = JSON.parse(readFileSync(fixture.path, 'utf8')) - assert.ok(isAdfDocument(parsed), `${fixture.name} is not an ADF document`) - const result = adfToMarkdown(parsed) - assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) - assert.equal(spellings.get(result.value), undefined, `${fixture.name} and ${spellings.get(result.value)} share one markdown spelling`) - spellings.set(result.value, fixture.name) - } -}) - test('no round-trip fixture repeats the document another holds', () => { const documents = new Map() for (const fixture of roundTripFixtures()) { -- 2.52.0 From b6930b45a4132535c0a1db56082256a8524d2bfd Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 22:39:20 +0200 Subject: [PATCH 06/10] 4.2: an autolink holding a backtick gives way inside a directive's content --- .../directive-autolink-backtick.json | 50 +++++++++++++++++++ .../directive-autolink-backtick.md | 3 ++ spec/flavour.md | 11 ++-- src/markdown/emit/inline-line.ts | 4 +- 4 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 corpus/round-trip/combinations/directive-autolink-backtick.json create mode 100644 corpus/round-trip/combinations/directive-autolink-backtick.md diff --git a/corpus/round-trip/combinations/directive-autolink-backtick.json b/corpus/round-trip/combinations/directive-autolink-backtick.json new file mode 100644 index 0000000..2ddbd7f --- /dev/null +++ b/corpus/round-trip/combinations/directive-autolink-backtick.json @@ -0,0 +1,50 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "http://a`b" + }, + "type": "link" + } + ], + "text": "http://a`b", + "type": "text" + }, + { + "text": "`c", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "http://d`e" + }, + "type": "link" + } + ], + "text": "http://d`e", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/directive-autolink-backtick.md b/corpus/round-trip/combinations/directive-autolink-backtick.md new file mode 100644 index 0000000..163f313 --- /dev/null +++ b/corpus/round-trip/combinations/directive-autolink-backtick.md @@ -0,0 +1,3 @@ +:underline[[http://a\`b](http://a\`b)]`c + +:underline[[http://d\`e](http://d`e)] diff --git a/spec/flavour.md b/spec/flavour.md index d503374..d3506f5 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -48,7 +48,8 @@ normalizes to it through the round-trip. title; title in double quotes. A backslash escapes a parenthesis the destination leaves unbalanced, and a quote inside the title; a balanced pair stays bare. `` autolink form only when the text equals the destination and the destination is a valid CommonMark autolink - (absolute URI). + (absolute URI) — inside an inline directive's `[content]`, one holding no backtick and no + unbalanced bracket. - Paragraphs on one line — no soft wrapping; a soft line break in input becomes a single space. - Entity references in input decode to their characters; output backslash-escapes only where text would otherwise parse as syntax, scanning the assembled line rather than each text node: escape @@ -131,10 +132,10 @@ each a named error naming the spelling to write instead. **Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as directive syntax — the leading `:` of a would-be directive, `]` inside content, a bracket a link's -destination and title inside content leave unbalanced (an autolink holding one takes the -`[text](url)` form) and a backtick there that would open a code span, a `{` right after a -directive's closing `]`, which would otherwise be read as the attributes it has none of; outside -code spans and code blocks, a backslash before `:` in input yields a literal colon. +destination and title inside content leave unbalanced and a backtick there that would open a code +span, a `{` right after a directive's closing `]`, which would otherwise be read as the attributes +it has none of; outside code spans and code blocks, a backslash before `:` in input yields a +literal colon. **Malformed directives are error results**, named: an unclosed container at end of input, a body fence line of the container's length or longer, a bare colon-run line outside any container or diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index deed5ff..ec3bf1f 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -284,8 +284,8 @@ function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range if (typeof href !== 'string') return success({ carry: range }) const node = nodes[0] const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1 - const balanced = !context.bracketed || escapeUnbalanced(href, '[', ']') === href - if (bare && balanced && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] }) + const autolinkHolds = !context.bracketed || (!href.includes('`') && escapeUnbalanced(href, '[', ']') === href) + if (bare && autolinkHolds && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] }) const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined, path) if (!target.ok) return target const inner = emitRun(nodes, depth + 1, range.first, { ...context, bracketed: true }) -- 2.52.0 From fade9f51ea9ebf15b8bca14d8953a801713d1afe Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 22:39:29 +0200 Subject: [PATCH 07/10] 4.2: generated keys avoid V8's JSON.parse key fault --- src/adf-property.test.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/adf-property.test.ts b/src/adf-property.test.ts index bdc8eda..17f39cb 100644 --- a/src/adf-property.test.ts +++ b/src/adf-property.test.ts @@ -11,7 +11,6 @@ import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' import { blockArgument } from './markdown/block-directive-arguments.ts' import { blockDirectives } from './adf/block-directives.ts' import { inlineDirectives } from './adf/inline-directives.ts' -import { isJsonValue } from './json-value.ts' import { markAttributes } from './adf/mark-attributes.ts' import { markdownToAdf } from './markdown/parse/markdown-to-adf.ts' import { toEditorNormal } from './adf/editor-normal.ts' @@ -24,7 +23,7 @@ const gateSeed = 20260914 const depthIdentifier = fc.createDepthIdentifier() const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' } -const markdownCharacters = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é 🎉') +const markdownCharacters = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉') const spelledTypes = new Set(['text', ...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes)]) function textOf(minLength: number): Arbitrary { @@ -34,14 +33,29 @@ function textOf(minLength: number): Arbitrary { ) } -const jsonValue = fc.jsonValue({ maxDepth: 2 }).filter(isJsonValue) const text = textOf(1) const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type)) +const numberValue = fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 }) + +// V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash; Bun is unaffected. +const keyCharacter = fc + .oneof({ arbitrary: markdownCharacters, weight: 4 }, { arbitrary: fc.string({ maxLength: 1, minLength: 1, unit: 'grapheme' }), weight: 1 }) + .filter((character) => !/[\\"\x00-\x1f]/.test(character)) +const jsonKey = fc.string({ maxLength: 8, unit: keyCharacter }) + +const { jsonValue } = fc.letrec<{ jsonValue: JsonValue }>((tie) => ({ + jsonValue: fc.oneof( + { depthSize: 'small', maxDepth: 2 }, + fc.oneof(fc.constant(null), fc.boolean(), numberValue, textOf(0)), + fc.array(tie('jsonValue'), { maxLength: 3 }), + fc.dictionary(jsonKey, tie('jsonValue'), { maxKeys: 3, noNullPrototype: true }), + ), +})) const valueByKind: Readonly>> = { boolean: fc.boolean(), json: jsonValue, - number: fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 }), + number: numberValue, string: textOf(0), } @@ -65,7 +79,7 @@ function pipeTable({ body, header }: { body: AdfNode[][]; header: AdfNode[] }): const mark: Arbitrary = fc.oneof( { arbitrary: fc.oneof(...Object.entries(markAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type })))), weight: 9 }, - { arbitrary: fc.record({ attrs: fc.dictionary(text, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 }, + { arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 }, ) const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type }) @@ -92,7 +106,7 @@ const positions = fc.letrec((tie) => { return { leaf: directive.contentModel === 'code' || directive.contentModel === 'none', node } }) const unknownNode = fc - .record({ attrs: fc.dictionary(text, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType }) + .record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), content: fc.array(tie('inline'), { depthIdentifier, maxLength: 2 }), marks, type: unknownType }) .map((held): AdfNode => held) const leafBlocks = blockNodes.filter((entry) => entry.leaf).map((entry) => entry.node) const containerBlocks = blockNodes.filter((entry) => !entry.leaf).map((entry) => entry.node) -- 2.52.0 From 342c2027d8e586eccc9f2f27cef57365c381578d Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 23:24:21 +0200 Subject: [PATCH 08/10] 4.2: a link target's would-be directive escapes inside a directive's content --- .../combinations/directive-link-colon.json | 107 ++++++++++++++++++ .../combinations/directive-link-colon.md | 9 ++ spec/flavour.md | 12 +- src/markdown/directive-syntax.ts | 5 + src/markdown/emit/inline-line.ts | 4 +- src/markdown/emit/line-escaping.ts | 3 +- 6 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 corpus/round-trip/combinations/directive-link-colon.json create mode 100644 corpus/round-trip/combinations/directive-link-colon.md diff --git a/corpus/round-trip/combinations/directive-link-colon.json b/corpus/round-trip/combinations/directive-link-colon.json new file mode 100644 index 0000000..2cc9c86 --- /dev/null +++ b/corpus/round-trip/combinations/directive-link-colon.json @@ -0,0 +1,107 @@ +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": ":a{" + }, + "type": "link" + } + ], + "text": "x", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "y", + "title": ":a{" + }, + "type": "link" + } + ], + "text": "x", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": ":a[]{" + }, + "type": "link" + } + ], + "text": "x", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "https://example.com/?q=:emoji{" + }, + "type": "link" + } + ], + "text": "x", + "type": "text" + } + ], + "type": "paragraph" + }, + { + "content": [ + { + "marks": [ + { + "type": "underline" + }, + { + "attrs": { + "href": "ab:c{" + }, + "type": "link" + } + ], + "text": "ab:c{", + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 +} diff --git a/corpus/round-trip/combinations/directive-link-colon.md b/corpus/round-trip/combinations/directive-link-colon.md new file mode 100644 index 0000000..ecf5427 --- /dev/null +++ b/corpus/round-trip/combinations/directive-link-colon.md @@ -0,0 +1,9 @@ +:underline[[x](\:a{)] + +:underline[[x](y "\:a{")] + +:underline[[x](\:a[]{)] + +:underline[[x](https://example.com/?q=\:emoji{)] + +:underline[[ab\:c{](ab\:c{)] diff --git a/spec/flavour.md b/spec/flavour.md index d3506f5..c0f802b 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -48,8 +48,8 @@ normalizes to it through the round-trip. title; title in double quotes. A backslash escapes a parenthesis the destination leaves unbalanced, and a quote inside the title; a balanced pair stays bare. `` autolink form only when the text equals the destination and the destination is a valid CommonMark autolink - (absolute URI) — inside an inline directive's `[content]`, one holding no backtick and no - unbalanced bracket. + (absolute URI) — inside an inline directive's `[content]`, one holding no backtick, no + unbalanced bracket and no inline directive opener. - Paragraphs on one line — no soft wrapping; a soft line break in input becomes a single space. - Entity references in input decode to their characters; output backslash-escapes only where text would otherwise parse as syntax, scanning the assembled line rather than each text node: escape @@ -132,10 +132,10 @@ each a named error naming the spelling to write instead. **Escaping**: the emitter backslash-escapes whatever literal text would otherwise parse as directive syntax — the leading `:` of a would-be directive, `]` inside content, a bracket a link's -destination and title inside content leave unbalanced and a backtick there that would open a code -span, a `{` right after a directive's closing `]`, which would otherwise be read as the attributes -it has none of; outside code spans and code blocks, a backslash before `:` in input yields a -literal colon. +destination and title inside content leave unbalanced, a backtick there that would open a code span +and a `:` there that would open an inline directive, a `{` right after a directive's closing `]`, +which would otherwise be read as the attributes it has none of; outside code spans and code blocks, +a backslash before `:` in input yields a literal colon. **Malformed directives are error results**, named: an unclosed container at end of input, a body fence line of the container's length or longer, a bare colon-run line outside any container or diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 87c4976..3c0ec9e 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -56,6 +56,11 @@ export function attributeValue(text: string, kind: AttributeKind): AttributeRead return overNested(parsed) ? { refusal: 'nesting' } : { value: { kind, value: parsed } } } +export function holdsInlineDirectiveOpener(text: string): boolean { + for (let index = text.indexOf(':'); index !== -1; index = text.indexOf(':', index + 1)) if (opensInlineDirective(text, index)) return true + return false +} + export function isBareToken(text: string): boolean { return bareToken.test(text) } diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index ec3bf1f..6c0105b 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -6,13 +6,13 @@ import { claimsLine, holdsNullCharacter, isAutolink } from '../commonmark-gramma import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../link-syntax.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { holdsEntityReference } from '../entity-references.ts' +import { holdsInlineDirectiveOpener, slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts' import { inlineDirective } from '../../adf/inline-directives.ts' import { largestNesting } from '../../nesting.ts' import { longestBacktickRun } from '../backtick-runs.ts' import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { sameMark } from '../../adf/editor-normal.ts' -import { slotLineEndingFault, spellLeafDirective } from '../directive-syntax.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' import { spellTextDirective } from '../text-directive.ts' @@ -284,7 +284,7 @@ function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range if (typeof href !== 'string') return success({ carry: range }) const node = nodes[0] const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && nodeMarks(node).length === depth + 1 - const autolinkHolds = !context.bracketed || (!href.includes('`') && escapeUnbalanced(href, '[', ']') === href) + const autolinkHolds = !context.bracketed || (!href.includes('`') && !holdsInlineDirectiveOpener(href) && escapeUnbalanced(href, '[', ']') === href) if (bare && autolinkHolds && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success({ segments: [syntax(`<${href}>`)] }) const target = spellLinkTarget(href, typeof title === 'string' ? title : undefined, path) if (!target.ok) return target diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index f73b331..eeaa7ec 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -81,7 +81,8 @@ function escape(segments: readonly InlineSegment[], container: LineContainer): A (claimsLineStart(line, index, container) || mergesWithSyntax(scan, escapings, index) || opensConstruct(scan, linkClose, index, escaping === 'bracketed', container, escaped))) || - (escaping === 'bracketed-link-target' && scan.charAt(index) === '`' && opensCodeSpan(scan, index, escaped)) + (escaping === 'bracketed-link-target' && + ((scan.charAt(index) === '`' && opensCodeSpan(scan, index, escaped)) || (scan.charAt(index) === ':' && opensInlineDirective(scan, index)))) ) { output += '\\' escaped.add(index) -- 2.52.0 From a581e454ae1007b6d8dd3fd18f799f8313889834 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 23:24:32 +0200 Subject: [PATCH 09/10] 4.2: the review's generator, runner and pattern fixes --- docker-runner.sh | 2 +- src/adf-property.test.ts | 53 ++++++++++++++++++++---------- src/markdown/commonmark-grammar.ts | 3 +- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/docker-runner.sh b/docker-runner.sh index 504109f..cf66632 100644 --- a/docker-runner.sh +++ b/docker-runner.sh @@ -7,7 +7,7 @@ node_image=node:24.19.0-alpine3.24 in_image() { local image=$1 entrypoint=$2 shift 2 - docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@" + docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${PROPERTY_RUNS:+-e PROPERTY_RUNS} ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@" } with_firefox() { diff --git a/src/adf-property.test.ts b/src/adf-property.test.ts index 17f39cb..aeed542 100644 --- a/src/adf-property.test.ts +++ b/src/adf-property.test.ts @@ -18,17 +18,21 @@ import { toEditorNormal } from './adf/editor-normal.ts' type Positions = { block: AdfNode; inline: AdfNode } const deepRunsVariable = 'PROPERTY_RUNS' -const gateRuns = 3000 +const gateRuns = 1600 const gateSeed = 20260914 +// Bun's test runner stops a test after five seconds unless the test sets its own timeout. +const propertyTimeout = 600000 const depthIdentifier = fc.createDepthIdentifier() const emptyCell: AdfNode = { content: [{ type: 'paragraph' }], type: 'tableCell' } -const markdownCharacters = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉') +const flatCommonMarkShapeWeight = 4 +const markdownPieces = fc.constantFrom(...'aZ09 \t\n!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~é\xa0🎉', ':a[', ':a{', 'ab:', 'http://') +const nestingCommonMarkShapeWeight = 21 const spelledTypes = new Set(['text', ...Object.keys(blockDirectives), ...Object.keys(inlineDirectives), ...Object.keys(markAttributes)]) function textOf(minLength: number): Arbitrary { return fc.oneof( - { arbitrary: fc.string({ maxLength: 12, minLength, unit: markdownCharacters }), weight: 4 }, + { arbitrary: fc.string({ maxLength: 12, minLength, unit: markdownPieces }), weight: 4 }, { arbitrary: fc.string({ maxLength: 6, minLength, unit: 'grapheme' }), weight: 1 }, ) } @@ -37,11 +41,11 @@ const text = textOf(1) const unknownType = fc.oneof(fc.stringMatching(/^[a-z][A-Za-z0-9]{0,7}$/), text).filter((type) => !spelledTypes.has(type)) const numberValue = fc.oneof({ arbitrary: fc.integer({ max: 10, min: -1 }), weight: 3 }, { arbitrary: fc.double({ noDefaultInfinity: true, noNaN: true }), weight: 1 }) -// V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash; Bun is unaffected. -const keyCharacter = fc - .oneof({ arbitrary: markdownCharacters, weight: 4 }, { arbitrary: fc.string({ maxLength: 1, minLength: 1, unit: 'grapheme' }), weight: 1 }) - .filter((character) => !/[\\"\x00-\x1f]/.test(character)) -const jsonKey = fc.string({ maxLength: 8, unit: keyCharacter }) +// V8's JSON.parse returns a wrong key after parsing a key holding an escaped backslash (https://issues.chromium.org/issues/521080746); Bun is unaffected. +const keyPiece = fc + .oneof({ arbitrary: markdownPieces, weight: 4 }, { arbitrary: fc.string({ maxLength: 1, minLength: 1, unit: 'grapheme' }), weight: 1 }) + .filter((piece) => !/[\\"\x00-\x1f]/.test(piece)) +const jsonKey = fc.string({ maxLength: 8, unit: keyPiece }) const { jsonValue } = fc.letrec<{ jsonValue: JsonValue }>((tie) => ({ jsonValue: fc.oneof( @@ -85,10 +89,18 @@ const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type const textNode = fc.record({ marks, text }).map((held): AdfNode => ({ ...held, type: 'text' })) +const autolinkTextNode = fc + .record({ href: fc.tuple(fc.constantFrom('ab:', 'http://'), textOf(0)).map(([scheme, rest]) => `${scheme}${rest}`), marks }) + .map(({ href, marks: held }): AdfNode => ({ marks: [...held.filter((outer) => outer.type !== 'link'), { attrs: { href }, type: 'link' }], text: href, type: 'text' })) + const inlineNodes = Object.entries(inlineDirectives).map(([type, directive]) => fc.record({ attrs: attributes(directive.attributes), marks }).map((held): AdfNode => ({ ...held, type })), ) +function weighted(arbitraries: readonly Arbitrary[], weight: number): { arbitrary: Arbitrary; weight: number }[] { + return arbitraries.map((arbitrary) => ({ arbitrary, weight })) +} + const positions = fc.letrec((tie) => { const blockContent = fc.array(tie('block'), { depthIdentifier, maxLength: 3 }) const inlineContent = fc.array(tie('inline'), { depthIdentifier, maxLength: 4 }) @@ -117,27 +129,33 @@ const positions = fc.letrec((tie) => { blockContent.map((content): AdfNode => ({ content, type: 'listItem' })), { depthIdentifier, maxLength: 3, minLength: 1 }, ) - const commonMarkShapes = [ + const flatCommonMarkShapes = [ + fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })), + paragraph, + fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable), + ] + const nestingCommonMarkShapes = [ blockContent.map((content): AdfNode => ({ content, type: 'blockquote' })), listItems.map((content): AdfNode => ({ content, type: 'bulletList' })), - fc.record({ content: inlineContent, level: fc.integer({ max: 6, min: 1 }) }).map(({ content, level }): AdfNode => ({ attrs: { level }, content, type: 'heading' })), fc .record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) }) .map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })), - paragraph, - fc.record({ body: fc.array(fc.array(cell('tableCell'), { maxLength: 3 }), { maxLength: 2 }), header: fc.array(cell('tableHeader'), { maxLength: 3, minLength: 1 }) }).map(pipeTable), ] return { block: fc.oneof( { depthIdentifier, depthSize: 'small', maxDepth: 4 }, - { arbitrary: fc.oneof(...leafBlocks), weight: leafBlocks.length * 2 }, + { + arbitrary: fc.oneof(...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)), + weight: leafBlocks.length * 2 + flatCommonMarkShapes.length * flatCommonMarkShapeWeight, + }, { arbitrary: fc.oneof(...containerBlocks), weight: containerBlocks.length * 2 }, { arbitrary: fc.oneof(textNode, ...inlineNodes, unknownNode), weight: misplacedWeight }, - { arbitrary: fc.oneof(...commonMarkShapes), weight: blockNodes.length * 2 + misplacedWeight }, + { arbitrary: fc.oneof(...nestingCommonMarkShapes), weight: nestingCommonMarkShapes.length * nestingCommonMarkShapeWeight }, ), inline: fc.oneof( { depthIdentifier, depthSize: 'small', maxDepth: 4 }, { arbitrary: textNode, weight: 12 }, + { arbitrary: autolinkTextNode, weight: 2 }, { arbitrary: fc.oneof(...inlineNodes), weight: 7 }, { arbitrary: fc.oneof(...blockNodes.map((entry) => entry.node), unknownNode), weight: 2 }, ), @@ -149,12 +167,11 @@ const adfDocument = fc.array(positions.block, { depthIdentifier, maxLength: 4, m function runParameters(): { numRuns: number; seed?: number } { const deepRuns = env[deepRunsVariable] if (deepRuns === undefined) return { numRuns: gateRuns, seed: gateSeed } - const numRuns = Number(deepRuns) - assert.ok(Number.isSafeInteger(numRuns) && numRuns > 0, `${deepRunsVariable} holds a whole number of runs: found ${deepRuns}`) - return { numRuns } + assert.ok(/^[1-9]\d*$/.test(deepRuns), `${deepRunsVariable} is a run count in digits, such as ${deepRunsVariable}=10000: found ${JSON.stringify(deepRuns)}`) + return { numRuns: Number(deepRuns) } } -test('a generated document refuses to emit, or its markdown reads back to it', () => { +test('a generated document refuses to emit, or its markdown reads back to it', { timeout: propertyTimeout }, () => { fc.assert( fc.property(adfDocument, (document) => { const emitted = adfToMarkdown(document) diff --git a/src/markdown/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts index 9ed129b..c36fbb0 100644 --- a/src/markdown/commonmark-grammar.ts +++ b/src/markdown/commonmark-grammar.ts @@ -51,6 +51,7 @@ const htmlBlockConditions: HtmlBlockCondition[] = [ ] const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/ const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/ +const blankLine = /^[ \t]*$/ const codeFenceOpener = /^(`{3,}|~{3,})/ const directiveClaim = /^:{2,}(?:[A-Za-z0-9]|[ \t]*$)/ const pipeClaim = /^\|/ @@ -168,7 +169,7 @@ export function isAutolink(text: string): boolean { } export function isBlankLine(line: string): boolean { - return /^[ \t]*$/.test(line) + return blankLine.test(line) } export function isThematicBreak(line: string): boolean { -- 2.52.0 From f8149321f2770bd3818d11eff5630698853e7af3 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Mon, 14 Sep 2026 23:43:49 +0200 Subject: [PATCH 10/10] 4.2: the review's runner and weight nits --- docker-runner.sh | 2 +- src/adf-property.test.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docker-runner.sh b/docker-runner.sh index cf66632..8602e71 100644 --- a/docker-runner.sh +++ b/docker-runner.sh @@ -7,7 +7,7 @@ node_image=node:24.19.0-alpine3.24 in_image() { local image=$1 entrypoint=$2 shift 2 - docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${PROPERTY_RUNS:+-e PROPERTY_RUNS} ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@" + docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp ${PROPERTY_RUNS+-e PROPERTY_RUNS} ${in_image_network:+--network "$in_image_network"} -v "$PWD:/app" -w /app --entrypoint "$entrypoint" "$image" "$@" } with_firefox() { diff --git a/src/adf-property.test.ts b/src/adf-property.test.ts index aeed542..4343cb1 100644 --- a/src/adf-property.test.ts +++ b/src/adf-property.test.ts @@ -141,13 +141,11 @@ const positions = fc.letrec((tie) => { .record({ content: listItems, order: fc.oneof({ arbitrary: fc.integer({ max: 3, min: 0 }), weight: 4 }, { arbitrary: fc.integer({ max: 999999999, min: 0 }), weight: 1 }) }) .map(({ content, order }): AdfNode => ({ attrs: { order }, content, type: 'orderedList' })), ] + const flatBlocks = [...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)] return { block: fc.oneof( { depthIdentifier, depthSize: 'small', maxDepth: 4 }, - { - arbitrary: fc.oneof(...weighted(leafBlocks, 2), ...weighted(flatCommonMarkShapes, flatCommonMarkShapeWeight)), - weight: leafBlocks.length * 2 + flatCommonMarkShapes.length * flatCommonMarkShapeWeight, - }, + { arbitrary: fc.oneof(...flatBlocks), weight: flatBlocks.reduce((sum, entry) => sum + entry.weight, 0) }, { arbitrary: fc.oneof(...containerBlocks), weight: containerBlocks.length * 2 }, { arbitrary: fc.oneof(textNode, ...inlineNodes, unknownNode), weight: misplacedWeight }, { arbitrary: fc.oneof(...nestingCommonMarkShapes), weight: nestingCommonMarkShapes.length * nestingCommonMarkShapeWeight }, -- 2.52.0