This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import type { AdfAttributes } from './document.ts'
|
||||
import type { JsonValue } from '../json-value.ts'
|
||||
|
||||
export type AttributeKind = 'boolean' | 'json' | 'number' | 'string'
|
||||
|
||||
export type AttributeVocabulary = Readonly<Record<string, AttributeKind>>
|
||||
|
||||
export type VocabularyPair =
|
||||
| { key: string; kind: 'boolean'; value: boolean }
|
||||
| { key: string; kind: 'json'; value: JsonValue }
|
||||
| { key: string; kind: 'number'; value: number }
|
||||
| { key: string; kind: 'string'; value: string }
|
||||
|
||||
export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, spelledElsewhere: readonly string[]): VocabularyPair[] | undefined {
|
||||
const pairs: VocabularyPair[] = []
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
if (spelledElsewhere.includes(key)) continue
|
||||
const kind = Object.hasOwn(vocabulary, key) ? vocabulary[key] : undefined
|
||||
if (kind === undefined) return undefined
|
||||
const pair = vocabularyPair(key, value, kind)
|
||||
if (pair === undefined) return undefined
|
||||
pairs.push(pair)
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
function vocabularyPair(key: string, value: JsonValue, kind: AttributeKind): VocabularyPair | undefined {
|
||||
if (kind === 'boolean') return typeof value === 'boolean' ? { key, kind, value } : undefined
|
||||
if (kind === 'number') return typeof value === 'number' ? { key, kind, value } : undefined
|
||||
if (kind === 'string') return typeof value === 'string' ? { key, kind, value } : undefined
|
||||
return { key, kind, value }
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { AttributeVocabulary } from './attribute-vocabulary.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
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { isAdfDocument } from './document.ts'
|
||||
|
||||
test('accepts an editor-normal document', () => {
|
||||
assert.equal(isAdfDocument({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true)
|
||||
assert.equal(isAdfDocument({ type: 'doc', version: 1 }), true)
|
||||
})
|
||||
|
||||
test('rejects anything that is not a doc node', () => {
|
||||
assert.equal(isAdfDocument(null), false)
|
||||
assert.equal(isAdfDocument([]), false)
|
||||
assert.equal(isAdfDocument('doc'), false)
|
||||
assert.equal(isAdfDocument({ type: 'paragraph', version: 1 }), false)
|
||||
assert.equal(isAdfDocument({ type: 'doc' }), false)
|
||||
assert.equal(isAdfDocument({ type: 'doc', version: Number.NaN }), false)
|
||||
assert.equal(isAdfDocument({ extra: 1, type: 'doc', version: 1 }), false)
|
||||
})
|
||||
|
||||
test('rejects a node whose shape ProseMirror JSON cannot hold', () => {
|
||||
assert.equal(isAdfDocument({ content: [{ type: 1 }], type: 'doc', version: 1 }), false)
|
||||
assert.equal(isAdfDocument({ content: [{ text: 1, type: 'text' }], type: 'doc', version: 1 }), false)
|
||||
assert.equal(isAdfDocument({ content: [{ node: 'x', type: 'paragraph' }], type: 'doc', version: 1 }), false)
|
||||
assert.equal(isAdfDocument({ content: [{ content: {}, type: 'paragraph' }], type: 'doc', version: 1 }), false)
|
||||
assert.equal(isAdfDocument({ content: [{ marks: [{ type: 1 }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), false)
|
||||
assert.equal(isAdfDocument({ content: [{ attrs: { a: Number.POSITIVE_INFINITY }, type: 'paragraph' }], type: 'doc', version: 1 }), false)
|
||||
assert.equal(isAdfDocument({ content: [{ attrs: [], type: 'paragraph' }], type: 'doc', version: 1 }), false)
|
||||
})
|
||||
|
||||
test('accepts the JSON values an attribute may hold', () => {
|
||||
assert.equal(isAdfDocument({ content: [{ attrs: { a: [1, 'x', null, true, { b: 2 }] }, type: 'paragraph' }], type: 'doc', version: 1 }), true)
|
||||
assert.equal(isAdfDocument({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false)
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { isJsonValue, type JsonValue } from '../json-value.ts'
|
||||
|
||||
export type AdfAttributes = { [key: string]: JsonValue }
|
||||
|
||||
export type AdfMark = {
|
||||
attrs?: AdfAttributes
|
||||
type: string
|
||||
}
|
||||
|
||||
export type AdfNode = {
|
||||
attrs?: AdfAttributes
|
||||
content?: AdfNode[]
|
||||
marks?: AdfMark[]
|
||||
text?: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export type AdfDocument = {
|
||||
content?: AdfNode[]
|
||||
type: 'doc'
|
||||
version: number
|
||||
}
|
||||
|
||||
const documentKeys = ['content', 'type', 'version']
|
||||
const markKeys = ['attrs', 'type']
|
||||
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
|
||||
|
||||
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
|
||||
if ((node.marks ?? []).length > 0 || node.text !== undefined) return false
|
||||
return holdsOnly(node.attrs ?? {}, attributes)
|
||||
}
|
||||
|
||||
export function isAdfDocument(value: unknown): value is AdfDocument {
|
||||
if (!isRecord(value) || !holdsOnly(value, documentKeys)) return false
|
||||
if (value['type'] !== 'doc') return false
|
||||
if (typeof value['version'] !== 'number' || !Number.isFinite(value['version'])) return false
|
||||
return !('content' in value) || isNodeArray(value['content'])
|
||||
}
|
||||
|
||||
function isAdfMark(value: unknown): value is AdfMark {
|
||||
if (!isRecord(value) || !holdsOnly(value, markKeys)) return false
|
||||
if (typeof value['type'] !== 'string') return false
|
||||
return !('attrs' in value) || isAttributes(value['attrs'])
|
||||
}
|
||||
|
||||
function isNodeArray(value: unknown): value is AdfNode[] {
|
||||
if (!Array.isArray(value)) return false
|
||||
const pending: unknown[] = [...value]
|
||||
while (pending.length > 0) {
|
||||
const node = pending.pop()
|
||||
if (!isRecord(node) || !holdsOnly(node, nodeKeys)) return false
|
||||
if (typeof node['type'] !== 'string') return false
|
||||
if ('attrs' in node && !isAttributes(node['attrs'])) return false
|
||||
if ('marks' in node && !isArrayOf(node['marks'], isAdfMark)) return false
|
||||
if ('text' in node && typeof node['text'] !== 'string') return false
|
||||
if ('content' in node) {
|
||||
const content = node['content']
|
||||
if (!Array.isArray(content)) return false
|
||||
pending.push(...content)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function isArrayOf<T>(value: unknown, guard: (item: unknown) => item is T): value is T[] {
|
||||
return Array.isArray(value) && [...value].every(guard)
|
||||
}
|
||||
|
||||
function isAttributes(value: unknown): value is AdfAttributes {
|
||||
return isRecord(value) && isJsonValue(value)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function holdsOnly(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
||||
return Object.keys(value).every((key) => keys.includes(key))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AttributeVocabulary } from './attribute-vocabulary.ts'
|
||||
|
||||
export type InlineDirective = {
|
||||
attributes: AttributeVocabulary
|
||||
slot?: string
|
||||
}
|
||||
|
||||
const inlineDirectives: Readonly<Record<string, InlineDirective>> = {
|
||||
date: { attributes: { localId: 'string', timestamp: 'string' } },
|
||||
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string' }, slot: 'text' },
|
||||
hardBreak: { attributes: { localId: 'string', text: 'string' } },
|
||||
inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' } },
|
||||
mediaInline: {
|
||||
attributes: {
|
||||
alt: 'string',
|
||||
collection: 'string',
|
||||
data: 'json',
|
||||
height: 'number',
|
||||
id: 'string',
|
||||
localId: 'string',
|
||||
occurrenceKey: 'string',
|
||||
type: 'string',
|
||||
width: 'number',
|
||||
},
|
||||
},
|
||||
mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', userType: 'string' }, slot: 'text' },
|
||||
status: { attributes: { color: 'string', localId: 'string', style: 'string' }, slot: 'text' },
|
||||
}
|
||||
|
||||
export function inlineDirective(type: string): InlineDirective | undefined {
|
||||
return Object.hasOwn(inlineDirectives, type) ? inlineDirectives[type] : undefined
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user