Corpus 2e2: the mark runs, the run a carry breaks, and one mark vocabulary #21

Merged
lilleman merged 2 commits from mark-runs into main 2026-08-26 23:05:18 +02:00
11 changed files with 252 additions and 31 deletions
@@ -0,0 +1,50 @@
{
"content": [
{
"content": [
{
"marks": [
{
"type": "em"
},
{
"type": "strong"
}
],
"text": "Ready",
"type": "text"
},
{
"attrs": {
"extensionKey": "status-lozenge",
"extensionType": "com.atlassian.confluence.macro.core"
},
"marks": [
{
"type": "em"
},
{
"type": "strong"
}
],
"type": "inlineExtension"
},
{
"marks": [
{
"type": "em"
},
{
"type": "strong"
}
],
"text": "to ship",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1 @@
_**Ready**_:adf{json="{\"attrs\":{\"extensionKey\":\"status-lozenge\",\"extensionType\":\"com.atlassian.confluence.macro.core\"},\"marks\":[{\"type\":\"em\"},{\"type\":\"strong\"}],\"type\":\"inlineExtension\"}"}_**to ship**_
@@ -0,0 +1,129 @@
{
"content": [
{
"content": [
{
"marks": [
{
"attrs": {
"color": "#ae2e24"
},
"type": "textColor"
}
],
"text": "Overdue since ",
"type": "text"
},
{
"attrs": {
"color": "red",
"text": "Blocked"
},
"marks": [
{
"attrs": {
"color": "#ae2e24"
},
"type": "textColor"
}
],
"type": "status"
},
{
"marks": [
{
"attrs": {
"color": "#ae2e24"
},
"type": "textColor"
}
],
"text": " on Tuesday",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"type": "em"
}
],
"text": "Torque the ",
"type": "text"
},
{
"marks": [
{
"type": "em"
},
{
"type": "strong"
}
],
"text": "M8 bolt",
"type": "text"
},
{
"marks": [
{
"type": "em"
}
],
"text": " to ",
"type": "text"
},
{
"marks": [
{
"type": "em"
},
{
"type": "strong"
}
],
"text": "25 Nm",
"type": "text"
}
],
"type": "paragraph"
},
{
"content": [
{
"marks": [
{
"attrs": {
"href": "https://example.com/release"
},
"type": "link"
}
],
"text": "Release notes ",
"type": "text"
},
{
"attrs": {
"shortName": ":rocket:",
"text": "🚀"
},
"marks": [
{
"attrs": {
"href": "https://example.com/release"
},
"type": "link"
}
],
"type": "emoji"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
@@ -0,0 +1,5 @@
:textColor[Overdue since :status[Blocked]{color=red} on Tuesday]{color="#ae2e24"}
_Torque the **M8 bolt** to **25 Nm**_
[Release notes :emoji[🚀]{shortName=":rocket:"}](https://example.com/release)
@@ -0,0 +1 @@
unspellable-mark
+28
View File
@@ -0,0 +1,28 @@
{
"content": [
{
"content": [
{
"text": "un",
"type": "text"
},
{
"marks": [
{
"type": "strong"
}
],
"text": "-real",
"type": "text"
},
{
"text": "istic",
"type": "text"
}
],
"type": "paragraph"
}
],
"type": "doc",
"version": 1
}
+4 -3
View File
@@ -386,9 +386,10 @@ inline nodes carrying an identical mark, attributes included, at that depth. A r
node the emitter carries, so no emitted carry sits inside a mark spelling.
An inline node whose marks no nesting spells — a mark type not listed here, an attribute no
spelling holds, an order putting a code span outside another mark, or `code` on text holding a
newline — rides the inline carry whole. An opaque carry inside a mark spelling is a named error
in input: the carry restores its node exactly, marks included (AGENTS.md §3).
spelling holds, an order putting a code span outside another mark, `code` on text holding a
newline, or a spelling CommonMark's flanking rules cannot open or close where the run sits
(`un**-real**istic`) — rides the inline carry whole. An opaque carry inside a mark spelling is a
named error in input: the carry restores its node exactly, marks included (AGENTS.md §3).
```
:textColor[**Overdue**]{color="#ae2e24"}, H:subsup[2]{type=sub}O, :underline[signed].
+3
View File
@@ -90,12 +90,15 @@ test('refuses a link attribute no markdown spelling holds', () => {
code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: 'https://example.com/', id: 'x' }, type: 'link' }], text: 't', type: 'text' })))),
'unspellable-mark',
)
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { href: 4 }, type: 'link' }], text: 't', type: 'text' })))), 'unspellable-mark')
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'link' }], text: 't', type: 'text' })))), 'unsupported-node-shape')
})
test('refuses a mark the canonical spellings cannot nest', () => {
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'annotation' }], text: 'x', type: 'text' })))), 'unspellable-mark')
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ type: 'code' }, { type: 'strong' }], text: 'x', type: 'text' })))), 'unspellable-mark')
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { colour: 'red' }, type: 'em' }], text: 'x', type: 'text' })))), 'unspellable-mark')
assert.equal(code(adfToMarkdown(document(paragraph({ marks: [{ attrs: { localId: 'x' }, type: 'code' }], text: 'x', type: 'text' })))), 'unspellable-mark')
})
test('refuses whitespace CommonMark cannot hold', () => {
+16 -7
View File
@@ -8,6 +8,10 @@ export type InlineDirective = {
slot?: string
}
export type MarkSpelling =
| { attributes: AttributeVocabulary; kind: 'code' | 'directive' | 'link'; spelling?: undefined }
| { attributes: AttributeVocabulary; kind: 'emphasis'; spelling: string }
const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
date: { attributes: { localId: 'string', timestamp: 'string' } },
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string' }, slot: 'text' },
@@ -29,19 +33,24 @@ const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
status: { attributes: { color: 'string', localId: 'string', style: 'string' }, slot: 'text' },
}
const markDirectives: Readonly<Record<string, AttributeVocabulary>> = {
border: { color: 'string', size: 'number' },
subsup: { type: 'string' },
textColor: { color: 'string' },
underline: {},
const markSpellings: Readonly<Record<string, MarkSpelling>> = {
border: { attributes: { color: 'string', size: 'number' }, kind: 'directive' },
code: { attributes: {}, kind: 'code' },
em: { attributes: {}, kind: 'emphasis', spelling: '_' },
link: { attributes: { href: 'string', title: 'string' }, kind: 'link' },
strike: { attributes: {}, kind: 'emphasis', spelling: '~~' },
strong: { attributes: {}, kind: 'emphasis', spelling: '**' },
subsup: { attributes: { type: 'string' }, kind: 'directive' },
textColor: { attributes: { color: 'string' }, kind: 'directive' },
underline: { attributes: {}, kind: 'directive' },
}
export function inlineDirective(type: string): InlineDirective | undefined {
return Object.hasOwn(inlineDirectives, type) ? inlineDirectives[type] : undefined
}
export function markDirective(type: string): AttributeVocabulary | undefined {
return Object.hasOwn(markDirectives, type) ? markDirectives[type] : undefined
export function markSpelling(type: string): MarkSpelling | undefined {
return Object.hasOwn(markSpellings, type) ? markSpellings[type] : undefined
}
export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective, path: ConvertErrorPath): Result<string> {
+9 -18
View File
@@ -1,7 +1,7 @@
import type { AdfMark, AdfNode } from './adf-document.ts'
import type { InlineDirective } from './inline-directives.ts'
import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer } from './markdown-escaping.ts'
import { inlineDirective, markDirective, spellInlineNodeAttributes, spellMarkAttributes } from './inline-directives.ts'
import { inlineDirective, markSpelling, spellInlineNodeAttributes, spellMarkAttributes } from './inline-directives.ts'
import { largestNesting } from './nesting.ts'
import { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isAutolink, isUnicodeWhitespace } from './commonmark-grammar.ts'
import { carriedInline } from './opaque-carry.ts'
@@ -19,9 +19,6 @@ type InlineContext = {
type InlineRun = { index: number; kind: 'marked'; mark: AdfMark; nodes: AdfNode[] } | { index: number; kind: 'plain'; node: AdfNode }
const emphasisSpellings: Readonly<Record<string, string>> = { em: '_', strike: '~~', strong: '**' }
const linkAttributes = ['href', 'title']
export function emitInlineLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath): Result<string> {
const segments = lineSegments(nodes, container, path)
if (!segments.ok) return segments
@@ -204,15 +201,14 @@ function emitText(node: AdfNode, context: InlineContext, path: ConvertErrorPath)
}
function emitMarkedRun(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<InlineSegment[]> {
if (mark.type === 'code') return emitCodeSpan(nodes, depth, nodePath(context, index))
if (mark.type === 'link') return emitLink(nodes, mark, depth, index, context)
const path = nodePath(context, index)
const spelling = Object.hasOwn(emphasisSpellings, mark.type) ? emphasisSpellings[mark.type] : undefined
if (spelling !== undefined) return emitEmphasis(nodes, mark, spelling, depth, index, context, path)
const vocabulary = markDirective(mark.type)
if (vocabulary === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`, path)
const attributes = spellMarkAttributes(mark, vocabulary, path)
const spelling = markSpelling(mark.type)
if (spelling === undefined) return failure('unspellable-mark', `no markdown spelling holds the ${mark.type} mark`, path)
const attributes = spellMarkAttributes(mark, spelling.attributes, path)
if (!attributes.ok) return attributes
if (spelling.kind === 'code') return emitCodeSpan(nodes, depth, path)
if (spelling.kind === 'emphasis') return emitEmphasis(nodes, mark, spelling.spelling, depth, index, context, path)
if (spelling.kind === 'link') return emitLink(nodes, mark, depth, index, context, path)
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true, spansLines: false })
if (!inner.ok) return inner
return success([syntax(`:${mark.type}[`), ...inner.value, syntax(`]${attributes.value}`)])
@@ -227,7 +223,6 @@ function emitEmphasis(
context: InlineContext,
path: ConvertErrorPath,
): Result<InlineSegment[]> {
if (Object.keys(mark.attrs ?? {}).length > 0) return failure('unspellable-mark', `the ${mark.type} spelling holds no attributes`, path)
const inner = emitRun(nodes, depth + 1, index, context)
if (!inner.ok) return inner
const carried = carryStrippedWhitespace(inner.value)
@@ -263,20 +258,16 @@ function needsPadding(text: string): boolean {
return text.startsWith(' ') && text.endsWith(' ') && /[^ ]/.test(text)
}
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext): Result<InlineSegment[]> {
const path = nodePath(context, index)
const unspelled = Object.keys(mark.attrs ?? {}).find((key) => !linkAttributes.includes(key))
if (unspelled !== undefined) return failure('unspellable-mark', `the link spelling holds no ${unspelled} attribute`, path)
function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, index: number, context: InlineContext, path: ConvertErrorPath): Result<InlineSegment[]> {
const href = mark.attrs?.['href']
const title = mark.attrs?.['title']
if (typeof href !== 'string') return failure('unsupported-node-shape', 'a link mark carries no href', path)
if (title !== undefined && typeof title !== 'string') return failure('unsupported-node-shape', 'a link title is no string', path)
const node = nodes[0]
const bare = nodes.length === 1 && node !== undefined && node.type === 'text' && node.text === href && (node.marks ?? []).length === depth + 1
if (bare && title === undefined && isAutolink(href) && !holdsEntityReference(href)) return success([syntax(`<${href}>`)])
const destination = spellDestination(href, path)
if (!destination.ok) return destination
const spelledTitle = title === undefined ? success('') : spellTitle(title, path)
const spelledTitle = typeof title === 'string' ? spellTitle(title, path) : success('')
if (!spelledTitle.ok) return spelledTitle
const inner = emitRun(nodes, depth + 1, index, { ...context, bracketed: true })
if (!inner.ok) return inner
+6 -3
View File
@@ -110,7 +110,10 @@ detail is settled at its own milestone.
arg slot holding no bare token, marks no nesting spells — where the emitter still refuses,
which leaves the refusals a container's own spelling owns. The carry spells
`escaping: 'none'`, which is what makes `tryPipeCell` refuse the pipe form for a carry
whose JSON holds a pipe.
whose JSON holds a pipe. The flanking trigger 2e2 added to that list is the odd one out:
`unspellableMark` finds it after assembly and names a mark type against the line's path,
so the failing run needs identifying before the carry can replace the refusal
`corpus/unspellable/mark-inside-word` pins.
- [ ] **2e5 — Combined documents and the collision property.** Documents combining nodes rather
than isolating one, and the gate's collision property: no two corpus documents may emit
the same bytes — one spelling for two documents is a round-trip break no parser can undo,
@@ -169,8 +172,8 @@ detail is settled at its own milestone.
markdown, the payloads supplied by the maintainer.
- [ ] **5 — Release pipeline, ship `0.1.0`.** Publish-on-version-change (§9), `NPM_TOKEN` secret,
the repo made public first (§6). The `ConvertErrorCode` freeze (§8) is checkable here: every
`corpus/unspellable/` document is one of 1d's decisions, so the directory empties as they land
and whatever survives is permanent. `0.1.0` is the markdown round-trip: both markdown
`corpus/unspellable/` document is a decision or a deferred trigger this file names, so the
directory empties as they land and whatever survives is permanent. `0.1.0` is the markdown round-trip: both markdown
directions, the types, `isAdfDocument`. The build lands here: a build tsconfig emitting JS
and `.d.ts` to `dist/` (the dev config's `allowImportingTsExtensions` forces `noEmit`, so
the build config needs `rewriteRelativeImportExtensions`), plus `exports`/`files` in