Part the source into adf/ and markdown/ ahead of the parser #30
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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,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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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'
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user