mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-14 12:30:26 +01:00
Append the reading-cost figure
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import type { AdfAttributes } from '../../adf/document.ts'
|
||||
import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts'
|
||||
import type { DirectiveAttributes } from '../directive-syntax.ts'
|
||||
import { attributeNestingMessage } from '../../adf/document.ts'
|
||||
import { attributeValue, spellAttributeValue } from '../directive-syntax.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
|
||||
export type Elsewhere = { key: string; slot: 'argument' | 'content' }
|
||||
|
||||
export function readVocabulary(
|
||||
type: string,
|
||||
attributes: DirectiveAttributes,
|
||||
vocabulary: AttributeVocabulary,
|
||||
elsewhere: Elsewhere | undefined,
|
||||
path: ConvertErrorPath,
|
||||
): Result<AdfAttributes> {
|
||||
const attrs: AdfAttributes = {}
|
||||
for (const [key, spelled] of attributes) {
|
||||
if (key === elsewhere?.key) {
|
||||
const place = elsewhere.slot === 'argument' ? 'as the directive argument' : 'in the content slot'
|
||||
return failure('unsupported-node-shape', `${type} spells its ${key} attribute ${place}, never in {attrs}`, path)
|
||||
}
|
||||
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
|
||||
if (kind === undefined) return failure('unsupported-node-shape', `${type} holds no ${key} attribute: this one spells it`, path)
|
||||
const read = attributeValue(spelled.decoded, kind)
|
||||
if (read.refusal === 'nesting') return failure('unsupported-nesting-depth', attributeNestingMessage(key, type), path)
|
||||
if (read.value === undefined) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind === 'numbers' ? 'comma list of numbers' : kind}`, path)
|
||||
const spelling = spellAttributeValue(read.value)
|
||||
if (spelling !== spelled.spelling) return failure('unsupported-node-shape', `${type} spells its ${key} attribute as ${key}=${spelling}`, path)
|
||||
attrs[key] = read.value.value
|
||||
}
|
||||
return success(attrs)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { attributeNestingMessage, nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||
import { blockArgument, marksAttribute, spellBlockMarks } from '../block-directive.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { isBareToken, spellAttributes, spellDirectiveOpener, spellVocabulary } from '../directive-syntax.ts'
|
||||
import { spellEmptyKeys } from '../empty-keys.ts'
|
||||
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
||||
|
||||
export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, spelledByBody: readonly string[] = []): Result<string> | undefined {
|
||||
const argumentAttribute = blockArgument(node.type)
|
||||
const slot = bareArgument(node, argumentAttribute)
|
||||
if (slot === undefined) return undefined
|
||||
const spelled = argumentAttribute === undefined ? spelledByBody : [argumentAttribute, ...spelledByBody]
|
||||
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, spelled)
|
||||
if (pairs === undefined) return undefined
|
||||
const spelledPairs = [...spellVocabulary(pairs), ...spellEmptyKeys(node)]
|
||||
const marks = nodeMarks(node)
|
||||
if (marks.length > 0) {
|
||||
const spelling = spellBlockMarks(marks)
|
||||
if (spelling === undefined) return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, node.type), path)
|
||||
spelledPairs.push([marksAttribute, spelling])
|
||||
}
|
||||
return success(spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs)))
|
||||
}
|
||||
|
||||
// `undefined` where the argument slot holds a value no bare token spells.
|
||||
function bareArgument(node: AdfNode, argumentAttribute: string | undefined): { argument: string | undefined } | undefined {
|
||||
const value = argumentAttribute === undefined ? undefined : nodeAttrs(node)[argumentAttribute]
|
||||
if (value === undefined) return { argument: undefined }
|
||||
return typeof value === 'string' && isBareToken(value) ? { argument: value } : undefined
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { AdfMark } from '../adf/document.ts'
|
||||
import type { BlockType } from '../adf/block-nodes.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { blockNodeModel } from '../adf/block-nodes.ts'
|
||||
import { isAdfMark } from '../adf/document.ts'
|
||||
import { markVocabularies } from '../adf/mark-attributes.ts'
|
||||
import { overNested } from '../json-value.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
import { spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellStringAttribute } from './directive-syntax.ts'
|
||||
import { spellMarkVocabulary } from './mark-spellings.ts'
|
||||
|
||||
const argumentByType = new Map(
|
||||
Object.entries({
|
||||
blockTaskItem: 'state',
|
||||
panel: 'panelType',
|
||||
taskItem: 'state',
|
||||
} satisfies Partial<Record<BlockType, string>>),
|
||||
)
|
||||
|
||||
export const documentName = 'doc'
|
||||
|
||||
// spec/flavour.md, Directives: a document holding no content key, which the empty string cannot spell.
|
||||
export const documentAttribute = { key: 'content', value: 'none' }
|
||||
|
||||
export const documentSpelling = spellDirectiveOpener(documentName, undefined, spellAttributes([[documentAttribute.key, documentAttribute.value]]))
|
||||
|
||||
export const marksAttribute = 'marks'
|
||||
|
||||
export function blockArgument(type: string): string | undefined {
|
||||
return argumentByType.get(type)
|
||||
}
|
||||
|
||||
export function blockDirectiveForm(name: string): 'container' | 'leaf' | undefined {
|
||||
if (name === documentName) return 'leaf'
|
||||
const model = blockNodeModel(name)
|
||||
if (model === undefined) return undefined
|
||||
return model.contentModel === 'none' ? 'leaf' : 'container'
|
||||
}
|
||||
|
||||
export function markValues(marks: readonly AdfMark[]): JsonValue {
|
||||
return marks.map((mark) => (mark.attrs === undefined ? { type: mark.type } : { attrs: mark.attrs, type: mark.type }))
|
||||
}
|
||||
|
||||
// `undefined` where the JSON array nests past largestNesting.
|
||||
export function spellBlockMarks(marks: readonly AdfMark[]): string | undefined {
|
||||
const named: string[] = []
|
||||
for (const mark of marks) {
|
||||
const vocabulary = markVocabularies.get(mark.type)
|
||||
const attributes = vocabulary === undefined ? undefined : spellMarkVocabulary(mark, vocabulary)
|
||||
if (attributes === undefined) return overNested(markValues(marks)) ? undefined : spellJsonAttribute(markValues(marks))
|
||||
named.push(`${mark.type}${attributes}`)
|
||||
}
|
||||
return spellStringAttribute(named.join(' '))
|
||||
}
|
||||
|
||||
export function readMarkValues(value: JsonValue): AdfMark[] | undefined {
|
||||
if (!Array.isArray(value) || value.length === 0) return undefined
|
||||
const marks: AdfMark[] = []
|
||||
for (const item of value) {
|
||||
if (!isAdfMark(item)) return undefined
|
||||
marks.push(item)
|
||||
}
|
||||
return serializeCanonicalJson(markValues(marks), 'compact') === serializeCanonicalJson(value, 'compact') ? marks : undefined
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { nodeAttrs } from '../../adf/document.ts'
|
||||
import { spellAttributes, spellVocabulary } from '../directive-syntax.ts'
|
||||
import { spellEmptyKeys } from '../empty-keys.ts'
|
||||
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
|
||||
|
||||
export function spellInlineNodeAttributes(node: AdfNode, model: InlineNodeModel): string | undefined {
|
||||
const pairs = vocabularyPairs(nodeAttrs(node), model.attributes, model.textAttribute === undefined ? [] : [model.textAttribute])
|
||||
return pairs === undefined ? undefined : spellAttributes([...spellVocabulary(pairs), ...spellEmptyKeys(node)])
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AdfMark } from '../../adf/document.ts'
|
||||
import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts'
|
||||
import type { ConvertFault } from '../../result.ts'
|
||||
import type { DirectiveAttributes } from '../directive-syntax.ts'
|
||||
import type { MarkSpelling } from '../mark-spellings.ts'
|
||||
import { directivePrefix } from '../directive-syntax.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { markSpelling } from '../mark-spellings.ts'
|
||||
import { readEmptyKeys } from '../empty-keys.ts'
|
||||
import { readVocabulary } from './directive-attributes.ts'
|
||||
|
||||
export function readDirectiveMark(name: string, attributes: DirectiveAttributes, path: ConvertErrorPath): Result<AdfMark> | undefined {
|
||||
const spelling = markSpelling(name)
|
||||
if (spelling === undefined) return undefined
|
||||
const markdown = markdownForm(spelling)
|
||||
if (markdown !== undefined) return failure('unsupported-node-shape', `${name} is spelled ${markdown}, never as a directive`, path)
|
||||
return readMarkAttributes(name, attributes, spelling.attributes, path)
|
||||
}
|
||||
|
||||
export function readMarkAttributes(type: string, attributes: DirectiveAttributes, vocabulary: AttributeVocabulary, path: ConvertErrorPath): Result<AdfMark> {
|
||||
const empty = readEmptyKeys(type, attributes, ['attrs'], false)
|
||||
if (empty.fault !== undefined) return faulted(empty.fault, path)
|
||||
const attrs = readVocabulary(type, empty.value.rest, vocabulary, undefined, path)
|
||||
if (!attrs.ok) return attrs
|
||||
if (empty.value.empty.has('attrs')) return success({ attrs: {}, type })
|
||||
return success(Object.keys(attrs.value).length === 0 ? { type } : { attrs: attrs.value, type })
|
||||
}
|
||||
|
||||
export function inlineMarkSpellingFault(name: string): ConvertFault | undefined {
|
||||
const spelling = markSpelling(name)
|
||||
if (spelling === undefined) return undefined
|
||||
const directive = `${directivePrefix}${name}[…]`
|
||||
const forms = spelling.kind === 'link' ? `[x](url) or ${directive}` : (markdownForm(spelling) ?? directive)
|
||||
return { code: 'unsupported-node-shape', message: `${name} is spelled ${forms}, never as a block directive` }
|
||||
}
|
||||
|
||||
function markdownForm(spelling: MarkSpelling): string | undefined {
|
||||
switch (spelling.kind) {
|
||||
case 'code':
|
||||
return '`x`'
|
||||
case 'directive':
|
||||
case 'link':
|
||||
return undefined
|
||||
case 'emphasis':
|
||||
return `${spelling.spelling}x${spelling.spelling}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { AdfAttributes, AdfMark, AdfNode, EmptyKey } from '../../adf/document.ts'
|
||||
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import type { ConvertFault } from '../../result.ts'
|
||||
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
|
||||
import type { Elsewhere } from './directive-attributes.ts'
|
||||
import { attributeNestingMessage, isUnmarkedSpellableText } from '../../adf/document.ts'
|
||||
import { attributePairCause, attributeValue, directivePrefix, readAttributes, readDirectiveName, unknownDirectiveFault } from '../directive-syntax.ts'
|
||||
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues, spellBlockMarks } from '../block-directive.ts'
|
||||
import { blockNodeModel } from '../../adf/block-nodes.ts'
|
||||
import { carryFencePrefix, carryName } from '../opaque-carry.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { inlineMarkSpellingFault, readMarkAttributes } from './directive-marks.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { markVocabularies } from '../../adf/mark-attributes.ts'
|
||||
import { readEmptyKeys, spellsEmpty } from '../empty-keys.ts'
|
||||
import { readVocabulary } from './directive-attributes.ts'
|
||||
import { slotFault } from '../directive-syntax.ts'
|
||||
import { textBreakName } from '../adjacent-text.ts'
|
||||
import { textDirectiveName } from '../text-directive.ts'
|
||||
|
||||
export type BlockDirectiveNode = { contentModel: BlockNodeModel['contentModel']; node: AdfNode }
|
||||
|
||||
export function readBlockDirectiveNode(
|
||||
name: string,
|
||||
argument: string | undefined,
|
||||
attributes: DirectiveAttributes,
|
||||
path: ConvertErrorPath,
|
||||
): Result<BlockDirectiveNode> {
|
||||
if (name === carryName) {
|
||||
return failure('malformed-directive', `the name ${carryName} is reserved for the opaque carry, whose block form is the ${carryFencePrefix} fence`, path)
|
||||
}
|
||||
const model = blockNodeModel(name)
|
||||
if (model === undefined) return faulted(inlineSpellingFault(name) ?? unknownDirectiveFault(name), path)
|
||||
const argumentKey = blockArgument(name)
|
||||
const spelled = attributes.get(marksAttribute)
|
||||
const empty = readEmptyKeys(name, attributes, spellsEmpty(spelled) ? ['attrs', 'content', 'marks'] : ['attrs', 'content'], argument !== undefined)
|
||||
if (empty.fault !== undefined) return faulted(empty.fault, path)
|
||||
const { rest } = empty.value
|
||||
rest.delete(marksAttribute)
|
||||
const elsewhere: Elsewhere | undefined = argumentKey === undefined ? undefined : { key: argumentKey, slot: 'argument' }
|
||||
const attrs = readVocabulary(name, rest, model.attributes, elsewhere, path)
|
||||
if (!attrs.ok) return attrs
|
||||
if (argument !== undefined) {
|
||||
if (argumentKey === undefined) return failure('unsupported-node-shape', `${name} takes no argument: this one spells one`, path)
|
||||
attrs.value[argumentKey] = argument
|
||||
}
|
||||
const marks: Result<AdfMark[] | undefined> = spelled === undefined || spellsEmpty(spelled) ? success(undefined) : readMarks(name, spelled, path)
|
||||
if (!marks.ok) return marks
|
||||
return success({ contentModel: model.contentModel, node: namedNode(name, attrs.value, marks.value, empty.value.empty) })
|
||||
}
|
||||
|
||||
export function readInlineDirectiveNode(
|
||||
name: string,
|
||||
attributes: DirectiveAttributes,
|
||||
content: readonly AdfNode[] | undefined,
|
||||
path: ConvertErrorPath,
|
||||
): Result<AdfNode> {
|
||||
const model = inlineNodeModel(name)
|
||||
if (model === undefined) return faulted(blockSpellingFault(name) ?? unknownDirectiveFault(name), path)
|
||||
const slot = model.textAttribute
|
||||
if (slot === undefined && content !== undefined) return failure('unsupported-node-shape', `${name} takes no content: this one holds some`, path)
|
||||
const elsewhere: Elsewhere | undefined = slot === undefined ? undefined : { key: slot, slot: 'content' }
|
||||
const empty = readEmptyKeys(name, attributes, ['attrs', 'content', 'marks'], slot !== undefined && content !== undefined)
|
||||
if (empty.fault !== undefined) return faulted(empty.fault, path)
|
||||
const attrs = readVocabulary(name, empty.value.rest, model.attributes, elsewhere, path)
|
||||
if (!attrs.ok) return attrs
|
||||
if (slot !== undefined && content !== undefined) {
|
||||
const text = slotText(content)
|
||||
if (text === undefined) {
|
||||
return failure('unsupported-node-shape', `the ${name} content slot holds one text node carrying neither marks, attributes nor content: this one holds something else`, path)
|
||||
}
|
||||
const fault = slotFault(name, text)
|
||||
if (fault !== undefined) return faulted(fault, path)
|
||||
attrs.value[slot] = text
|
||||
}
|
||||
return success(namedNode(name, attrs.value, undefined, empty.value.empty))
|
||||
}
|
||||
|
||||
// A name the other position spells names that spelling, never the code a later MINOR may fill (docs/decisions.md §Which code a cause takes).
|
||||
function inlineSpellingFault(name: string): ConvertFault | undefined {
|
||||
const mark = inlineMarkSpellingFault(name)
|
||||
if (mark !== undefined) return mark
|
||||
if (inlineNodeModel(name) === undefined && name !== textDirectiveName && name !== textBreakName) return undefined
|
||||
return { code: 'unsupported-node-shape', message: `${name} takes the inline form, ${directivePrefix}${name}{…}, never the block form` }
|
||||
}
|
||||
|
||||
function blockSpellingFault(name: string): ConvertFault | undefined {
|
||||
if (blockDirectiveForm(name) === undefined) return undefined
|
||||
return { code: 'unsupported-node-shape', message: `${name} takes the block form, ${directivePrefix}${name}, never the inline form` }
|
||||
}
|
||||
|
||||
// spec/flavour.md, Inline nodes: the slot is plain text, its adjacent nodes already merged.
|
||||
function slotText(content: readonly AdfNode[]): string | undefined {
|
||||
if (content.length === 0) return ''
|
||||
const only = content.length === 1 ? content[0] : undefined
|
||||
if (only === undefined || !isUnmarkedSpellableText(only) || typeof only.text !== 'string') return undefined
|
||||
return only.text
|
||||
}
|
||||
|
||||
function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath): Result<AdfMark[]> {
|
||||
const marks = spelled.decoded.startsWith('[') ? readMarkArray(type, spelled.decoded, path) : readNamedMarks(type, spelled.decoded, path)
|
||||
if (!marks.ok) return marks
|
||||
const spelling = spellBlockMarks(marks.value)
|
||||
return spelling === spelled.spelling ? marks : failure('unsupported-node-shape', `${type} spells its ${marksAttribute} attribute as ${marksAttribute}=${spelling}`, path)
|
||||
}
|
||||
|
||||
function readMarkArray(type: string, text: string, path: ConvertErrorPath): Result<AdfMark[]> {
|
||||
const read = attributeValue(text, 'json')
|
||||
if (read.refusal === 'nesting') return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, type), path)
|
||||
const marks = read.value === undefined ? undefined : readMarkValues(read.value.value)
|
||||
if (marks === undefined) {
|
||||
return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} is its marks array in canonical JSON: this one is not`, path)
|
||||
}
|
||||
return success(marks)
|
||||
}
|
||||
|
||||
function readNamedMarks(type: string, text: string, path: ConvertErrorPath): Result<AdfMark[]> {
|
||||
const marks: AdfMark[] = []
|
||||
let cursor = 0
|
||||
for (;;) {
|
||||
const mark = readNamedMark(type, text, cursor, path)
|
||||
if (!mark.ok) return mark
|
||||
marks.push(mark.value.mark)
|
||||
cursor = mark.value.end
|
||||
if (cursor === text.length) return success(marks)
|
||||
if (text.charAt(cursor) !== ' ') return failure('unsupported-node-shape', unnamedMarks(type), path)
|
||||
cursor += 1
|
||||
}
|
||||
}
|
||||
|
||||
function readNamedMark(type: string, text: string, index: number, path: ConvertErrorPath): Result<{ end: number; mark: AdfMark }> {
|
||||
const name = readDirectiveName(text, index)
|
||||
if (name === undefined) return failure('unsupported-node-shape', unnamedMarks(type), path)
|
||||
const vocabulary = markVocabularies.get(name)
|
||||
if (vocabulary === undefined) return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} names ${name}, no mark the flavour lists: write the marks as their JSON array, marks="[{\\"type\\":\\"${name}\\"}]"`, path)
|
||||
const start = index + name.length
|
||||
// These {attrs} sit inside the quoted marks value, where no directive opens, so a malformed pair takes no escape hint.
|
||||
const attributes = text.charAt(start) === '{' ? readAttributes(text, start, attributePairCause) : { value: { attributes: new Map<string, DirectiveValue>(), end: start } }
|
||||
if (attributes.fault !== undefined) {
|
||||
return failure('unsupported-node-shape', `the ${name} mark in the ${marksAttribute} attribute of ${type}: ${attributes.fault.message}`, path)
|
||||
}
|
||||
const mark = readMarkAttributes(name, attributes.value.attributes, vocabulary, path)
|
||||
return mark.ok ? success({ end: attributes.value.end, mark: mark.value }) : mark
|
||||
}
|
||||
|
||||
function unnamedMarks(type: string): string {
|
||||
return `the ${marksAttribute} attribute of ${type} names its marks one space apart, each as its type and any {attrs}: this one does not`
|
||||
}
|
||||
|
||||
function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined, empty: ReadonlySet<EmptyKey>): AdfNode {
|
||||
const node: AdfNode = Object.keys(attrs).length > 0 || empty.has('attrs') ? { attrs, type } : { type }
|
||||
if (empty.has('content')) node.content = []
|
||||
if (marks !== undefined || empty.has('marks')) node.marks = [...(marks ?? [])]
|
||||
return node
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { DirectiveAttributes, DirectiveLine } from './directive-syntax.ts'
|
||||
import { claimDirectivePrefix, readDirectiveLine, readInlineDirective } from './directive-syntax.ts'
|
||||
import { inlineSource } from './inline-tokens.ts'
|
||||
import { largestNesting } from '../nesting.ts'
|
||||
|
||||
// A pair the input spells bare decodes to itself; a quoted one names its spelling beside the decoding.
|
||||
type Pair = [string, string, string?]
|
||||
|
||||
function attributes(...pairs: Pair[]): DirectiveAttributes {
|
||||
return new Map(pairs.map(([key, decoded, spelling]) => [key, { decoded, spelling: spelling ?? decoded }]))
|
||||
}
|
||||
|
||||
function opener(name: string, argument?: string, ...pairs: Pair[]): { value: DirectiveLine } {
|
||||
return { value: { argument, attributes: attributes(...pairs), kind: 'opener', name } }
|
||||
}
|
||||
|
||||
function fault(line: string): string {
|
||||
const read = readDirectiveLine(line)
|
||||
return read?.fault === undefined ? `read ${JSON.stringify(read)}` : read.fault.message
|
||||
}
|
||||
|
||||
function inline(text: string): unknown {
|
||||
const prefix = claimDirectivePrefix(text, 0)
|
||||
if (prefix === undefined) return 'unclaimed'
|
||||
const read = readInlineDirective(inlineSource(text), prefix)
|
||||
if (read.fault !== undefined) return read.fault.message
|
||||
return { attributes: read.value.attributes, content: read.value.content, length: read.value.length, name: read.value.name }
|
||||
}
|
||||
|
||||
function spans(text: string, name: string, content: string | undefined, ...pairs: Pair[]): void {
|
||||
assert.deepEqual(inline(text), { attributes: attributes(...pairs), content, length: text.length, name })
|
||||
}
|
||||
|
||||
test('claims a prefixed line only where a closer, or a name a space or the line end follows, opens it', () => {
|
||||
assert.equal(readDirectiveLine('Part.'), undefined)
|
||||
assert.equal(readDirectiveLine(':::panel info'), undefined)
|
||||
assert.equal(readDirectiveLine(' !adf:rule'), undefined)
|
||||
assert.equal(readDirectiveLine('!adf:mention[@A]'), undefined)
|
||||
assert.equal(readDirectiveLine('!adf:hardBreak{}'), undefined)
|
||||
assert.equal(readDirectiveLine('!adf:panel{}'), undefined)
|
||||
assert.equal(readDirectiveLine('!adf:'), undefined)
|
||||
assert.equal(readDirectiveLine('!adf:/'), undefined)
|
||||
assert.equal(readDirectiveLine('!adf:Panel'), undefined)
|
||||
assert.equal(readDirectiveLine('!adf:panel\tinfo'), undefined)
|
||||
})
|
||||
|
||||
test('reads a closer as the name it closes, and nothing after the name', () => {
|
||||
assert.deepEqual(readDirectiveLine('!adf:/panel'), { value: { kind: 'closer', name: 'panel' } })
|
||||
assert.deepEqual(readDirectiveLine('!adf:/panel \t'), { value: { kind: 'closer', name: 'panel' } })
|
||||
const closer = 'a closer carries nothing after its name: this one does; \\!adf: keeps the prefix literal'
|
||||
assert.equal(fault('!adf:/panel info'), closer)
|
||||
assert.equal(fault('!adf:/panel{}'), closer)
|
||||
})
|
||||
|
||||
test('reads the opener, its argument and its attributes', () => {
|
||||
assert.deepEqual(readDirectiveLine('!adf:rule'), opener('rule'))
|
||||
assert.deepEqual(readDirectiveLine('!adf:rule \t'), opener('rule'))
|
||||
assert.deepEqual(readDirectiveLine('!adf:rule\t'), opener('rule'))
|
||||
assert.deepEqual(readDirectiveLine('!adf:taskItem TODO'), opener('taskItem', 'TODO'))
|
||||
assert.deepEqual(readDirectiveLine('!adf:media {id=a-1 type=file}'), opener('media', undefined, ['id', 'a-1'], ['type', 'file']))
|
||||
assert.deepEqual(readDirectiveLine('!adf:panel info {panelColor="#ff0000"} '), opener('panel', 'info', ['panelColor', '#ff0000', '"#ff0000"']))
|
||||
})
|
||||
|
||||
test('decodes a quoted attribute value, the escapes {attrs} reserves included', () => {
|
||||
assert.deepEqual(readDirectiveLine('!adf:extension {text="two words"}'), opener('extension', undefined, ['text', 'two words', '"two words"']))
|
||||
assert.deepEqual(
|
||||
readDirectiveLine('!adf:extension {text="a\\u0060b\\u0026c\\u003cd\\u007ce"}'),
|
||||
opener('extension', undefined, ['text', 'a`b&c<d|e', '"a\\u0060b\\u0026c\\u003cd\\u007ce"']),
|
||||
)
|
||||
assert.deepEqual(readDirectiveLine('!adf:extension {text="a\\"b\\\\c\\nd"}'), opener('extension', undefined, ['text', 'a"b\\c\nd', '"a\\"b\\\\c\\nd"']))
|
||||
assert.deepEqual(readDirectiveLine('!adf:extension {text="}{"}'), opener('extension', undefined, ['text', '}{', '"}{"']))
|
||||
})
|
||||
|
||||
test('names the {attrs} keys read out of the alphabetical order canonical form spells', () => {
|
||||
assert.equal(fault('!adf:media {type=file id=a-1}'), 'the {attrs} keys read in alphabetical order: id before type')
|
||||
assert.deepEqual(readDirectiveLine('!adf:media {id=a-1 type=file}'), opener('media', undefined, ['id', 'a-1'], ['type', 'file']))
|
||||
})
|
||||
|
||||
test('spells an empty {attrs} only where the brace itself claims the directive', () => {
|
||||
const omitted = 'an empty {attrs} is omitted unless the { itself claims the directive: this one spells {}'
|
||||
assert.equal(fault('!adf:rule {}'), omitted)
|
||||
assert.equal(fault('!adf:panel info {}'), omitted)
|
||||
assert.equal(inline('!adf:underline[a]{}'), omitted)
|
||||
spans('!adf:hardBreak{}', 'hardBreak', undefined)
|
||||
})
|
||||
|
||||
test('names the opener no spelling reads', () => {
|
||||
const shape = 'a directive opener reads a name, one bare argument and {attrs}, one space apart: this one does not; \\!adf: keeps the prefix literal'
|
||||
assert.equal(fault('!adf:panel info'), shape)
|
||||
assert.equal(fault('!adf:panel info extra'), shape)
|
||||
assert.equal(fault('!adf:panel "info"'), shape)
|
||||
assert.equal(fault('!adf:panel info{}'), shape)
|
||||
assert.equal(fault('!adf:panel {a=1} x'), shape)
|
||||
})
|
||||
|
||||
test('names the attributes no spelling reads', () => {
|
||||
assert.equal(fault('!adf:panel {a=1'), 'the {attrs} closing brace is missing')
|
||||
assert.equal(fault('!adf:panel {a="x}'), 'the {attrs} quoted value is unclosed')
|
||||
assert.equal(fault('!adf:panel {a="\\uzzzz"}'), 'the {attrs} quoted value is not a JSON string')
|
||||
assert.equal(fault('!adf:panel {a}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal')
|
||||
assert.equal(fault('!adf:panel {a=}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal')
|
||||
assert.equal(fault('!adf:panel {=1}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal')
|
||||
assert.equal(fault('!adf:panel {a=1 b=2}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal')
|
||||
assert.equal(fault('!adf:panel { a=1}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal')
|
||||
assert.equal(fault('!adf:panel {a=1 }'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal')
|
||||
assert.equal(fault('!adf:panel {a=1 a=2}'), 'the attribute key a is spelled twice')
|
||||
assert.equal(inline('!adf:mention[@A]{id}'), 'an attribute reads key=value, the value bare or double-quoted: this one does not; \\!adf: keeps the prefix literal')
|
||||
})
|
||||
|
||||
test('breaks the directive on the raw characters a quoted value spells as escapes', () => {
|
||||
assert.equal(fault('!adf:panel {a="x`y"}'), 'a raw ` inside {attrs} breaks the directive: spell it \\u0060')
|
||||
assert.equal(fault('!adf:panel {a="x&y"}'), 'a raw & inside {attrs} breaks the directive: spell it \\u0026')
|
||||
assert.equal(fault('!adf:panel {a="x<y"}'), 'a raw < inside {attrs} breaks the directive: spell it \\u003c')
|
||||
assert.equal(fault('!adf:panel {a="x|y"}'), 'a raw | inside {attrs} breaks the directive: spell it \\u007c')
|
||||
})
|
||||
|
||||
test('claims at the prefix, and reads a directive only where a bracket or a brace follows the name', () => {
|
||||
const completes = 'an unescaped !adf: completes no directive; \\!adf: keeps the prefix literal'
|
||||
assert.equal(inline('Part.'), 'unclaimed')
|
||||
assert.equal(inline(':mention[@A]'), 'unclaimed')
|
||||
assert.equal(inline('!adfx:mention[@A]'), 'unclaimed')
|
||||
assert.equal(inline('!adf:'), completes)
|
||||
assert.equal(inline('!adf:Mention[@A]'), completes)
|
||||
assert.equal(inline('!adf:mention @A'), completes)
|
||||
spans('!adf:mention[@A]', 'mention', '@A')
|
||||
spans('!adf:date{timestamp=1756080000000}', 'date', undefined, ['timestamp', '1756080000000'])
|
||||
spans('!adf:emoji[]{shortName=":tada:"}', 'emoji', '', ['shortName', ':tada:', '":tada:"'])
|
||||
spans('!adf:underline[ a ]', 'underline', ' a ')
|
||||
})
|
||||
|
||||
test('binds an inline directive as a unit, its content balancing brackets like link text', () => {
|
||||
spans('!adf:underline[a [b] c]', 'underline', 'a [b] c')
|
||||
spans('!adf:underline[a \\] b]', 'underline', 'a \\] b')
|
||||
spans('!adf:underline[a `]` b]', 'underline', 'a `]` b')
|
||||
spans('!adf:underline[a `b c]', 'underline', 'a `b c')
|
||||
spans('!adf:underline[!adf:status[x]{color=red}]', 'underline', '!adf:status[x]{color=red}')
|
||||
spans('!adf:status[x]{color=red style="bold "}', 'status', 'x', ['color', 'red'], ['style', 'bold ', '"bold "'])
|
||||
assert.deepEqual(inline('!adf:underline[a] {}'), { attributes: attributes(), content: 'a', length: 17, name: 'underline' })
|
||||
assert.deepEqual(inline('!adf:text{text=" "} and more'), { attributes: attributes(['text', ' ', '" "']), content: undefined, length: 19, name: 'text' })
|
||||
})
|
||||
|
||||
test('names the inline directive left unclosed at the end of its line', () => {
|
||||
assert.equal(inline('!adf:mention[@A'), 'an inline directive [content] is unclosed; \\!adf: keeps the prefix literal')
|
||||
assert.equal(inline('!adf:mention[@A\nB]'), 'an inline directive [content] is unclosed; \\!adf: keeps the prefix literal')
|
||||
assert.equal(inline('!adf:mention[a `b\nc` d]'), 'an inline directive [content] is unclosed; \\!adf: keeps the prefix literal')
|
||||
assert.equal(inline('!adf:underline[!adf:status[x'), 'an inline directive [content] is unclosed; \\!adf: keeps the prefix literal')
|
||||
assert.equal(inline('!adf:mention[@A]{id=1'), 'the {attrs} closing brace is missing')
|
||||
assert.equal(inline('!adf:mention{id=1'), 'the {attrs} closing brace is missing')
|
||||
assert.equal(inline('!adf:text{text="a\nb"}'), 'the {attrs} quoted value is not a JSON string')
|
||||
})
|
||||
|
||||
test('refuses inline directives nested deeper than the parser carries', () => {
|
||||
const nest = (depth: number): string => `${'!adf:underline['.repeat(depth)}x${']'.repeat(depth)}`
|
||||
spans(nest(largestNesting), 'underline', nest(largestNesting - 1))
|
||||
assert.equal(inline(nest(largestNesting + 1)), `the input nests inline directives deeper than the ${largestNesting} levels the parser carries`)
|
||||
})
|
||||
@@ -0,0 +1,354 @@
|
||||
import type { AttributeKind, VocabularyPair, VocabularyValue } from '../adf/attribute-vocabulary.ts'
|
||||
import type { CodeSpanCloser } from './commonmark/backtick-runs.ts'
|
||||
import type { ConvertFault } from '../result.ts'
|
||||
import type { InlineSource } from './inline-tokens.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
import { backslashEscape, holdsNullCharacter } from './commonmark/grammar.ts'
|
||||
import { backtickRun } from './commonmark/backtick-runs.ts'
|
||||
import { isNumberList } from '../adf/attribute-vocabulary.ts'
|
||||
import { overNested } from '../json-value.ts'
|
||||
import { largestNesting } from '../nesting.ts'
|
||||
import { parseJsonText } from '../json-text.ts'
|
||||
import { serializeCanonicalJson } from '../canonical-json.ts'
|
||||
|
||||
export type AttributeReading = { refusal: 'kind' | 'nesting'; value?: undefined } | { refusal?: undefined; value: VocabularyValue }
|
||||
|
||||
export type Attributes = { attributes: DirectiveAttributes; end: number }
|
||||
|
||||
// Proof that `!adf:` stands at `index`; only `claimDirectivePrefix` builds one.
|
||||
export type ClaimedPrefix = { readonly [claimedPrefix]: true; readonly index: number }
|
||||
|
||||
export type DirectiveValue = { decoded: string; spelling: string }
|
||||
|
||||
export type DirectiveAttributes = ReadonlyMap<string, DirectiveValue>
|
||||
|
||||
export type DirectiveLine =
|
||||
| { argument: string | undefined; attributes: DirectiveAttributes; kind: 'opener'; name: string }
|
||||
| { kind: 'closer'; name: string }
|
||||
|
||||
// spans keys index content, so the two travel together: separate them and every offset is wrong.
|
||||
export type DirectiveSpan = { attributes: DirectiveAttributes; content: string | undefined; length: number; name: string; spans: NestedSpans }
|
||||
|
||||
export type NestedSpans = ReadonlyMap<number, DirectiveSpan>
|
||||
|
||||
export type Read<T> = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T }
|
||||
|
||||
type AttributePair = { end: number; key: string; value: DirectiveValue }
|
||||
|
||||
type Content = { content: string | undefined; end: number; spans: NestedSpans }
|
||||
|
||||
type DirectiveContent = { end: number; spans: NestedSpans }
|
||||
|
||||
export const directivePrefix = '!adf:'
|
||||
|
||||
const claimedPrefix: unique symbol = Symbol('claimed prefix')
|
||||
const bareTokenSource = '[A-Za-z0-9_-]+'
|
||||
const bareRun = new RegExp(bareTokenSource, 'y')
|
||||
const bareToken = new RegExp(`^${bareTokenSource}$`)
|
||||
const directiveName = /[a-z][A-Za-z0-9]*/y
|
||||
const inlineDirectiveOpener = new RegExp(`${directivePrefix}[a-z][A-Za-z0-9]*[[{]`, 'y')
|
||||
const lineEnd = /^[ \t]*$/
|
||||
// A quoted value spells these as \uXXXX: CommonMark binds a code span, entity, HTML tag or pipe cell before a directive.
|
||||
const reservedSource = '[&<`|]'
|
||||
const quotedEscapes = new RegExp(reservedSource, 'g')
|
||||
const rawReserved = new RegExp(reservedSource)
|
||||
const noAttributes: DirectiveAttributes = new Map()
|
||||
|
||||
export const noSpans: NestedSpans = new Map()
|
||||
|
||||
export const attributePairCause = 'an attribute reads key=value, the value bare or double-quoted: this one does not'
|
||||
|
||||
export const directiveEscape = `\\${directivePrefix} keeps the prefix literal`
|
||||
|
||||
const closerFault = `a closer carries nothing after its name: this one does; ${directiveEscape}`
|
||||
const emptyFault = 'an empty {attrs} is omitted unless the { itself claims the directive: this one spells {}'
|
||||
const openerFault = `a directive opener reads a name, one bare argument and {attrs}, one space apart: this one does not; ${directiveEscape}`
|
||||
const orderFault = 'the {attrs} keys read in alphabetical order'
|
||||
const pairFault = `${attributePairCause}; ${directiveEscape}`
|
||||
const prefixFault = `an unescaped ${directivePrefix} completes no directive; ${directiveEscape}`
|
||||
|
||||
export function attributeValue(text: string, kind: AttributeKind): AttributeReading {
|
||||
if (kind === 'string') return { value: { kind, value: text } }
|
||||
if (kind === 'boolean') return text === 'true' || text === 'false' ? { value: { kind, value: text === 'true' } } : { refusal: 'kind' }
|
||||
// A JSON array reads only so the respelling check names the comma list to write.
|
||||
if (kind === 'numbers') {
|
||||
const values = text.startsWith('[') ? parseJsonText(text).value : text.split(',').map((part) => parseJsonText(part).value)
|
||||
return isNumberList(values) ? { value: { kind, value: values } } : { refusal: 'kind' }
|
||||
}
|
||||
const parsed = parseJsonText(text).value
|
||||
if (parsed === undefined) return { refusal: 'kind' }
|
||||
if (kind === 'number') return typeof parsed === 'number' ? { value: { kind, value: parsed } } : { refusal: 'kind' }
|
||||
return overNested(parsed) ? { refusal: 'nesting' } : { value: { kind, value: parsed } }
|
||||
}
|
||||
|
||||
export function claimDirectivePrefix(text: string, index: number): ClaimedPrefix | undefined {
|
||||
return claimsDirectivePrefix(text, index) ? { [claimedPrefix]: true, index } : undefined
|
||||
}
|
||||
|
||||
export function holdsDirectivePrefix(text: string): boolean {
|
||||
return text.includes(directivePrefix)
|
||||
}
|
||||
|
||||
export function isBareToken(text: string): boolean {
|
||||
return bareToken.test(text)
|
||||
}
|
||||
|
||||
export function malformedDirective(message: string): ConvertFault {
|
||||
return { code: 'malformed-directive', message }
|
||||
}
|
||||
|
||||
export function readDirectiveLine(line: string): Read<DirectiveLine> | undefined {
|
||||
if (!claimsDirectivePrefix(line, 0)) return undefined
|
||||
return line.charAt(directivePrefix.length) === '/' ? readCloserLine(line) : readOpenerLine(line)
|
||||
}
|
||||
|
||||
export function readInlineDirective(inline: InlineSource, prefix: ClaimedPrefix): Read<DirectiveSpan> {
|
||||
return readClaimedDirective(inline.text, prefix.index, 1, inline.codeSpanCloser)
|
||||
}
|
||||
|
||||
export function readSoleStringAttribute(span: DirectiveSpan, key: string): Read<string> {
|
||||
if (span.content !== undefined) return { fault: unsupportedNodeShape(`${span.name} takes no content: this one holds some`) }
|
||||
const spelled = span.attributes.get(key)
|
||||
if (spelled === undefined || span.attributes.size !== 1) return { fault: unsupportedNodeShape(`${span.name} holds one ${key} attribute alone: this one does not`) }
|
||||
const spelling = spellStringAttribute(spelled.decoded)
|
||||
if (spelling !== spelled.spelling) return { fault: unsupportedNodeShape(`${span.name} spells its ${key} attribute as ${key}=${spelling}`) }
|
||||
return { value: spelled.decoded }
|
||||
}
|
||||
|
||||
// The emitter carries a node whose slot this refuses.
|
||||
export function slotFault(type: string, text: string): ConvertFault | undefined {
|
||||
if (/[\n\r]/.test(text)) return unsupportedNodeShape(`the ${type} content slot holds a line ending, and an inline directive never spans lines: replace it with a space, or write the node as !adf:carry`)
|
||||
return holdsNullCharacter(text) ? unsupportedNodeShape(`the ${type} content slot holds a null character: write the node as !adf:carry`) : undefined
|
||||
}
|
||||
|
||||
export function spellAttributes(pairs: readonly (readonly [string, string])[]): string {
|
||||
if (pairs.length === 0) return ''
|
||||
const spelled = [...pairs].sort(([left], [right]) => keyOrder(left, right)).map(([key, value]) => `${key}=${value}`)
|
||||
return `{${spelled.join(' ')}}`
|
||||
}
|
||||
|
||||
export function spellDirectiveCloser(name: string): string {
|
||||
return `${directivePrefix}/${name}`
|
||||
}
|
||||
|
||||
export function spellDirectiveOpener(name: string, argument: string | undefined, attributes: string): string {
|
||||
return `${directivePrefix}${name}${argument === undefined ? '' : ` ${argument}`}${attributes === '' ? '' : ` ${attributes}`}`
|
||||
}
|
||||
|
||||
export function spellJsonAttribute(value: JsonValue): string {
|
||||
return quote(serializeCanonicalJson(value, 'compact'))
|
||||
}
|
||||
|
||||
export function spellInlineDirectiveOpener(name: string): string {
|
||||
return `${directivePrefix}${name}[`
|
||||
}
|
||||
|
||||
export function spellInlineLeafDirective(name: string, attributes: string): string {
|
||||
return `${directivePrefix}${name}${attributes === '' ? '{}' : attributes}`
|
||||
}
|
||||
|
||||
export function spellStringAttribute(text: string): string {
|
||||
return isBareToken(text) ? text : quote(text)
|
||||
}
|
||||
|
||||
export function spellAttributeValue(value: VocabularyValue): string {
|
||||
if (value.kind === 'boolean') return `${value.value}`
|
||||
if (value.kind === 'json') return spellJsonAttribute(value.value)
|
||||
if (value.kind === 'number') return spellStringAttribute(serializeCanonicalJson(value.value, 'compact'))
|
||||
if (value.kind === 'numbers') return spellStringAttribute(value.value.map((item) => serializeCanonicalJson(item, 'compact')).join(','))
|
||||
return spellStringAttribute(value.value)
|
||||
}
|
||||
|
||||
export function spellVocabulary(pairs: readonly VocabularyPair[]): [string, string][] {
|
||||
return pairs.map((pair): [string, string] => [pair.key, spellAttributeValue(pair)])
|
||||
}
|
||||
|
||||
export function unknownDirectiveFault(name: string): ConvertFault {
|
||||
return { code: 'unknown-directive-name', message: `the directive name ${name} reads back to no node; ${directiveEscape}` }
|
||||
}
|
||||
|
||||
export function unsupportedNodeShape(message: string): ConvertFault {
|
||||
return { code: 'unsupported-node-shape', message }
|
||||
}
|
||||
|
||||
function claimsDirectivePrefix(text: string, index: number): boolean {
|
||||
return text.startsWith(directivePrefix, index)
|
||||
}
|
||||
|
||||
function keyOrder(left: string, right: string): number {
|
||||
if (left < right) return -1
|
||||
return left > right ? 1 : 0
|
||||
}
|
||||
|
||||
function quote(text: string): string {
|
||||
return JSON.stringify(text).replace(quotedEscapes, (character) => `\\u${escapeDigits(character)}`)
|
||||
}
|
||||
|
||||
function escapeDigits(character: string): string {
|
||||
return character.charCodeAt(0).toString(16).padStart(4, '0')
|
||||
}
|
||||
|
||||
function inlineDirectiveName(text: string, index: number): string | undefined {
|
||||
inlineDirectiveOpener.lastIndex = index
|
||||
const opened = inlineDirectiveOpener.exec(text)?.[0]
|
||||
return opened === undefined ? undefined : opened.slice(directivePrefix.length, -1)
|
||||
}
|
||||
|
||||
function readCloserLine(line: string): Read<DirectiveLine> | undefined {
|
||||
const start = directivePrefix.length + 1
|
||||
const name = readDirectiveName(line, start)
|
||||
if (name === undefined) return undefined
|
||||
return lineEnd.test(line.slice(start + name.length)) ? { value: { kind: 'closer', name } } : { fault: malformedDirective(closerFault) }
|
||||
}
|
||||
|
||||
function readOpenerLine(line: string): Read<DirectiveLine> | undefined {
|
||||
const name = readDirectiveName(line, directivePrefix.length)
|
||||
if (name === undefined) return undefined
|
||||
const rest = line.slice(directivePrefix.length + name.length)
|
||||
if (!rest.startsWith(' ') && !lineEnd.test(rest)) return undefined
|
||||
const opener = readOpenerRest(rest)
|
||||
if (opener.fault !== undefined) return { fault: opener.fault }
|
||||
return { value: { argument: opener.value.argument, attributes: opener.value.attributes, kind: 'opener', name } }
|
||||
}
|
||||
|
||||
export function readDirectiveName(text: string, index: number): string | undefined {
|
||||
directiveName.lastIndex = index
|
||||
return directiveName.exec(text)?.[0]
|
||||
}
|
||||
|
||||
function readOpenerRest(rest: string): Read<{ argument: string | undefined; attributes: DirectiveAttributes }> {
|
||||
bareRun.lastIndex = 1
|
||||
const argument = bareRun.exec(rest)?.[0]
|
||||
const attributes = readOpenerAttributes(rest, argument === undefined ? 0 : 1 + argument.length)
|
||||
if (attributes.fault !== undefined) return { fault: attributes.fault }
|
||||
if (!lineEnd.test(rest.slice(attributes.value.end))) return { fault: malformedDirective(openerFault) }
|
||||
return { value: { argument, attributes: attributes.value.attributes } }
|
||||
}
|
||||
|
||||
function readOpenerAttributes(rest: string, index: number): Read<Attributes> {
|
||||
return rest.charAt(index) === ' ' ? readAttributesAt(rest, index + 1, false) : { value: { attributes: noAttributes, end: index } }
|
||||
}
|
||||
|
||||
function readNestedDirective(text: string, index: number, depth: number, codeSpanCloser: CodeSpanCloser): Read<DirectiveSpan> | undefined {
|
||||
return claimsDirectivePrefix(text, index) ? readClaimedDirective(text, index, depth, codeSpanCloser) : undefined
|
||||
}
|
||||
|
||||
function readClaimedDirective(text: string, index: number, depth: number, codeSpanCloser: CodeSpanCloser): Read<DirectiveSpan> {
|
||||
const name = inlineDirectiveName(text, index)
|
||||
if (name === undefined) return { fault: malformedDirective(prefixFault) }
|
||||
if (depth > largestNesting) {
|
||||
return { fault: { code: 'unsupported-nesting-depth', message: `the input nests inline directives deeper than the ${largestNesting} levels the parser carries` } }
|
||||
}
|
||||
const slot = readContentSlot(text, index + directivePrefix.length + name.length, depth, codeSpanCloser)
|
||||
if (slot.fault !== undefined) return { fault: slot.fault }
|
||||
const attributes = readAttributesAt(text, slot.value.end, slot.value.content === undefined)
|
||||
if (attributes.fault !== undefined) return { fault: attributes.fault }
|
||||
return { value: { attributes: attributes.value.attributes, content: slot.value.content, length: attributes.value.end - index, name, spans: slot.value.spans } }
|
||||
}
|
||||
|
||||
function readContentSlot(text: string, index: number, depth: number, codeSpanCloser: CodeSpanCloser): Read<Content> {
|
||||
if (text.charAt(index) !== '[') return { value: { content: undefined, end: index, spans: noSpans } }
|
||||
const close = readDirectiveContent(text, index + 1, depth, codeSpanCloser)
|
||||
if (close.fault !== undefined) return { fault: close.fault }
|
||||
return { value: { content: text.slice(index + 1, close.value.end), end: close.value.end + 1, spans: close.value.spans } }
|
||||
}
|
||||
|
||||
function readAttributesAt(text: string, index: number, braceClaims: boolean): Read<Attributes> {
|
||||
if (text.charAt(index) !== '{') return { value: { attributes: noAttributes, end: index } }
|
||||
const read = readAttributes(text, index, pairFault)
|
||||
if (read.fault !== undefined || braceClaims || read.value.attributes.size > 0) return read
|
||||
return { fault: malformedDirective(emptyFault) }
|
||||
}
|
||||
|
||||
// A code span, an escape and a nested directive each bind before the content's own closing bracket.
|
||||
function readDirectiveContent(text: string, start: number, depth: number, codeSpanCloser: CodeSpanCloser): Read<DirectiveContent> {
|
||||
const spans = new Map<number, DirectiveSpan>()
|
||||
let brackets = 0
|
||||
let cursor = start
|
||||
while (cursor < text.length && text.charAt(cursor) !== '\n') {
|
||||
const character = text.charAt(cursor)
|
||||
if (character === '\\') {
|
||||
cursor += backslashEscape(text, cursor) === undefined ? 1 : 2
|
||||
continue
|
||||
}
|
||||
if (character === '`') {
|
||||
const span = readCodeSpanEnd(text, cursor, codeSpanCloser)
|
||||
if (span === undefined) break
|
||||
cursor = span
|
||||
continue
|
||||
}
|
||||
const nested = readNestedDirective(text, cursor, depth + 1, codeSpanCloser)
|
||||
if (nested?.fault !== undefined) return { fault: nested.fault }
|
||||
if (nested !== undefined) {
|
||||
spans.set(cursor - start, nested.value)
|
||||
cursor += nested.value.length
|
||||
continue
|
||||
}
|
||||
if (character === ']' && brackets === 0) return { value: { end: cursor, spans } }
|
||||
if (character === '[') brackets += 1
|
||||
if (character === ']') brackets -= 1
|
||||
cursor += 1
|
||||
}
|
||||
return { fault: malformedDirective(`an inline directive [content] is unclosed; ${directiveEscape}`) }
|
||||
}
|
||||
|
||||
// `undefined` where the span crosses the line ending an inline directive may not cross.
|
||||
function readCodeSpanEnd(text: string, index: number, codeSpanCloser: CodeSpanCloser): number | undefined {
|
||||
const opener = backtickRun(text, index)
|
||||
const closer = codeSpanCloser(index + opener, opener)
|
||||
if (closer === undefined) return index + opener
|
||||
return text.slice(index, closer + opener).includes('\n') ? undefined : closer + opener
|
||||
}
|
||||
|
||||
export function readAttributes(text: string, index: number, pairMessage: string): Read<Attributes> {
|
||||
const attributes = new Map<string, DirectiveValue>()
|
||||
let cursor = index + 1
|
||||
let previous = ''
|
||||
while (cursor < text.length && text.charAt(cursor) !== '}') {
|
||||
if (attributes.size > 0) {
|
||||
if (text.charAt(cursor) !== ' ') return { fault: malformedDirective(pairMessage) }
|
||||
cursor += 1
|
||||
}
|
||||
const pair = readAttributePair(text, cursor, pairMessage)
|
||||
if (pair.fault !== undefined) return { fault: pair.fault }
|
||||
const key = pair.value.key
|
||||
if (attributes.has(key)) return { fault: malformedDirective(`the attribute key ${key} is spelled twice`) }
|
||||
if (keyOrder(previous, key) > 0) return { fault: malformedDirective(`${orderFault}: ${key} before ${previous}`) }
|
||||
previous = key
|
||||
attributes.set(key, pair.value.value)
|
||||
cursor = pair.value.end
|
||||
}
|
||||
if (text.charAt(cursor) !== '}') return { fault: malformedDirective('the {attrs} closing brace is missing') }
|
||||
return { value: { attributes, end: cursor + 1 } }
|
||||
}
|
||||
|
||||
function readAttributePair(text: string, index: number, pairMessage: string): Read<AttributePair> {
|
||||
bareRun.lastIndex = index
|
||||
const key = bareRun.exec(text)?.[0]
|
||||
if (key === undefined || text.charAt(index + key.length) !== '=') return { fault: malformedDirective(pairMessage) }
|
||||
const start = index + key.length + 1
|
||||
if (text.charAt(start) === '"') {
|
||||
const quoted = readQuotedValue(text, start)
|
||||
if (quoted.fault !== undefined) return { fault: quoted.fault }
|
||||
return { value: { end: quoted.value.end, key, value: quoted.value.value } }
|
||||
}
|
||||
bareRun.lastIndex = start
|
||||
const bare = bareRun.exec(text)?.[0]
|
||||
if (bare === undefined) return { fault: malformedDirective(pairMessage) }
|
||||
return { value: { end: start + bare.length, key, value: { decoded: bare, spelling: bare } } }
|
||||
}
|
||||
|
||||
function readQuotedValue(text: string, index: number): Read<{ end: number; value: DirectiveValue }> {
|
||||
let cursor = index + 1
|
||||
while (cursor < text.length && text.charAt(cursor) !== '"') cursor += text.charAt(cursor) === '\\' ? 2 : 1
|
||||
if (text.charAt(cursor) !== '"') return { fault: malformedDirective('the {attrs} quoted value is unclosed') }
|
||||
const spelling = text.slice(index, cursor + 1)
|
||||
const character = rawReserved.exec(spelling)?.[0]
|
||||
if (character !== undefined) {
|
||||
return { fault: malformedDirective(`a raw ${character} inside {attrs} breaks the directive: spell it \\u${escapeDigits(character)}`) }
|
||||
}
|
||||
const parsed = parseJsonText(spelling).value
|
||||
if (typeof parsed !== 'string') return { fault: malformedDirective('the {attrs} quoted value is not a JSON string') }
|
||||
return { value: { end: cursor + 1, value: { decoded: parsed, spelling } } }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { DirectiveSpan, Read } from './directive-syntax.ts'
|
||||
import { readSoleStringAttribute, spellAttributes, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
|
||||
|
||||
const name = 'text'
|
||||
const spelledRun = /^(?:[ \t]+|\n+|\0+)$/
|
||||
|
||||
export const textDirectiveName = name
|
||||
|
||||
export function spellTextDirective(text: string): string {
|
||||
return spellInlineLeafDirective(name, spellAttributes([[name, spellStringAttribute(text)]]))
|
||||
}
|
||||
|
||||
export function readTextDirective(span: DirectiveSpan): Read<string> | undefined {
|
||||
if (span.name !== name) return undefined
|
||||
const spelled = readSoleStringAttribute(span, name)
|
||||
if (spelled.fault !== undefined) return spelled
|
||||
if (!spelledRun.test(spelled.value)) return { fault: unsupportedNodeShape(`${name} spells one run of spaces and tabs, one run of newlines, or one run of null characters: this one spells none of them`) }
|
||||
return spelled
|
||||
}
|
||||
Reference in New Issue
Block a user