Part the source into adf/ and markdown/ ahead of the parser #30

Merged
lilleman merged 3 commits from tick-3a into main 2026-08-27 22:51:38 +02:00
29 changed files with 448 additions and 339 deletions
+8 -1
View File
@@ -137,7 +137,14 @@ 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`). - `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 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
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'
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 }
}
+93
View File
@@ -0,0 +1,93 @@
import type { AttributeVocabulary } from './attribute-vocabulary.ts'
export type BlockDirective = {
attributes: AttributeVocabulary
contentModel: 'block' | 'code' | 'inline' | 'none'
}
const cellAttributes: AttributeVocabulary = {
background: 'string',
colspan: 'number',
colwidth: 'json',
localId: 'string',
rowspan: 'number',
valign: 'string',
}
const expandAttributes: AttributeVocabulary = { localId: 'string', title: 'string' }
const extensionAttributes: AttributeVocabulary = {
extensionKey: 'string',
extensionType: 'string',
layout: 'string',
localId: 'string',
parameters: 'json',
text: 'string',
}
const localIdAttributes: AttributeVocabulary = { localId: 'string' }
const mediaAttributes: AttributeVocabulary = {
alt: 'string',
collection: 'string',
height: 'number',
id: 'string',
localId: 'string',
occurrenceKey: 'string',
type: 'string',
url: 'string',
width: 'number',
}
const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' }
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' },
contentModel: 'code',
},
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: {
attributes: { localId: 'string', panelColor: 'string', panelIcon: 'string', panelIconId: 'string', panelIconText: 'string' },
contentModel: '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<Record<string, BlockDirective>>
export type BlockType = keyof typeof blockDirectives
export function blockDirective(type: string): BlockDirective | undefined {
return isBlockType(type) ? blockDirectives[type] : undefined
}
function isBlockType(type: string): type is BlockType {
return Object.hasOwn(blockDirectives, 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
textAttribute?: string
}
const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
date: { attributes: { localId: 'string', timestamp: 'string' } },
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: {
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' }, textAttribute: 'text' },
status: { attributes: { color: 'string', localId: 'string', style: 'string' }, textAttribute: '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)
}
-117
View File
@@ -1,117 +0,0 @@
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'
export type BlockDirective = {
argument?: string
attributes: AttributeVocabulary
body: 'block' | 'code' | 'inline' | 'none'
}
const cellAttributes: AttributeVocabulary = {
background: 'string',
colspan: 'number',
colwidth: 'json',
localId: 'string',
rowspan: 'number',
valign: 'string',
}
const expandAttributes: AttributeVocabulary = { localId: 'string', title: 'string' }
const extensionAttributes: AttributeVocabulary = {
extensionKey: 'string',
extensionType: 'string',
layout: 'string',
localId: 'string',
parameters: 'json',
text: 'string',
}
const localIdAttributes: AttributeVocabulary = { localId: 'string' }
const mediaAttributes: AttributeVocabulary = {
alt: 'string',
collection: 'string',
height: 'number',
id: 'string',
localId: 'string',
occurrenceKey: 'string',
type: 'string',
url: 'string',
width: 'number',
}
const syncBlockAttributes: AttributeVocabulary = { localId: 'string', resourceId: 'string' }
const blockDirectives: Readonly<Record<string, BlockDirective>> = {
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' },
codeBlock: {
attributes: { hideLineNumbers: 'boolean', language: 'string', localId: 'string', uniqueId: 'string', wrap: 'boolean' },
body: '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' },
panel: {
argument: 'panelType',
attributes: { localId: 'string', panelColor: 'string', panelIcon: 'string', panelIconId: 'string', panelIconText: 'string' },
body: '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' },
}
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 }
})
}
+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)
}
+13
View File
@@ -0,0 +1,13 @@
import type { BlockType } from '../adf/block-directives.ts'
const argumentByType = new Map(
Object.entries({
blockTaskItem: 'state',
panel: 'panelType',
taskItem: 'state',
} satisfies Partial<Record<BlockType, string>>),
)
export function blockArgument(type: string): string | undefined {
return argumentByType.get(type)
}
@@ -11,7 +11,9 @@ const bracketedAutolink = new RegExp(`^<(?:${autolinkSource})>`)
const controlCharacter = new RegExp(`[${controlCharacterRange}]`) const controlCharacter = new RegExp(`[${controlCharacterRange}]`)
const entityReference = new RegExp(entityReferenceSource) const entityReference = new RegExp(entityReferenceSource)
const nullCharacter = new RegExp(nullCharacterSource) const nullCharacter = new RegExp(nullCharacterSource)
const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/
const firstCharacterOpeners = [/^#{1,6}(?:[ \t]|$)/, /^>/, /^[*+-](?:[ \t]|$)/, /^`{3,}/, /^~{3,}/, /^:{2,}/, /^\|/] 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 orderedListOpener = /^(\d{1,9})[.)](?:[ \t]|$)/
const setextUnderline = /^(?:=+|-+)$/ const setextUnderline = /^(?:=+|-+)$/
const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/ const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/
@@ -42,6 +44,10 @@ export function holdsNullCharacter(text: string): boolean {
return nullCharacter.test(text) return nullCharacter.test(text)
} }
export function isAsciiPunctuation(character: string): boolean {
return asciiPunctuation.test(character)
}
export function isAutolink(text: string): boolean { export function isAutolink(text: string): boolean {
return autolink.test(text) return autolink.test(text)
} }
@@ -58,6 +64,10 @@ export function opensBracketedAutolink(text: string): boolean {
return bracketedAutolink.test(text) return bracketedAutolink.test(text)
} }
export function opensHtmlConstruct(text: string): boolean {
return htmlConstructs.some((construct) => construct.test(text))
}
export function startsEntityReference(text: string): boolean { export function startsEntityReference(text: string): boolean {
return anchoredEntityReference.test(text) return anchoredEntityReference.test(text)
} }
+46
View File
@@ -0,0 +1,46 @@
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_-]+$/
const inlineDirectiveOpener = /^:[a-z][A-Za-z0-9]*[[{]/
// spec/flavour.md, Attributes.
const quotedEscapes = /[&<`|]/g
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}`)
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'
@@ -116,14 +117,14 @@ function commonMarkText(text: string): EmittedBlock {
function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> { function emitDirectiveBlock(node: AdfNode, directive: BlockDirective, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path) if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text`, path)
const content = node.content ?? [] 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.contentModel === '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 === 'code') return emitCodeDirective(node, directive, path)
const header = spellDirectiveHeader(node, directive) const header = spellDirectiveHeader(node, directive)
if (header === undefined) return commonMarkLine(carriedBlock(node, path)) 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}` }) 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 if (!body.ok) return body
const fenceColons = Math.max(3, body.value.fenceColons + 1) const fenceColons = Math.max(3, body.value.fenceColons + 1)
const fence = ':'.repeat(fenceColons) const fence = ':'.repeat(fenceColons)
@@ -0,0 +1,34 @@
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 argumentAttribute = blockArgument(node.type)
const argument = spellArgument(node, argumentAttribute)
if (argument === undefined) return undefined
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...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, 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}`
}
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
}
@@ -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']
@@ -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))
}
@@ -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[] }
@@ -206,7 +209,7 @@ function emitInlineDirective(node: AdfNode, directive: InlineDirective, index: n
if (!empty.ok) return empty if (!empty.ok) return empty
const attributes = spellInlineNodeAttributes(node, directive) const attributes = spellInlineNodeAttributes(node, directive)
if (attributes === undefined) return success({ carry: { first: index, last: index } }) 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 (slot === undefined) return success({ segments: [syntax(spellLeafDirective(node.type, attributes))] })
if (typeof slot !== 'string') return success({ carry: { first: index, last: index } }) 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) if (/[\n\r]/.test(slot)) return failure('unspellable-whitespace', `a ${node.type} content slot holds a newline no inline directive spans`, path)
@@ -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,13 @@
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' import {
escapesLineClaim,
isAsciiPunctuation,
opensBracketedAutolink,
opensHtmlConstruct,
startsEntityReference,
type LinePosition,
} from '../commonmark-grammar.ts'
import { opensInlineDirective } from '../directive-attributes.ts'
export type EmphasisRole = 'close' | 'open' export type EmphasisRole = 'close' | 'open'
@@ -21,9 +29,6 @@ type EmittedRun = { canClose: boolean; canOpen: boolean; character: string; deli
const delimiters = ['*', '_', '`', '~'] 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 = /[([:]/ const followsLinkText = /[([:]/
export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine { export function assembleInlineLine(segments: readonly InlineSegment[], container: LineContainer): AssembledLine {
@@ -211,10 +216,10 @@ function claimsCharacter(
const rest = scan.slice(index) const rest = scan.slice(index)
if (inBrackets && (character === '[' || character === ']')) return true if (inBrackets && (character === '[' || character === ']')) return true
if (character === '|') return container === 'table-cell' 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 startsEntityReference(rest)
if (character === '<') return opensBracketedAutolink(rest) || htmlConstructs.some((construct) => construct.test(rest)) if (character === '<') return opensBracketedAutolink(rest) || opensHtmlConstruct(rest)
if (character === ':') return inlineDirectiveOpener.test(rest) if (character === ':') return opensInlineDirective(rest)
if (character === '[') return opensLink(scan, escapings, index) if (character === '[') return opensLink(scan, escapings, index)
if (character === '`') return opensCodeSpan(scan, index, escaped) if (character === '`') return opensCodeSpan(scan, index, escaped)
if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, escaped) if (character === '*' || character === '_' || character === '~') return claimsEmphasis(scan, index, escaped)
@@ -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)
+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'
+9 -7
View File
@@ -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 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 owes the carry a trigger). One mark vocabulary lands here, before 2e3 changes the
attribute spelling: `emphasisSpellings`, `linkAttributes` and the `code`/`link` names join 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 the mark table (3a parted it across `adf/mark-attributes.ts` and
literals in the emitter — and the parser (3) needs every name to make `:em[x]` the named `markdown/mark-spellings.ts`), which holds four of the nine marks while the rest are
error `spec/flavour.md` promises. 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.** - [x] **2e3 — Attribute canonicalization and the quoted value's escape.**
**Settled** (the maintainer, 2026-08-26): a quoted attribute value escapes `` ` ``, `&`, **Settled** (the maintainer, 2026-08-26): a quoted attribute value escapes `` ` ``, `&`,
`<` and `|` as `\u0060`, `\u0026`, `\u003c` and `\u007c`, in every directive, block and `<` and `|` as `\u0060`, `\u0026`, `\u003c` and `\u007c`, in every directive, block and
@@ -168,7 +169,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
@@ -188,7 +189,7 @@ detail is settled at its own milestone.
HTML will want too, not a markdown spelling. HTML will want too, not a markdown spelling.
**Settled** (the maintainer, 2026-08-27): `markdown/` parts here as well, into `emit/` and **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, `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 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 `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 test catches, get their guard: a test reads the spec's node sections, takes each
@@ -225,8 +226,9 @@ detail is settled at its own milestone.
entity references decoding to their characters, code spans and the literal they hold — 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 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, 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` comment and processing instruction refused by name, recognized by the
`markdown-escaping.ts` already escapes against, under 3b's one-table rule. `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`, - [ ] **3e — Emphasis and links.** `_`, `*` and `~~` runs through `matchEmphasis` to the `em`,
`strong` and `strike` marks; links inline and reference, 3b's definitions resolved here, `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. autolinks, and the image gap's named errors — a titled image, and one amid other text.