Part the source into adf/ and markdown/ ahead of the parser
CI / gate (push) Successful in 5s

This commit is contained in:
2026-08-27 22:16:27 +02:00
parent e560639ffb
commit e580eec09b
27 changed files with 299 additions and 234 deletions
+5 -1
View File
@@ -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 - 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 fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states
unrepresentable. 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 — - Reuse before adding; the smallest sufficient diff is the benchmark; no speculative generality —
a second consumer, or it goes. a second consumer, or it goes.
+32
View File
@@ -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<Record<string, AttributeKind>>
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 }
}
@@ -1,6 +1,4 @@
import type { AdfMark, AdfNode, AttributeVocabulary } from './adf-document.ts' import type { AttributeVocabulary } from './attribute-vocabulary.ts'
import type { JsonValue } from './json-value.ts'
import { isBareToken, spellAttributes, spellJsonAttribute, vocabularyPairs } from './directive-attributes.ts'
export type BlockDirective = { export type BlockDirective = {
argument?: string argument?: string
@@ -89,29 +87,3 @@ const blockDirectives: Readonly<Record<string, BlockDirective>> = {
export function blockDirective(type: string): BlockDirective | undefined { export function blockDirective(type: string): BlockDirective | undefined {
return Object.hasOwn(blockDirectives, type) ? blockDirectives[type] : 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 }
})
}
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { isAdfDocument } from './adf-document.ts' import { isAdfDocument } from './document.ts'
test('accepts an editor-normal document', () => { test('accepts an editor-normal document', () => {
assert.equal(isAdfDocument({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true) assert.equal(isAdfDocument({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true)
+1 -5
View File
@@ -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 AdfAttributes = { [key: string]: JsonValue }
export type AttributeKind = 'boolean' | 'json' | 'number' | 'string'
export type AttributeVocabulary = Readonly<Record<string, AttributeKind>>
export type AdfMark = { export type AdfMark = {
attrs?: AdfAttributes attrs?: AdfAttributes
type: string type: string
+32
View File
@@ -0,0 +1,32 @@
import type { AttributeVocabulary } from './attribute-vocabulary.ts'
export type InlineDirective = {
attributes: AttributeVocabulary
slot?: string
}
const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
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
}
+19
View File
@@ -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<Record<string, AttributeVocabulary>>
export type MarkType = keyof typeof markAttributes
export function isMarkType(type: string): type is MarkType {
return Object.hasOwn(markAttributes, type)
}
+2 -2
View File
@@ -4,8 +4,8 @@ import { dirname, join } from 'node:path'
import test from 'node:test' import test from 'node:test'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { adfToMarkdown } from './adf-to-markdown.ts' import { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
import { isAdfDocument } from './adf-document.ts' import { isAdfDocument } from './adf/document.ts'
import { isJsonValue } from './json-value.ts' import { isJsonValue } from './json-value.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from './canonical-json.ts'
-50
View File
@@ -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')}`)
}
+3 -3
View File
@@ -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 { ConvertError, ConvertErrorCode, Result } from './result.ts'
export type { JsonValue } from './json-value.ts' export type { JsonValue } from './json-value.ts'
export { adfToMarkdown } from './adf-to-markdown.ts' export { adfToMarkdown } from './markdown/emit/adf-to-markdown.ts'
export { isAdfDocument } from './adf-document.ts' export { isAdfDocument } from './adf/document.ts'
-63
View File
@@ -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<Record<string, InlineDirective>> = {
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<Record<string, MarkSpelling>> = {
border: { attributes: { color: 'string', size: 'number' }, kind: 'directive' },
code: { attributes: {}, kind: 'code' },
em: { attributes: {}, kind: 'emphasis', spelling: '_' },
link: { attributes: { href: 'string', title: 'string' }, kind: 'link' },
strike: { attributes: {}, kind: 'emphasis', spelling: '~~' },
strong: { attributes: {}, kind: 'emphasis', spelling: '**' },
subsup: { attributes: { type: 'string' }, kind: 'directive' },
textColor: { attributes: { color: 'string' }, kind: 'directive' },
underline: { attributes: {}, kind: 'directive' },
}
export function inlineDirective(type: string): InlineDirective | undefined {
return Object.hasOwn(inlineDirectives, type) ? inlineDirectives[type] : undefined
}
export function 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)
}
+32
View File
@@ -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 }
})
}
+37
View File
@@ -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<string> {
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<string> {
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
}
+41
View File
@@ -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')}`)
}
@@ -1,9 +1,9 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf-document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { Result } from './result.ts' import type { Result } from '../../result.ts'
import { adfToMarkdown } from './index.ts' import { adfToMarkdown } from '../../index.ts'
function document(...content: AdfNode[]): AdfDocument { function document(...content: AdfNode[]): AdfDocument {
return { content, type: 'doc', version: 1 } return { content, type: 'doc', version: 1 }
@@ -1,16 +1,17 @@
import type { AdfDocument, AdfNode } from './adf-document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockDirective } from './block-directives.ts' import type { BlockDirective } from '../../adf/block-directives.ts'
import type { JsonValue } from './json-value.ts' import type { JsonValue } from '../../json-value.ts'
import { blockDirective, spellDirectiveHeader } from './block-directives.ts' import { blockDirective } from '../../adf/block-directives.ts'
import { tryImage } from './markdown-image.ts' import { carriedBlock, carryName } from '../opaque-carry.ts'
import { emitInlineLine } from './markdown-inline.ts' import { carriesOnly, isAdfDocument } from '../../adf/document.ts'
import { tryPipeTable } from './markdown-pipe-table.ts' import { emitInlineLine } from './inline-line.ts'
import { carriedBlock, carryName } from './opaque-carry.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.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 { holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isThematicBreak } from '../commonmark-grammar.ts'
import { carriesOnly, isAdfDocument } from './adf-document.ts' import { largestNesting } from '../../nesting.ts'
import { largestNesting } from './nesting.ts' import { spellDirectiveHeader } from '../block-directive-spelling.ts'
import { fencedCodeBlock } from './backtick-runs.ts' import { tryImage } from './image.ts'
import { tryPipeTable } from './pipe-table.ts'
type BlockContainer = 'directive' | 'document' | 'list-item' type BlockContainer = 'directive' | 'document' | 'list-item'
type BlockSpelling = 'commonmark' | 'directive' type BlockSpelling = 'commonmark' | 'directive'
@@ -1,8 +1,8 @@
import type { AdfNode } from './adf-document.ts' import type { AdfNode } from '../../adf/document.ts'
import { carriesOnly } from './adf-document.ts' import { carriesOnly } from '../../adf/document.ts'
import type { ConvertErrorPath } from './result.ts' import type { ConvertErrorPath } from '../../result.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from '../../canonical-json.ts'
import { tryImageLine } from './markdown-inline.ts' import { tryImageLine } from './inline-line.ts'
const centeredMediaSingle = '{"layout":"center"}' const centeredMediaSingle = '{"layout":"center"}'
const imageAttributes = ['alt', 'type', 'url'] const imageAttributes = ['alt', 'type', 'url']
@@ -1,14 +1,17 @@
import type { AdfMark, AdfNode } from './adf-document.ts' import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { InlineDirective } from './inline-directives.ts' import type { InlineDirective } from '../../adf/inline-directives.ts'
import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './markdown-escaping.ts' import { assembleInlineLine, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts'
import { inlineDirective, markSpelling, spellInlineNodeAttributes, spellMarkAttributes } from './inline-directives.ts' import { carriedInline } from '../opaque-carry.ts'
import { largestNesting } from './nesting.ts' import { claimsLine, holdsEntityReference, holdsNullCharacter, isAutolink } from '../commonmark-grammar.ts'
import { claimsLine, holdsControlCharacter, holdsEntityReference, holdsNullCharacter, isAutolink } from './commonmark-grammar.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { carriedInline } from './opaque-carry.ts' import { inlineDirective } from '../../adf/inline-directives.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from './backtick-runs.ts' import { longestBacktickRun } from '../backtick-runs.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
import { spellAttributes, spellStringAttribute } from './directive-attributes.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[] } 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})`)] }) return success({ segments: [syntax('['), ...inner.value.segments, syntax(`](${destination.value}${spelledTitle.value})`)] })
} }
function spellDestination(href: string, path: ConvertErrorPath): Result<string> {
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<string> {
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 { function sameMark(candidate: AdfMark, mark: AdfMark): boolean {
if (candidate.type !== mark.type) return false if (candidate.type !== mark.type) return false
return serializeCanonicalJson(candidate.attrs ?? {}, 'compact') === serializeCanonicalJson(mark.attrs ?? {}, 'compact') return serializeCanonicalJson(candidate.attrs ?? {}, 'compact') === serializeCanonicalJson(mark.attrs ?? {}, 'compact')
@@ -1,5 +1,5 @@
import { escapesLineClaim, opensBracketedAutolink, startsEntityReference, type LinePosition } from './commonmark-grammar.ts' import { escapesLineClaim, opensBracketedAutolink, startsEntityReference, type LinePosition } from '../commonmark-grammar.ts'
import { delimiterFlags, isWordCharacter, matchEmphasis } from './emphasis-matching.ts' import { delimiterFlags, isWordCharacter, matchEmphasis } from '../emphasis-matching.ts'
export type EmphasisRole = 'close' | 'open' export type EmphasisRole = 'close' | 'open'
@@ -1,7 +1,7 @@
import type { AdfNode } from './adf-document.ts' import type { AdfNode } from '../../adf/document.ts'
import { carriesOnly } from './adf-document.ts' import { carriesOnly } from '../../adf/document.ts'
import { tryPipeCell } from './markdown-inline.ts' import { tryPipeCell } from './inline-line.ts'
import type { ConvertErrorPath } from './result.ts' import type { ConvertErrorPath } from '../../result.ts'
export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | undefined { export function tryPipeTable(node: AdfNode, path: ConvertErrorPath): string | undefined {
const rows = pipeRows(node) const rows = pipeRows(node)
@@ -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))
}
+35
View File
@@ -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<Record<MarkType, Spelling>> = {
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))
}
@@ -1,10 +1,10 @@
import type { AdfNode } from './adf-document.ts' import type { AdfNode } from '../adf/document.ts'
import type { JsonSpelling } from './canonical-json.ts' import type { JsonSpelling } from '../canonical-json.ts'
import { failure, success, type ConvertErrorPath, type Result } from './result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
import { isJsonValue } from './json-value.ts' import { isJsonValue } from '../json-value.ts'
import { fencedCodeBlock } from './backtick-runs.ts' import { fencedCodeBlock } from './backtick-runs.ts'
import { largestNesting } from './nesting.ts' import { largestNesting } from '../nesting.ts'
import { serializeCanonicalJson } from './canonical-json.ts' import { serializeCanonicalJson } from '../canonical-json.ts'
import { spellAttributes, spellStringAttribute } from './directive-attributes.ts' import { spellAttributes, spellStringAttribute } from './directive-attributes.ts'
export const carryName = 'adf' export const carryName = 'adf'
+1 -1
View File
@@ -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 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 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. 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 `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, inside `markdown/`, and `emphasis-matching.ts` beside it — the parser reuses it whole,
`delimiterFlags` and `matchEmphasis` taking CommonMark's own run vocabulary rather than `delimiterFlags` and `matchEmphasis` taking CommonMark's own run vocabulary rather than