From e580eec09badf7eb4a4a72b4bfd837239b05fbcf Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 27 Aug 2026 22:16:27 +0200 Subject: [PATCH 1/3] Part the source into adf/ and markdown/ ahead of the parser --- AGENTS.md | 6 +- src/adf/attribute-vocabulary.ts | 32 ++++++++++ src/{ => adf}/block-directives.ts | 30 +-------- .../document.test.ts} | 2 +- src/{adf-document.ts => adf/document.ts} | 6 +- src/adf/inline-directives.ts | 32 ++++++++++ src/adf/mark-attributes.ts | 19 ++++++ src/corpus.test.ts | 4 +- src/directive-attributes.ts | 50 --------------- src/index.ts | 6 +- src/inline-directives.ts | 63 ------------------- src/{ => markdown}/backtick-runs.ts | 0 src/markdown/block-directive-spelling.ts | 32 ++++++++++ src/{ => markdown}/commonmark-grammar.ts | 0 src/markdown/destination-spelling.ts | 37 +++++++++++ src/markdown/directive-attributes.ts | 41 ++++++++++++ .../emit}/adf-to-markdown.test.ts | 6 +- src/{ => markdown/emit}/adf-to-markdown.ts | 27 ++++---- .../emit/image.ts} | 10 +-- .../emit/inline-line.ts} | 60 +++++------------- .../emit/line-escaping.ts} | 4 +- .../emit/pipe-table.ts} | 8 +-- src/{ => markdown}/emphasis-matching.ts | 0 src/markdown/inline-directive-spelling.ts | 9 +++ src/markdown/mark-spellings.ts | 35 +++++++++++ src/{ => markdown}/opaque-carry.ts | 12 ++-- todo.md | 2 +- 27 files changed, 299 insertions(+), 234 deletions(-) create mode 100644 src/adf/attribute-vocabulary.ts rename src/{ => adf}/block-directives.ts (71%) rename src/{adf-document.test.ts => adf/document.test.ts} (97%) rename src/{adf-document.ts => adf/document.ts} (92%) create mode 100644 src/adf/inline-directives.ts create mode 100644 src/adf/mark-attributes.ts delete mode 100644 src/directive-attributes.ts delete mode 100644 src/inline-directives.ts rename src/{ => markdown}/backtick-runs.ts (100%) create mode 100644 src/markdown/block-directive-spelling.ts rename src/{ => markdown}/commonmark-grammar.ts (100%) create mode 100644 src/markdown/destination-spelling.ts create mode 100644 src/markdown/directive-attributes.ts rename src/{ => markdown/emit}/adf-to-markdown.test.ts (99%) rename src/{ => markdown/emit}/adf-to-markdown.ts (94%) rename src/{markdown-image.ts => markdown/emit/image.ts} (79%) rename src/{markdown-inline.ts => markdown/emit/inline-line.ts} (86%) rename src/{markdown-escaping.ts => markdown/emit/line-escaping.ts} (99%) rename src/{markdown-pipe-table.ts => markdown/emit/pipe-table.ts} (89%) rename src/{ => markdown}/emphasis-matching.ts (100%) create mode 100644 src/markdown/inline-directive-spelling.ts create mode 100644 src/markdown/mark-spellings.ts rename src/{ => markdown}/opaque-carry.ts (78%) diff --git a/AGENTS.md b/AGENTS.md index b55b6f1..f934f73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,7 +137,11 @@ 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. -- Explicit over implicit; descriptive names; no catch-all files (`utils`, `helpers`, `misc`). +- 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 + file does not repeat its directory in its name — `adf/document.ts`, never + `adf/adf-document.ts`. - Reuse before adding; the smallest sufficient diff is the benchmark; no speculative generality — a second consumer, or it goes. diff --git a/src/adf/attribute-vocabulary.ts b/src/adf/attribute-vocabulary.ts new file mode 100644 index 0000000..7e30d80 --- /dev/null +++ b/src/adf/attribute-vocabulary.ts @@ -0,0 +1,32 @@ +import type { AdfAttributes } from './document.ts' +import type { JsonValue } from '../json-value.ts' + +export type AttributeKind = 'boolean' | 'json' | 'number' | 'string' + +export type AttributeVocabulary = Readonly> + +export type VocabularyPair = + | { key: string; kind: 'boolean'; value: boolean } + | { key: string; kind: 'json'; value: JsonValue } + | { key: string; kind: 'number'; value: number } + | { key: string; kind: 'string'; value: string } + +export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, spelledElsewhere: readonly string[]): VocabularyPair[] | undefined { + const pairs: VocabularyPair[] = [] + for (const [key, value] of Object.entries(attrs)) { + if (spelledElsewhere.includes(key)) continue + const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined + if (kind === undefined) return undefined + const pair = vocabularyPair(key, value, kind) + if (pair === undefined) return undefined + pairs.push(pair) + } + return pairs +} + +function vocabularyPair(key: string, value: JsonValue, kind: AttributeKind): VocabularyPair | undefined { + if (kind === 'boolean') return typeof value === 'boolean' ? { key, kind, value } : undefined + if (kind === 'number') return typeof value === 'number' ? { key, kind, value } : undefined + if (kind === 'string') return typeof value === 'string' ? { key, kind, value } : undefined + return { key, kind, value } +} diff --git a/src/block-directives.ts b/src/adf/block-directives.ts similarity index 71% rename from src/block-directives.ts rename to src/adf/block-directives.ts index 817689e..04912a2 100644 --- a/src/block-directives.ts +++ b/src/adf/block-directives.ts @@ -1,6 +1,4 @@ -import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts' -import type { JsonValue } from './json-value.ts' -import { isBareToken, spellAttributes, spellJsonAttribute, vocabularyPairs } from './directive-attributes.ts' +import type { AttributeVocabulary } from './attribute-vocabulary.ts' export type BlockDirective = { argument?: string @@ -89,29 +87,3 @@ const blockDirectives: Readonly> = { export function blockDirective(type: string): BlockDirective | undefined { return Object.hasOwn(blockDirectives, type) ? blockDirectives[type] : undefined } - -export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { - const argument = spellArgument(node, directive) - if (argument === undefined) return undefined - const spelled = directive.argument === undefined ? spelledByBody : [directive.argument, ...spelledByBody] - const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, spelled) - if (pairs === undefined) return undefined - const marks = node.marks ?? [] - if (marks.length > 0) pairs.push(['marks', spellJsonAttribute(markValues(marks))]) - const attributes = spellAttributes(pairs) - return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}` -} - -function spellArgument(node: AdfNode, directive: BlockDirective): string | undefined { - const value = directive.argument === undefined ? undefined : node.attrs?.[directive.argument] - if (value === undefined) return '' - if (typeof value !== 'string' || !isBareToken(value)) return undefined - return ` ${value}` -} - -function markValues(marks: readonly AdfMark[]): JsonValue { - return marks.map((mark) => { - const attrs = mark.attrs ?? {} - return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type } - }) -} diff --git a/src/adf-document.test.ts b/src/adf/document.test.ts similarity index 97% rename from src/adf-document.test.ts rename to src/adf/document.test.ts index 1c5eaba..0d1cb91 100644 --- a/src/adf-document.test.ts +++ b/src/adf/document.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { isAdfDocument } from './adf-document.ts' +import { isAdfDocument } from './document.ts' test('accepts an editor-normal document', () => { assert.equal(isAdfDocument({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true) diff --git a/src/adf-document.ts b/src/adf/document.ts similarity index 92% rename from src/adf-document.ts rename to src/adf/document.ts index f85ac28..7502f23 100644 --- a/src/adf-document.ts +++ b/src/adf/document.ts @@ -1,11 +1,7 @@ -import { isJsonValue, type JsonValue } from './json-value.ts' +import { isJsonValue, type JsonValue } from '../json-value.ts' export type AdfAttributes = { [key: string]: JsonValue } -export type AttributeKind = 'boolean' | 'json' | 'number' | 'string' - -export type AttributeVocabulary = Readonly> - export type AdfMark = { attrs?: AdfAttributes type: string diff --git a/src/adf/inline-directives.ts b/src/adf/inline-directives.ts new file mode 100644 index 0000000..73d5e08 --- /dev/null +++ b/src/adf/inline-directives.ts @@ -0,0 +1,32 @@ +import type { AttributeVocabulary } from './attribute-vocabulary.ts' + +export type InlineDirective = { + attributes: AttributeVocabulary + slot?: string +} + +const inlineDirectives: Readonly> = { + date: { attributes: { localId: 'string', timestamp: 'string' } }, + emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string' }, slot: 'text' }, + hardBreak: { attributes: { localId: 'string', text: 'string' } }, + inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' } }, + mediaInline: { + attributes: { + alt: 'string', + collection: 'string', + data: 'json', + height: 'number', + id: 'string', + localId: 'string', + occurrenceKey: 'string', + type: 'string', + width: 'number', + }, + }, + mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', userType: 'string' }, slot: 'text' }, + status: { attributes: { color: 'string', localId: 'string', style: 'string' }, slot: 'text' }, +} + +export function inlineDirective(type: string): InlineDirective | undefined { + return Object.hasOwn(inlineDirectives, type) ? inlineDirectives[type] : undefined +} diff --git a/src/adf/mark-attributes.ts b/src/adf/mark-attributes.ts new file mode 100644 index 0000000..7ae22eb --- /dev/null +++ b/src/adf/mark-attributes.ts @@ -0,0 +1,19 @@ +import type { AttributeVocabulary } from './attribute-vocabulary.ts' + +export const markAttributes = { + border: { color: 'string', size: 'number' }, + code: {}, + em: {}, + link: { href: 'string', title: 'string' }, + strike: {}, + strong: {}, + subsup: { type: 'string' }, + textColor: { color: 'string' }, + underline: {}, +} satisfies Readonly> + +export type MarkType = keyof typeof markAttributes + +export function isMarkType(type: string): type is MarkType { + return Object.hasOwn(markAttributes, type) +} diff --git a/src/corpus.test.ts b/src/corpus.test.ts index 64f4251..a3cafbe 100644 --- a/src/corpus.test.ts +++ b/src/corpus.test.ts @@ -4,8 +4,8 @@ import { dirname, join } from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { adfToMarkdown } from './adf-to-markdown.ts' -import { isAdfDocument } from './adf-document.ts' +import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' +import { isAdfDocument } from './adf/document.ts' import { isJsonValue } from './json-value.ts' import { serializeCanonicalJson } from './canonical-json.ts' diff --git a/src/directive-attributes.ts b/src/directive-attributes.ts deleted file mode 100644 index b93492d..0000000 --- a/src/directive-attributes.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { AdfAttributes, AttributeKind, AttributeVocabulary } from './adf-document.ts' -import type { JsonValue } from './json-value.ts' -import { serializeCanonicalJson } from './canonical-json.ts' - -const bareToken = /^[A-Za-z0-9_-]+$/ - -// spec/flavour.md, Attributes. -const quotedEscapes = /[&<`|]/g - -export function isBareToken(text: string): boolean { - return bareToken.test(text) -} - -export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, spelledElsewhere: readonly string[]): [string, string][] | undefined { - const pairs: [string, string][] = [] - for (const [key, value] of Object.entries(attrs)) { - if (spelledElsewhere.includes(key)) continue - const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined - if (kind === undefined) return undefined - const spelled = spellAttributeValue(value, kind) - if (spelled === undefined) return undefined - pairs.push([key, spelled]) - } - return pairs -} - -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}`) - return `{${spelled.join(' ')}}` -} - -export function spellAttributeValue(value: JsonValue, kind: AttributeKind): string | undefined { - if (kind === 'json') return spellJsonAttribute(value) - if (kind === 'boolean') return typeof value === 'boolean' ? `${value}` : undefined - if (kind === 'number') return typeof value === 'number' ? spellStringAttribute(JSON.stringify(value)) : undefined - return typeof value === 'string' ? spellStringAttribute(value) : undefined -} - -export function spellJsonAttribute(value: JsonValue): string { - return quote(serializeCanonicalJson(value, 'compact')) -} - -export function spellStringAttribute(text: string): string { - return isBareToken(text) ? text : quote(text) -} - -function quote(text: string): string { - return JSON.stringify(text).replace(quotedEscapes, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`) -} diff --git a/src/index.ts b/src/index.ts index 6afbe03..056730f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ -export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf-document.ts' +export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts' export type { ConvertError, ConvertErrorCode, Result } from './result.ts' export type { JsonValue } from './json-value.ts' -export { adfToMarkdown } from './adf-to-markdown.ts' -export { isAdfDocument } from './adf-document.ts' +export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts' +export { isAdfDocument } from './adf/document.ts' diff --git a/src/inline-directives.ts b/src/inline-directives.ts deleted file mode 100644 index eaad116..0000000 --- a/src/inline-directives.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts' -import { spellAttributes, vocabularyPairs } from './directive-attributes.ts' - -export type InlineDirective = { - attributes: AttributeVocabulary - slot?: string -} - -export type MarkSpelling = - | { attributes: AttributeVocabulary; kind: 'code' | 'directive' | 'link'; spelling?: undefined } - | { attributes: AttributeVocabulary; kind: 'emphasis'; spelling: string } - -const inlineDirectives: Readonly> = { - date: { attributes: { localId: 'string', timestamp: 'string' } }, - emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string' }, slot: 'text' }, - hardBreak: { attributes: { localId: 'string', text: 'string' } }, - inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' } }, - mediaInline: { - attributes: { - alt: 'string', - collection: 'string', - data: 'json', - height: 'number', - id: 'string', - localId: 'string', - occurrenceKey: 'string', - type: 'string', - width: 'number', - }, - }, - mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', userType: 'string' }, slot: 'text' }, - status: { attributes: { color: 'string', localId: 'string', style: 'string' }, slot: 'text' }, -} - -const markSpellings: Readonly> = { - 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 markSpelling(type: string): MarkSpelling | undefined { - return Object.hasOwn(markSpellings, type) ? markSpellings[type] : undefined -} - -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(pairs) -} - -export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined { - const pairs = vocabularyPairs(mark.attrs ?? {}, vocabulary, []) - return pairs === undefined ? undefined : spellAttributes(pairs) -} diff --git a/src/backtick-runs.ts b/src/markdown/backtick-runs.ts similarity index 100% rename from src/backtick-runs.ts rename to src/markdown/backtick-runs.ts diff --git a/src/markdown/block-directive-spelling.ts b/src/markdown/block-directive-spelling.ts new file mode 100644 index 0000000..446486d --- /dev/null +++ b/src/markdown/block-directive-spelling.ts @@ -0,0 +1,32 @@ +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' + +export function spellDirectiveHeader(node: AdfNode, directive: BlockDirective, spelledByBody: readonly string[] = []): string | undefined { + const argument = spellArgument(node, directive) + if (argument === undefined) return undefined + const spelled = directive.argument === undefined ? spelledByBody : [directive.argument, ...spelledByBody] + const pairs = vocabularyPairs(node.attrs ?? {}, directive.attributes, spelled) + if (pairs === undefined) return undefined + const spelledPairs = spellVocabulary(pairs) + const marks = node.marks ?? [] + if (marks.length > 0) spelledPairs.push(['marks', spellJsonAttribute(markValues(marks))]) + const attributes = spellAttributes(spelledPairs) + return `${node.type}${argument}${attributes === '' ? '' : ` ${attributes}`}` +} + +function spellArgument(node: AdfNode, directive: BlockDirective): string | undefined { + const value = directive.argument === undefined ? undefined : node.attrs?.[directive.argument] + if (value === undefined) return '' + if (typeof value !== 'string' || !isBareToken(value)) return undefined + return ` ${value}` +} + +function markValues(marks: readonly AdfMark[]): JsonValue { + return marks.map((mark) => { + const attrs = mark.attrs ?? {} + return Object.keys(attrs).length === 0 ? { type: mark.type } : { attrs, type: mark.type } + }) +} diff --git a/src/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts similarity index 100% rename from src/commonmark-grammar.ts rename to src/markdown/commonmark-grammar.ts diff --git a/src/markdown/destination-spelling.ts b/src/markdown/destination-spelling.ts new file mode 100644 index 0000000..619f3ad --- /dev/null +++ b/src/markdown/destination-spelling.ts @@ -0,0 +1,37 @@ +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) + if (href.includes('\\')) return failure('unspellable-link-destination', 'no canonical escape spells a backslash in a link destination', path) + if (holdsEntityReference(href)) { + return failure('unspellable-link-destination', 'a link destination shaped like an entity reference decodes on the way back', path) + } + if (href.includes(' ')) { + if (/[<>]/.test(href)) { + return failure('unspellable-link-destination', 'no canonical escape spells an angle bracket beside a space in a link destination', path) + } + return success(`<${href}>`) + } + if (href.startsWith('<')) return failure('unspellable-link-destination', 'a bare link destination cannot begin with an angle bracket', path) + if (!balanced(href)) return failure('unspellable-link-destination', 'no canonical escape spells an unbalanced parenthesis in a link destination', path) + return success(href) +} + +export function spellTitle(title: string, path: ConvertErrorPath): Result { + if (/["\n\r\\]/.test(title)) { + return failure('unspellable-link-title', 'no canonical escape spells a quote, backslash or newline in a link title', path) + } + if (holdsEntityReference(title)) return failure('unspellable-link-title', 'a link title shaped like an entity reference decodes on the way back', path) + return success(` "${title}"`) +} + +function balanced(href: string): boolean { + let depth = 0 + for (const character of href) { + if (character === '(') depth += 1 + if (character === ')') depth -= 1 + if (depth < 0) return false + } + return depth === 0 +} diff --git a/src/markdown/directive-attributes.ts b/src/markdown/directive-attributes.ts new file mode 100644 index 0000000..5a27544 --- /dev/null +++ b/src/markdown/directive-attributes.ts @@ -0,0 +1,41 @@ +import type { JsonValue } from '../json-value.ts' +import type { VocabularyPair } from '../adf/attribute-vocabulary.ts' +import { serializeCanonicalJson } from '../canonical-json.ts' + +const bareToken = /^[A-Za-z0-9_-]+$/ + +// spec/flavour.md, Attributes. +const quotedEscapes = /[&<`|]/g + +export function isBareToken(text: string): boolean { + return bareToken.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}`) + return `{${spelled.join(' ')}}` +} + +export function spellVocabulary(pairs: readonly VocabularyPair[]): [string, string][] { + return pairs.map((pair): [string, string] => [pair.key, spellAttributeValue(pair)]) +} + +export function spellJsonAttribute(value: JsonValue): string { + return quote(serializeCanonicalJson(value, 'compact')) +} + +export function spellStringAttribute(text: string): string { + return isBareToken(text) ? text : quote(text) +} + +function spellAttributeValue(pair: VocabularyPair): string { + if (pair.kind === 'boolean') return `${pair.value}` + if (pair.kind === 'json') return spellJsonAttribute(pair.value) + if (pair.kind === 'number') return spellStringAttribute(JSON.stringify(pair.value)) + return spellStringAttribute(pair.value) +} + +function quote(text: string): string { + return JSON.stringify(text).replace(quotedEscapes, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`) +} diff --git a/src/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts similarity index 99% rename from src/adf-to-markdown.test.ts rename to src/markdown/emit/adf-to-markdown.test.ts index 6774b34..398714b 100644 --- a/src/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict' import test from 'node:test' -import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf-document.ts' -import type { Result } from './result.ts' -import { adfToMarkdown } from './index.ts' +import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' +import type { Result } from '../../result.ts' +import { adfToMarkdown } from '../../index.ts' function document(...content: AdfNode[]): AdfDocument { return { content, type: 'doc', version: 1 } diff --git a/src/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts similarity index 94% rename from src/adf-to-markdown.ts rename to src/markdown/emit/adf-to-markdown.ts index 64a89a0..c673dbb 100644 --- a/src/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -1,16 +1,17 @@ -import type { AdfDocument, AdfNode } from './adf-document.ts' -import type { BlockDirective } from './block-directives.ts' -import type { JsonValue } from './json-value.ts' -import { blockDirective, spellDirectiveHeader } from './block-directives.ts' -import { tryImage } from './markdown-image.ts' -import { emitInlineLine } from './markdown-inline.ts' -import { tryPipeTable } from './markdown-pipe-table.ts' -import { carriedBlock, carryName } from './opaque-carry.ts' -import { failure, success, type ConvertErrorPath, type Result } from './result.ts' -import { holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isThematicBreak } from './commonmark-grammar.ts' -import { carriesOnly, isAdfDocument } from './adf-document.ts' -import { largestNesting } from './nesting.ts' -import { fencedCodeBlock } from './backtick-runs.ts' +import type { AdfDocument, AdfNode } from '../../adf/document.ts' +import type { BlockDirective } from '../../adf/block-directives.ts' +import type { JsonValue } from '../../json-value.ts' +import { blockDirective } from '../../adf/block-directives.ts' +import { carriedBlock, carryName } from '../opaque-carry.ts' +import { carriesOnly, isAdfDocument } from '../../adf/document.ts' +import { emitInlineLine } from './inline-line.ts' +import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' +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 { tryImage } from './image.ts' +import { tryPipeTable } from './pipe-table.ts' type BlockContainer = 'directive' | 'document' | 'list-item' type BlockSpelling = 'commonmark' | 'directive' diff --git a/src/markdown-image.ts b/src/markdown/emit/image.ts similarity index 79% rename from src/markdown-image.ts rename to src/markdown/emit/image.ts index 1eaf509..f44a880 100644 --- a/src/markdown-image.ts +++ b/src/markdown/emit/image.ts @@ -1,8 +1,8 @@ -import type { AdfNode } from './adf-document.ts' -import { carriesOnly } from './adf-document.ts' -import type { ConvertErrorPath } from './result.ts' -import { serializeCanonicalJson } from './canonical-json.ts' -import { tryImageLine } from './markdown-inline.ts' +import type { AdfNode } from '../../adf/document.ts' +import { carriesOnly } from '../../adf/document.ts' +import type { ConvertErrorPath } from '../../result.ts' +import { serializeCanonicalJson } from '../../canonical-json.ts' +import { tryImageLine } from './inline-line.ts' const centeredMediaSingle = '{"layout":"center"}' const imageAttributes = ['alt', 'type', 'url'] diff --git a/src/markdown-inline.ts b/src/markdown/emit/inline-line.ts similarity index 86% rename from src/markdown-inline.ts rename to src/markdown/emit/inline-line.ts index b1bdea8..76edfba 100644 --- a/src/markdown-inline.ts +++ b/src/markdown/emit/inline-line.ts @@ -1,14 +1,17 @@ -import type { AdfMark, AdfNode } from './adf-document.ts' -import type { InlineDirective } from './inline-directives.ts' -import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './markdown-escaping.ts' -import { inlineDirective, markSpelling, spellInlineNodeAttributes, spellMarkAttributes } from './inline-directives.ts' -import { largestNesting } from './nesting.ts' -import { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isAutolink } from './commonmark-grammar.ts' -import { carriedInline } from './opaque-carry.ts' -import { failure, success, type ConvertErrorPath, type Result } from './result.ts' -import { longestBacktickRun } from './backtick-runs.ts' -import { serializeCanonicalJson } from './canonical-json.ts' -import { spellAttributes, spellStringAttribute } from './directive-attributes.ts' +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 { carriedInline } from '../opaque-carry.ts' +import { claimsLine, holdsEntityReference, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts' +import { failure, success, type ConvertErrorPath, type Result } from '../../result.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 { 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' type EmittedLine = { line: string; segments: InlineSegment[] } @@ -293,41 +296,6 @@ function emitLink(nodes: readonly AdfNode[], mark: AdfMark, depth: number, range return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${destination.value}${spelledTitle.value})`)] }) } -function spellDestination(href: string, path: ConvertErrorPath): Result { - if (holdsControlCharacter(href)) return failure('unspellable-link-destination', 'a link destination holds a control character', path) - if (href.includes('\\')) return failure('unspellable-link-destination', 'no canonical escape spells a backslash in a link destination', path) - if (holdsEntityReference(href)) { - return failure('unspellable-link-destination', 'a link destination shaped like an entity reference decodes on the way back', path) - } - if (href.includes(' ')) { - if (/[<>]/.test(href)) { - return failure('unspellable-link-destination', 'no canonical escape spells an angle bracket beside a space in a link destination', path) - } - return success(`<${href}>`) - } - if (href.startsWith('<')) return failure('unspellable-link-destination', 'a bare link destination cannot begin with an angle bracket', path) - if (!balanced(href)) return failure('unspellable-link-destination', 'no canonical escape spells an unbalanced parenthesis in a link destination', path) - return success(href) -} - -function spellTitle(title: string, path: ConvertErrorPath): Result { - if (/["\n\r\\]/.test(title)) { - return failure('unspellable-link-title', 'no canonical escape spells a quote, backslash or newline in a link title', path) - } - if (holdsEntityReference(title)) return failure('unspellable-link-title', 'a link title shaped like an entity reference decodes on the way back', path) - return success(` "${title}"`) -} - -function balanced(href: string): boolean { - let depth = 0 - for (const character of href) { - if (character === '(') depth += 1 - if (character === ')') depth -= 1 - if (depth < 0) return false - } - return depth === 0 -} - function sameMark(candidate: AdfMark, mark: AdfMark): boolean { if (candidate.type !== mark.type) return false return serializeCanonicalJson(candidate.attrs ?? {}, 'compact') === serializeCanonicalJson(mark.attrs ?? {}, 'compact') diff --git a/src/markdown-escaping.ts b/src/markdown/emit/line-escaping.ts similarity index 99% rename from src/markdown-escaping.ts rename to src/markdown/emit/line-escaping.ts index 40ad3ec..f84fde7 100644 --- a/src/markdown-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -1,5 +1,5 @@ -import { escapesLineClaim, opensBracketedAutolink, startsEntityReference, type LinePosition } from './commonmark-grammar.ts' -import { delimiterFlags, isWordCharacter, matchEmphasis } from './emphasis-matching.ts' +import { escapesLineClaim, opensBracketedAutolink, startsEntityReference, type LinePosition } from '../commonmark-grammar.ts' +import { delimiterFlags, isWordCharacter, matchEmphasis } from '../emphasis-matching.ts' export type EmphasisRole = 'close' | 'open' diff --git a/src/markdown-pipe-table.ts b/src/markdown/emit/pipe-table.ts similarity index 89% rename from src/markdown-pipe-table.ts rename to src/markdown/emit/pipe-table.ts index 8756a69..6439b44 100644 --- a/src/markdown-pipe-table.ts +++ b/src/markdown/emit/pipe-table.ts @@ -1,7 +1,7 @@ -import type { AdfNode } from './adf-document.ts' -import { carriesOnly } from './adf-document.ts' -import { tryPipeCell } from './markdown-inline.ts' -import type { ConvertErrorPath } from './result.ts' +import type { AdfNode } from '../../adf/document.ts' +import { carriesOnly } from '../../adf/document.ts' +import { tryPipeCell } from './inline-line.ts' +import type { ConvertErrorPath } from '../../result.ts' export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | undefined { const rows = pipeRows(node) diff --git a/src/emphasis-matching.ts b/src/markdown/emphasis-matching.ts similarity index 100% rename from src/emphasis-matching.ts rename to src/markdown/emphasis-matching.ts diff --git a/src/markdown/inline-directive-spelling.ts b/src/markdown/inline-directive-spelling.ts new file mode 100644 index 0000000..a994d2f --- /dev/null +++ b/src/markdown/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.slot === undefined ? [] : [directive.slot]) + return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) +} diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts new file mode 100644 index 0000000..b93f208 --- /dev/null +++ b/src/markdown/mark-spellings.ts @@ -0,0 +1,35 @@ +import type { AdfMark } from '../adf/document.ts' +import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts' +import type { MarkType } from '../adf/mark-attributes.ts' +import { isMarkType, markAttributes } from '../adf/mark-attributes.ts' +import { spellAttributes, spellVocabulary } from './directive-attributes.ts' +import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' + +type Spelling = { kind: 'code' | 'directive' | 'link'; spelling?: undefined } | { kind: 'emphasis'; spelling: string } + +export type MarkSpelling = Spelling & { attributes: AttributeVocabulary } + +const markSpellings: Readonly> = { + border: { kind: 'directive' }, + code: { kind: 'code' }, + em: { kind: 'emphasis', spelling: '_' }, + link: { kind: 'link' }, + strike: { kind: 'emphasis', spelling: '~~' }, + strong: { kind: 'emphasis', spelling: '**' }, + subsup: { kind: 'directive' }, + textColor: { kind: 'directive' }, + underline: { kind: 'directive' }, +} + +export function markSpelling(type: string): MarkSpelling | undefined { + if (!isMarkType(type)) return undefined + const spelling = markSpellings[type] + const attributes = markAttributes[type] + if (spelling.kind === 'emphasis') return { attributes, kind: spelling.kind, spelling: spelling.spelling } + return { attributes, kind: spelling.kind } +} + +export function spellMarkAttributes(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined { + const pairs = vocabularyPairs(mark.attrs ?? {}, vocabulary, []) + return pairs === undefined ? undefined : spellAttributes(spellVocabulary(pairs)) +} diff --git a/src/opaque-carry.ts b/src/markdown/opaque-carry.ts similarity index 78% rename from src/opaque-carry.ts rename to src/markdown/opaque-carry.ts index 2acbd92..1e6ecd1 100644 --- a/src/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -1,10 +1,10 @@ -import type { AdfNode } from './adf-document.ts' -import type { JsonSpelling } from './canonical-json.ts' -import { failure, success, type ConvertErrorPath, type Result } from './result.ts' -import { isJsonValue } from './json-value.ts' +import type { AdfNode } from '../adf/document.ts' +import type { JsonSpelling } from '../canonical-json.ts' +import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' +import { isJsonValue } from '../json-value.ts' import { fencedCodeBlock } from './backtick-runs.ts' -import { largestNesting } from './nesting.ts' -import { serializeCanonicalJson } from './canonical-json.ts' +import { largestNesting } from '../nesting.ts' +import { serializeCanonicalJson } from '../canonical-json.ts' import { spellAttributes, spellStringAttribute } from './directive-attributes.ts' export const carryName = 'adf' diff --git a/todo.md b/todo.md index 4127b79..f457c9c 100644 --- a/todo.md +++ b/todo.md @@ -168,7 +168,7 @@ detail is settled at its own milestone. unbounded nesting actually arrives, so §11's 500 binds all three of the emitter's guards here: block depth at 3c and again at 3f's container fences, inline and mark depth at 3f and 3i, a carried value's JSON at 3j, where `isJsonValue` already bounds it. - - [ ] **3a — The hierarchy.** Mechanical, ahead of the first parser file: `src/adf/` and + - [x] **3a — The hierarchy.** Mechanical, ahead of the first parser file: `src/adf/` and `src/markdown/` (`html/` arrives with its first file, 6-7), the grammar module shared inside `markdown/`, and `emphasis-matching.ts` beside it — the parser reuses it whole, `delimiterFlags` and `matchEmphasis` taking CommonMark's own run vocabulary rather than From 559922e3270ea9a39beccc3da939dd8a2a9495ec Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 27 Aug 2026 22:37:11 +0200 Subject: [PATCH 2/3] 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. From 48dcdecceaeb874358a61c3cd012b648220ff49f Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 27 Aug 2026 22:50:55 +0200 Subject: [PATCH 3/3] Answer the stability review: guard the argument lookup, and correct the claims the split made false --- AGENTS.md | 4 ++-- src/adf/attribute-vocabulary.ts | 2 +- src/markdown/block-directive-arguments.ts | 14 ++++++++------ src/markdown/commonmark-grammar.ts | 5 ++--- todo.md | 2 +- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f88b32f..9d97f5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,8 +138,8 @@ live Atlassian APIs; property-generated ADF trees; the CommonMark spec suite aga 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. + `html/`) parts into `emit/` (ADF→format) and `parse/` (format→ADF), its root holding 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/attribute-vocabulary.ts b/src/adf/attribute-vocabulary.ts index 7e30d80..8c8fde5 100644 --- a/src/adf/attribute-vocabulary.ts +++ b/src/adf/attribute-vocabulary.ts @@ -1,7 +1,7 @@ import type { AdfAttributes } from './document.ts' import type { JsonValue } from '../json-value.ts' -export type AttributeKind = 'boolean' | 'json' | 'number' | 'string' +type AttributeKind = 'boolean' | 'json' | 'number' | 'string' export type AttributeVocabulary = Readonly> diff --git a/src/markdown/block-directive-arguments.ts b/src/markdown/block-directive-arguments.ts index ee86d95..9087549 100644 --- a/src/markdown/block-directive-arguments.ts +++ b/src/markdown/block-directive-arguments.ts @@ -1,11 +1,13 @@ import type { BlockType } from '../adf/block-directives.ts' -const blockArguments: Readonly> = { - blockTaskItem: 'state', - panel: 'panelType', - taskItem: 'state', -} satisfies Partial> +const argumentByType = new Map( + Object.entries({ + blockTaskItem: 'state', + panel: 'panelType', + taskItem: 'state', + } satisfies Partial>), +) export function blockArgument(type: string): string | undefined { - return blockArguments[type] + return argumentByType.get(type) } diff --git a/src/markdown/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts index 022b654..0d9dec1 100644 --- a/src/markdown/commonmark-grammar.ts +++ b/src/markdown/commonmark-grammar.ts @@ -1,8 +1,5 @@ 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' @@ -14,7 +11,9 @@ const bracketedAutolink = new RegExp(`^<(?:${autolinkSource})>`) const controlCharacter = new RegExp(`[${controlCharacterRange}]`) const entityReference = new RegExp(entityReferenceSource) const nullCharacter = new RegExp(nullCharacterSource) +const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/ const firstCharacterOpeners = [/^#{1,6}(?:[ \t]|$)/, /^>/, /^[*+-](?:[ \t]|$)/, /^`{3,}/, /^~{3,}/, /^:{2,}/, /^\|/] +const htmlConstructs = [/^<[!?]/, /^<\/?[A-Za-z][A-Za-z0-9-]*(?:[\s/>]|$)/, /^<[^\s<>@]+@[^\s<>@]+>/] const orderedListOpener = /^(\d{1,9})[.)](?:[ \t]|$)/ const setextUnderline = /^(?:=+|-+)$/ const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/ diff --git a/todo.md b/todo.md index 411b5e7..6135f99 100644 --- a/todo.md +++ b/todo.md @@ -189,7 +189,7 @@ detail is settled at its own milestone. HTML will want too, not a markdown spelling. **Settled** (the maintainer, 2026-08-27): `markdown/` parts here as well, into `emit/` and `parse/` with the shared set at the root — the grammar module, emphasis matching, - destination spelling, the tables' markdown halves — and `parse/` arriving with 3b's first + the tables' markdown halves — and `parse/` arriving with 3b's first file, the rule `html/` already follows. And the node tables, a second copy of `spec/flavour.md`'s prose whose mistyped attribute name degrades into a false refusal no test catches, get their guard: a test reads the spec's node sections, takes each