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