From 559922e3270ea9a39beccc3da939dd8a2a9495ec Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 27 Aug 2026 22:37:11 +0200 Subject: [PATCH] Answer the architecture review: emit-only spellings move down, and the tables drop markdown's words --- AGENTS.md | 3 + src/adf/block-directives.ts | 80 ++++++++++--------- src/adf/inline-directives.ts | 8 +- src/markdown/block-directive-arguments.ts | 11 +++ src/markdown/commonmark-grammar.ts | 11 +++ src/markdown/directive-attributes.ts | 5 ++ src/markdown/emit/adf-to-markdown.ts | 10 +-- .../{ => emit}/block-directive-spelling.ts | 20 ++--- .../{ => emit}/destination-spelling.ts | 4 +- .../emit/inline-directive-spelling.ts | 9 +++ src/markdown/emit/inline-line.ts | 6 +- src/markdown/emit/line-escaping.ts | 19 +++-- src/markdown/inline-directive-spelling.ts | 9 --- todo.md | 12 +-- 14 files changed, 125 insertions(+), 82 deletions(-) create mode 100644 src/markdown/block-directive-arguments.ts rename src/markdown/{ => emit}/block-directive-spelling.ts (57%) rename src/markdown/{ => emit}/destination-spelling.ts (94%) create mode 100644 src/markdown/emit/inline-directive-spelling.ts delete mode 100644 src/markdown/inline-directive-spelling.ts diff --git a/AGENTS.md b/AGENTS.md index f934f73..f88b32f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,9 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga - No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states unrepresentable. +- `src/adf/` holds ADF's own knowledge and imports no format. Each format directory (`markdown/`, + `html/`) parts into `emit/` (ADF→format) and `parse/` (format→ADF), its root holding only what + both directions read. - The attribute vocabulary is ADF's: `adf/` walks it and narrows each value to its kind, and a format spells the narrowed value. A spelling that re-checks the type is the check's second copy. - Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`); a diff --git a/src/adf/block-directives.ts b/src/adf/block-directives.ts index 04912a2..f750afd 100644 --- a/src/adf/block-directives.ts +++ b/src/adf/block-directives.ts @@ -1,9 +1,8 @@ import type { AttributeVocabulary } from './attribute-vocabulary.ts' export type BlockDirective = { - argument?: string attributes: AttributeVocabulary - body: 'block' | 'code' | 'inline' | 'none' + contentModel: 'block' | 'code' | 'inline' | 'none' } const cellAttributes: AttributeVocabulary = { @@ -42,48 +41,53 @@ const mediaAttributes: AttributeVocabulary = { const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' } -const blockDirectives: Readonly> = { - blockTaskItem: { argument: 'state', attributes: localIdAttributes, body: 'block' }, - blockquote: { attributes: localIdAttributes, body: 'block' }, - bodiedExtension: { attributes: extensionAttributes, body: 'block' }, - bodiedSyncBlock: { attributes: syncBlockAttributes, body: 'block' }, - bulletList: { attributes: localIdAttributes, body: 'block' }, - caption: { attributes: localIdAttributes, body: 'inline' }, +const blockDirectives = { + blockTaskItem: { attributes: localIdAttributes, contentModel: 'block' }, + blockquote: { attributes: localIdAttributes, contentModel: 'block' }, + bodiedExtension: { attributes: extensionAttributes, contentModel: 'block' }, + bodiedSyncBlock: { attributes: syncBlockAttributes, contentModel: 'block' }, + bulletList: { attributes: localIdAttributes, contentModel: 'block' }, + caption: { attributes: localIdAttributes, contentModel: 'inline' }, codeBlock: { attributes: { hideLineNumbers: 'boolean', language: 'string', localId: 'string', uniqueId: 'string', wrap: 'boolean' }, - body: 'code', + contentModel: 'code', }, - decisionItem: { attributes: { localId: 'string', state: 'string' }, body: 'inline' }, - decisionList: { attributes: localIdAttributes, body: 'block' }, - expand: { attributes: expandAttributes, body: 'block' }, - extension: { attributes: extensionAttributes, body: 'none' }, - extensionFrame: { attributes: {}, body: 'block' }, - heading: { attributes: { level: 'number', localId: 'string' }, body: 'inline' }, - layoutColumn: { attributes: { localId: 'string', valign: 'string', width: 'number' }, body: 'block' }, - layoutSection: { attributes: localIdAttributes, body: 'block' }, - listItem: { attributes: localIdAttributes, body: 'block' }, - media: { attributes: mediaAttributes, body: 'none' }, - mediaGroup: { attributes: {}, body: 'block' }, - mediaSingle: { attributes: { layout: 'string', localId: 'string', width: 'number', widthType: 'string' }, body: 'block' }, - multiBodiedExtension: { attributes: extensionAttributes, body: 'block' }, - nestedExpand: { attributes: expandAttributes, body: 'block' }, - orderedList: { attributes: { localId: 'string', order: 'number' }, body: 'block' }, + decisionItem: { attributes: { localId: 'string', state: 'string' }, contentModel: 'inline' }, + decisionList: { attributes: localIdAttributes, contentModel: 'block' }, + expand: { attributes: expandAttributes, contentModel: 'block' }, + extension: { attributes: extensionAttributes, contentModel: 'none' }, + extensionFrame: { attributes: {}, contentModel: 'block' }, + heading: { attributes: { level: 'number', localId: 'string' }, contentModel: 'inline' }, + layoutColumn: { attributes: { localId: 'string', valign: 'string', width: 'number' }, contentModel: 'block' }, + layoutSection: { attributes: localIdAttributes, contentModel: 'block' }, + listItem: { attributes: localIdAttributes, contentModel: 'block' }, + media: { attributes: mediaAttributes, contentModel: 'none' }, + mediaGroup: { attributes: {}, contentModel: 'block' }, + mediaSingle: { attributes: { layout: 'string', localId: 'string', width: 'number', widthType: 'string' }, contentModel: 'block' }, + multiBodiedExtension: { attributes: extensionAttributes, contentModel: 'block' }, + nestedExpand: { attributes: expandAttributes, contentModel: 'block' }, + orderedList: { attributes: { localId: 'string', order: 'number' }, contentModel: 'block' }, panel: { - argument: 'panelType', attributes: { localId: 'string', panelColor: 'string', panelIcon: 'string', panelIconId: 'string', panelIconText: 'string' }, - body: 'block', + contentModel: 'block', }, - paragraph: { attributes: localIdAttributes, body: 'inline' }, - rule: { attributes: localIdAttributes, body: 'none' }, - syncBlock: { attributes: syncBlockAttributes, body: 'none' }, - table: { attributes: { displayMode: 'string', isNumberColumnEnabled: 'boolean', layout: 'string', localId: 'string', width: 'number' }, body: 'block' }, - tableCell: { attributes: cellAttributes, body: 'block' }, - tableHeader: { attributes: cellAttributes, body: 'block' }, - tableRow: { attributes: localIdAttributes, body: 'block' }, - taskItem: { argument: 'state', attributes: localIdAttributes, body: 'inline' }, - taskList: { attributes: localIdAttributes, body: 'block' }, -} + paragraph: { attributes: localIdAttributes, contentModel: 'inline' }, + rule: { attributes: localIdAttributes, contentModel: 'none' }, + syncBlock: { attributes: syncBlockAttributes, contentModel: 'none' }, + table: { attributes: { displayMode: 'string', isNumberColumnEnabled: 'boolean', layout: 'string', localId: 'string', width: 'number' }, contentModel: 'block' }, + tableCell: { attributes: cellAttributes, contentModel: 'block' }, + tableHeader: { attributes: cellAttributes, contentModel: 'block' }, + tableRow: { attributes: localIdAttributes, contentModel: 'block' }, + taskItem: { attributes: localIdAttributes, contentModel: 'inline' }, + taskList: { attributes: localIdAttributes, contentModel: 'block' }, +} satisfies Readonly> + +export type BlockType = keyof typeof blockDirectives export function blockDirective(type: string): BlockDirective | undefined { - return Object.hasOwn(blockDirectives, type) ? blockDirectives[type] : undefined + return isBlockType(type) ? blockDirectives[type] : undefined +} + +function isBlockType(type: string): type is BlockType { + return Object.hasOwn(blockDirectives, type) } diff --git a/src/adf/inline-directives.ts b/src/adf/inline-directives.ts index 73d5e08..75ab247 100644 --- a/src/adf/inline-directives.ts +++ b/src/adf/inline-directives.ts @@ -2,12 +2,12 @@ import type { AttributeVocabulary } from './attribute-vocabulary.ts' export type InlineDirective = { attributes: AttributeVocabulary - slot?: string + textAttribute?: string } const inlineDirectives: Readonly> = { date: { attributes: { localId: 'string', timestamp: 'string' } }, - emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string' }, slot: 'text' }, + emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string' }, textAttribute: 'text' }, hardBreak: { attributes: { localId: 'string', text: 'string' } }, inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' } }, mediaInline: { @@ -23,8 +23,8 @@ const inlineDirectives: Readonly> = { width: 'number', }, }, - mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', userType: 'string' }, slot: 'text' }, - status: { attributes: { color: 'string', localId: 'string', style: 'string' }, slot: 'text' }, + mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', userType: 'string' }, textAttribute: 'text' }, + status: { attributes: { color: 'string', localId: 'string', style: 'string' }, textAttribute: 'text' }, } export function inlineDirective(type: string): InlineDirective | undefined { diff --git a/src/markdown/block-directive-arguments.ts b/src/markdown/block-directive-arguments.ts new file mode 100644 index 0000000..ee86d95 --- /dev/null +++ b/src/markdown/block-directive-arguments.ts @@ -0,0 +1,11 @@ +import type { BlockType } from '../adf/block-directives.ts' + +const blockArguments: Readonly> = { + blockTaskItem: 'state', + panel: 'panelType', + taskItem: 'state', +} satisfies Partial> + +export function blockArgument(type: string): string | undefined { + return blockArguments[type] +} diff --git a/src/markdown/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts index 2fd77a6..022b654 100644 --- a/src/markdown/commonmark-grammar.ts +++ b/src/markdown/commonmark-grammar.ts @@ -1,5 +1,8 @@ export type LinePosition = 'first' | 'later' +const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/ +const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/] + const controlCharacterRange = '\\u0000-\\u001f\\u007f' const autolinkSource = `[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\\s<>${controlCharacterRange}]*` const nullCharacterSource = '\\u0000' @@ -42,6 +45,10 @@ export function holdsNullCharacter(text: string): boolean { return nullCharacter.test(text) } +export function isAsciiPunctuation(character: string): boolean { + return asciiPunctuation.test(character) +} + export function isAutolink(text: string): boolean { return autolink.test(text) } @@ -58,6 +65,10 @@ export function opensBracketedAutolink(text: string): boolean { return bracketedAutolink.test(text) } +export function opensHtmlConstruct(text: string): boolean { + return htmlConstructs.some((construct) => construct.test(text)) +} + export function startsEntityReference(text: string): boolean { return anchoredEntityReference.test(text) } diff --git a/src/markdown/directive-attributes.ts b/src/markdown/directive-attributes.ts index 5a27544..246a216 100644 --- a/src/markdown/directive-attributes.ts +++ b/src/markdown/directive-attributes.ts @@ -3,6 +3,7 @@ import type { VocabularyPair } from '../adf/attribute-vocabulary.ts' import { serializeCanonicalJson } from '../canonical-json.ts' const bareToken = /^[A-Za-z0-9_-]+$/ +const inlineDirectiveOpener = /^:[a-z][A-Za-z0-9]*[[{]/ // spec/flavour.md, Attributes. const quotedEscapes = /[&<`|]/g @@ -11,6 +12,10 @@ export function isBareToken(text: string): boolean { return bareToken.test(text) } +export function opensInlineDirective(text: string): boolean { + return inlineDirectiveOpener.test(text) +} + export function spellAttributes(pairs: readonly (readonly [string, string])[]): string { if (pairs.length === 0) return '' const spelled = [...pairs].sort(([left], [right]) => (left < right ? -1 : 1)).map(([key, value]) => `${key}=${value}`) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index c673dbb..4334c0e 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -9,7 +9,7 @@ import { failure, success, type ConvertErrorPath, type Result } from '../../resu import { fencedCodeBlock } from '../backtick-runs.ts' import { holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isThematicBreak } from '../commonmark-grammar.ts' import { largestNesting } from '../../nesting.ts' -import { spellDirectiveHeader } from '../block-directive-spelling.ts' +import { spellDirectiveHeader } from './block-directive-spelling.ts' import { tryImage } from './image.ts' import { tryPipeTable } from './pipe-table.ts' @@ -117,14 +117,14 @@ function commonMarkText(text: string): EmittedBlock { function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result { if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path) const content = node.content ?? [] - if (directive.body === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path) - if (directive.body === 'code') return emitCodeDirective(node, directive, path) + if (directive.contentModel === 'none' && content.length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content`, path) + if (directive.contentModel === 'code') return emitCodeDirective(node, directive, path) const header = spellDirectiveHeader(node, directive) if (header === undefined) return commonMarkLine(carriedBlock(node, path)) - if (directive.body === 'none' || (directive.body === 'inline' && content.length === 0)) { + if (directive.contentModel === 'none' || (directive.contentModel === 'inline' && content.length === 0)) { return success({ fenceColons: 2, spelling: 'directive', text: `::${header}` }) } - const body = directive.body === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1) + const body = directive.contentModel === 'inline' ? emitInlineBody(content, path) : emitBlocks(content, 'directive', path, depth + 1) if (!body.ok) return body const fenceColons = Math.max(3, body.value.fenceColons + 1) const fence = ':'.repeat(fenceColons) diff --git a/src/markdown/block-directive-spelling.ts b/src/markdown/emit/block-directive-spelling.ts similarity index 57% rename from src/markdown/block-directive-spelling.ts rename to src/markdown/emit/block-directive-spelling.ts index 446486d..18c7ac9 100644 --- a/src/markdown/block-directive-spelling.ts +++ b/src/markdown/emit/block-directive-spelling.ts @@ -1,13 +1,15 @@ -import type { AdfMark, AdfNode } from '../adf/document.ts' -import type { BlockDirective } from '../adf/block-directives.ts' -import type { JsonValue } from '../json-value.ts' -import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from './directive-attributes.ts' -import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' +import type { AdfMark, AdfNode } from '../../adf/document.ts' +import type { BlockDirective } from '../../adf/block-directives.ts' +import type { JsonValue } from '../../json-value.ts' +import { blockArgument } from '../block-directive-arguments.ts' +import { isBareToken, spellAttributes, spellJsonAttribute, spellVocabulary } from '../directive-attributes.ts' +import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { - const argument = spellArgument(node, directive) + const argumentAttribute = blockArgument(node.type) + const argument = spellArgument(node, argumentAttribute) if (argument === undefined) return undefined - const spelled = directive.argument === undefined ? spelledByBody : [directive.argument, ...spelledByBody] + const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody] const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, spelled) if (pairs === undefined) return undefined const spelledPairs = spellVocabulary(pairs) @@ -17,8 +19,8 @@ export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, s return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}` } -function spellArgument(node: AdfNode, directive: BlockDirective): string | undefined { - const value = directive.argument === undefined ? undefined : node.attrs?.[directive.argument] +function spellArgument(node: AdfNode, argumentAttribute: string | undefined): string | undefined { + const value = argumentAttribute === undefined ? undefined : node.attrs?.[argumentAttribute] if (value === undefined) return '' if (typeof value !== 'string' || !isBareToken(value)) return undefined return ` ${value}` diff --git a/src/markdown/destination-spelling.ts b/src/markdown/emit/destination-spelling.ts similarity index 94% rename from src/markdown/destination-spelling.ts rename to src/markdown/emit/destination-spelling.ts index 619f3ad..6baf8b3 100644 --- a/src/markdown/destination-spelling.ts +++ b/src/markdown/emit/destination-spelling.ts @@ -1,5 +1,5 @@ -import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' -import { holdsControlCharacter, holdsEntityReference } from './commonmark-grammar.ts' +import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' +import { holdsControlCharacter, holdsEntityReference } from '../commonmark-grammar.ts' export function spellDestination(href: string, path: ConvertErrorPath): Result { if (holdsControlCharacter(href)) return failure('unspellable-link-destination', 'a link destination holds a control character', path) diff --git a/src/markdown/emit/inline-directive-spelling.ts b/src/markdown/emit/inline-directive-spelling.ts new file mode 100644 index 0000000..73866ce --- /dev/null +++ b/src/markdown/emit/inline-directive-spelling.ts @@ -0,0 +1,9 @@ +import type { AdfNode } from '../../adf/document.ts' +import type { InlineDirective } from '../../adf/inline-directives.ts' +import { spellAttributes, spellVocabulary } from '../directive-attributes.ts' +import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' + +export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined { + const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.textAttribute === undefined ? [] : [directive.textAttribute]) + return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) +} diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 76edfba..bd22c07 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -10,8 +10,8 @@ import { longestBacktickRun } from '../backtick-runs.ts' import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { serializeCanonicalJson } from '../../canonical-json.ts' import { spellAttributes, spellStringAttribute } from '../directive-attributes.ts' -import { spellDestination, spellTitle } from '../destination-spelling.ts' -import { spellInlineNodeAttributes } from '../inline-directive-spelling.ts' +import { spellDestination, spellTitle } from './destination-spelling.ts' +import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' type EmittedLine = { line: string; segments: InlineSegment[] } @@ -209,7 +209,7 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n if (!empty.ok) return empty const attributes = spellInlineNodeAttributes(node, directive) if (attributes === undefined) return success({ carry: { first: index, last: index } }) - const slot = directive.slot === undefined ? undefined : node.attrs?.[directive.slot] + const slot = directive.textAttribute === undefined ? undefined : node.attrs?.[directive.textAttribute] if (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] }) if (typeof slot !== 'string') return success({ carry: { first: index, last: index } }) if (/[\n\r]/.test(slot)) return failure('unspellable-whitespace', `a ${node.type} content slot holds a newline no inline directive spans`, path) diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index f84fde7..5591e4b 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -1,5 +1,13 @@ -import { escapesLineClaim, opensBracketedAutolink, startsEntityReference, type LinePosition } from '../commonmark-grammar.ts' import { delimiterFlags, isWordCharacter, matchEmphasis } from '../emphasis-matching.ts' +import { + escapesLineClaim, + isAsciiPunctuation, + opensBracketedAutolink, + opensHtmlConstruct, + startsEntityReference, + type LinePosition, +} from '../commonmark-grammar.ts' +import { opensInlineDirective } from '../directive-attributes.ts' export type EmphasisRole = 'close' | 'open' @@ -21,9 +29,6 @@ type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; deli const delimiters = ['*', '_', '`', '~'] -const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/ -const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/] -const inlineDirectiveOpener = /^:[a-z][A-Za-z0-9]*[[{]/ const followsLinkText = /[([:]/ export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine { @@ -211,10 +216,10 @@ function claimsCharacter( const rest = scan.slice(index) if (inBrackets && (character === '[' || character === ']')) return true if (character === '|') return container === 'table-cell' - if (character === '\\') return asciiPunctuation.test(scan.charAt(index + 1)) + if (character === '\\') return isAsciiPunctuation(scan.charAt(index + 1)) if (character === '&') return startsEntityReference(rest) - if (character === '<') return opensBracketedAutolink(rest) || htmlConstructs.some((construct) => construct.test(rest)) - if (character === ':') return inlineDirectiveOpener.test(rest) + if (character === '<') return opensBracketedAutolink(rest) || opensHtmlConstruct(rest) + if (character === ':') return opensInlineDirective(rest) if (character === '[') return opensLink(scan, escapings, index) if (character === '`') return opensCodeSpan(scan, index, escaped) if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, escaped) diff --git a/src/markdown/inline-directive-spelling.ts b/src/markdown/inline-directive-spelling.ts deleted file mode 100644 index a994d2f..0000000 --- a/src/markdown/inline-directive-spelling.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AdfNode } from '../adf/document.ts' -import type { InlineDirective } from '../adf/inline-directives.ts' -import { spellAttributes, spellVocabulary } from './directive-attributes.ts' -import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' - -export function spellInlineNodeAttributes(node: AdfNode, directive: InlineDirective): string | undefined { - const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, directive.slot === undefined ? [] : [directive.slot]) - return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) -} diff --git a/todo.md b/todo.md index f457c9c..411b5e7 100644 --- a/todo.md +++ b/todo.md @@ -95,9 +95,10 @@ detail is settled at its own milestone. included, and a mark spelling that cannot open where it sits (`un**-real**istic`; the spec owes the carry a trigger). One mark vocabulary lands here, before 2e3 changes the attribute spelling: `emphasisSpellings`, `linkAttributes` and the `code`/`link` names join - `inline-directives.ts`, which holds four of the nine marks while the rest are branch - literals in the emitter — and the parser (3) needs every name to make `:em[x]` the named - error `spec/flavour.md` promises. + the mark table (3a parted it across `adf/mark-attributes.ts` and + `markdown/mark-spellings.ts`), which holds four of the nine marks while the rest are + branch literals in the emitter — and the parser (3) needs every name to make `:em[x]` + the named error `spec/flavour.md` promises. - [x] **2e3 — Attribute canonicalization and the quoted value's escape.** **Settled** (the maintainer, 2026-08-26): a quoted attribute value escapes `` ` ``, `&`, `<` and `|` as `\u0060`, `\u0026`, `\u003c` and `\u007c`, in every directive, block and @@ -225,8 +226,9 @@ detail is settled at its own milestone. entity references decoding to their characters, code spans and the literal they hold — directive syntax and `~~` included — CommonMark's own hard breaks, a trailing backslash and two trailing spaces alike, a soft line break as one space, and the raw inline tag, - comment and processing instruction refused by name, recognized by the `htmlConstructs` - `markdown-escaping.ts` already escapes against, under 3b's one-table rule. + comment and processing instruction refused by name, recognized by the + `commonmark-grammar.ts` predicates the emitter already escapes against, under 3b's + one-table rule. - [ ] **3e — Emphasis and links.** `_`, `*` and `~~` runs through `matchEmphasis` to the `em`, `strong` and `strike` marks; links inline and reference, 3b's definitions resolved here, autolinks, and the image gap's named errors — a titled image, and one amid other text.