mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-14 12:30:26 +01:00
Answer architecture round 1: 2 🔴, 4 🟡; refuse a link around only nodes that take none, mark the change breaking, file code-text marks
This commit is contained in:
+13
-8
@@ -2,15 +2,15 @@ import type { AttributeVocabulary } from './attribute-vocabulary.ts'
|
||||
|
||||
export type InlineNodeModel = {
|
||||
attributes: AttributeVocabulary
|
||||
marks: readonly string[]
|
||||
markTypes: readonly string[]
|
||||
textAttribute?: string
|
||||
}
|
||||
|
||||
export const inlineNodes: Readonly<Record<string, InlineNodeModel>> = {
|
||||
date: { attributes: { localId: 'string', timestamp: 'string' }, marks: ['annotation'] },
|
||||
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, marks: ['annotation'], textAttribute: 'text' },
|
||||
hardBreak: { attributes: { localId: 'string', text: 'string' }, marks: [] },
|
||||
inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' }, marks: ['annotation'] },
|
||||
date: { attributes: { localId: 'string', timestamp: 'string' }, markTypes: ['annotation'] },
|
||||
emoji: { attributes: { id: 'string', localId: 'string', shortName: 'string', text: 'string' }, markTypes: ['annotation'], textAttribute: 'text' },
|
||||
hardBreak: { attributes: { localId: 'string', text: 'string' }, markTypes: [] },
|
||||
inlineCard: { attributes: { data: 'json', localId: 'string', url: 'string' }, markTypes: ['annotation'] },
|
||||
mediaInline: {
|
||||
attributes: {
|
||||
alt: 'string',
|
||||
@@ -23,12 +23,17 @@ export const inlineNodes: Readonly<Record<string, InlineNodeModel>> = {
|
||||
type: 'string',
|
||||
width: 'number',
|
||||
},
|
||||
marks: ['annotation', 'border', 'dataConsumer', 'link'],
|
||||
markTypes: ['annotation', 'border', 'dataConsumer', 'link'],
|
||||
},
|
||||
mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', text: 'string', userType: 'string' }, marks: ['annotation'], textAttribute: 'text' },
|
||||
status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, marks: ['annotation'], textAttribute: 'text' },
|
||||
mention: { attributes: { accessLevel: 'string', id: 'string', localId: 'string', text: 'string', userType: 'string' }, markTypes: ['annotation'], textAttribute: 'text' },
|
||||
status: { attributes: { color: 'string', localId: 'string', style: 'string', text: 'string' }, markTypes: ['annotation'], textAttribute: 'text' },
|
||||
}
|
||||
|
||||
export function inlineNodeModel(type: string): InlineNodeModel | undefined {
|
||||
return Object.hasOwn(inlineNodes, type) ? inlineNodes[type] : undefined
|
||||
}
|
||||
|
||||
// An unknown type is no inline leaf the table holds, so the table withholds nothing from it.
|
||||
export function takesMark(type: string, markType: string): boolean {
|
||||
return inlineNodeModel(type)?.markTypes.includes(markType) ?? true
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ test("the ADF JSON Schemas hold no type the tables leave unspelled, the pinned c
|
||||
|
||||
test('the inline node table gives a type exactly the mark types the ADF JSON Schemas give it, in order', () => {
|
||||
const { marked } = schemaTypes()
|
||||
for (const [type, model] of Object.entries(inlineNodes)) assert.deepEqual(model.marks, [...(marked.get(type) ?? [])].sort(), type)
|
||||
for (const [type, model] of Object.entries(inlineNodes)) assert.deepEqual(model.markTypes, [...(marked.get(type) ?? [])].sort(), type)
|
||||
})
|
||||
|
||||
function spelled(): Spelled[] {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { failure, success, type ConvertErrorPath, type Result } from '../../resu
|
||||
import { flavourClaims, highlightDelimiter } from '../portable/conventions.ts'
|
||||
import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts'
|
||||
import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
|
||||
@@ -226,7 +226,7 @@ function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): bo
|
||||
if (carried.has(index)) return true
|
||||
if (node.type === 'text') return !isSpellableText(node) || typeof node.text !== 'string' || node.text === ''
|
||||
const model = inlineNodeModel(node.type)
|
||||
if (model === undefined || nodeMarks(node).some((mark) => !model.marks.includes(mark.type))) return true
|
||||
if (model === undefined || nodeMarks(node).some((mark) => !takesMark(node.type, mark.type))) return true
|
||||
const slot = model.textAttribute === undefined ? undefined : nodeAttrs(node)[model.textAttribute]
|
||||
return typeof slot === 'string' && slotFault(node.type, slot) !== undefined && nodeContent(node).length === 0 && node.text === undefined
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { highlightDelimiter } from '../portable/conventions.ts'
|
||||
import { identicalMarks, mergeAdjacentText, nodeAttrs, nodeMarks } from '../../adf/document.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { inlineNodeModel, takesMark } from '../../adf/inline-nodes.ts'
|
||||
import { joinsWhenRead, textBreakName, textBreakSpelling } from '../adjacent-text.ts'
|
||||
import { matchEmphasis } from '../commonmark/emphasis-matching.ts'
|
||||
import { normalizeLabel, readInlineTarget, readLabel } from '../commonmark/link-syntax.ts'
|
||||
@@ -84,6 +84,7 @@ const ownMarksInMark = 'move the opaque carry, or the node spelling marks=empty,
|
||||
const editorHighlight: AdfMark = { attrs: { color: '#f8e6a0' }, type: 'backgroundColor' }
|
||||
const hreflessLink = 'the link mark spells its href: this one spells none'
|
||||
const linkInLink = 'no link wraps a link: the [content] this one marks already holds one'
|
||||
const unlinkable = 'the text of this link holds only nodes that take no link: write the link around text beside them'
|
||||
const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or <url>'
|
||||
const textBreak: TextBreak = { kind: 'textBreak' }
|
||||
|
||||
@@ -210,7 +211,7 @@ function directiveMarkPiece(scan: Scan, name: string, mark: AdfMark, slot: SlotC
|
||||
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps none`, scan.path)
|
||||
}
|
||||
if (slot.nodes.some(holdsOwnMarks)) return failure('unsupported-node-shape', ownMarksInMark, scan.path)
|
||||
if (!slot.nodes.some((node) => takesMark(node, mark.type))) {
|
||||
if (!slot.nodes.some((node) => marksItem(node, mark.type))) {
|
||||
return failure('unsupported-node-shape', `the ${name} mark wraps the [content] it marks: this one wraps only nodes that take no ${name} mark`, scan.path)
|
||||
}
|
||||
const refused = mark.type === 'link' ? refuseLinkDirective(scan, mark, slot.nodes, index) : undefined
|
||||
@@ -266,9 +267,9 @@ function mediaSingle({ alt, definition, link }: Image): AdfNode {
|
||||
return centeredImage([externalMedia(alt, definition.destination, link), ...caption])
|
||||
}
|
||||
|
||||
// ADF holds no empty text node and some nodes take no link, so the destination text carries the link ahead of the text's nodes; an empty destination leaves them alone.
|
||||
// ADF holds no empty text node and a hard break takes no link, so the destination text carries the link ahead of the text's hard breaks; an empty destination leaves the breaks alone.
|
||||
function linkTo(nodes: readonly Inline[], definition: LinkDefinition): Inline[] {
|
||||
if (nodes.some((node) => takesMark(node, 'link'))) return applyMark(nodes, linkMark(definition))
|
||||
if (nodes.some((node) => marksItem(node, 'link'))) return applyMark(nodes, linkMark(definition))
|
||||
return definition.destination === '' ? [...nodes] : [{ marks: [linkMark(definition)], text: definition.destination, type: 'text' }, ...nodes]
|
||||
}
|
||||
|
||||
@@ -290,8 +291,8 @@ function partText(items: readonly Inline[], scan: Scan): Result<AdfNode[]> {
|
||||
}
|
||||
|
||||
// spec/flavour.md, Marks: a spelling skips a node Atlassian's schema withholds its mark from.
|
||||
function takesMark(item: Inline, type: string): boolean {
|
||||
return !isNode(item) || (inlineNodeModel(item.type)?.marks.includes(type) ?? true)
|
||||
function marksItem(item: Inline, type: string): boolean {
|
||||
return !isNode(item) || takesMark(item.type, type)
|
||||
}
|
||||
|
||||
function isNode(item: Inline): item is AdfNode {
|
||||
@@ -400,7 +401,11 @@ function linkPiece(scan: Scan, inner: readonly Piece[], definition: LinkDefiniti
|
||||
const [only] = inner
|
||||
if (inner.length === 1 && only?.kind === 'image') return success({ ...only, link: definition })
|
||||
const nodes = resolveNodes(inner, scan, true)
|
||||
return nodes.ok ? success({ kind: 'nodes', nodes: linkTo(nodes.value, definition) }) : nodes
|
||||
if (!nodes.ok) return nodes
|
||||
if (!nodes.value.some((node) => marksItem(node, 'link')) && nodes.value.some((node) => isNode(node) && node.type !== 'hardBreak')) {
|
||||
return failure('unsupported-node-shape', unlinkable, scan.path)
|
||||
}
|
||||
return success({ kind: 'nodes', nodes: linkTo(nodes.value, definition) })
|
||||
}
|
||||
|
||||
// CommonMark: no link nests inside another, though an image's description holds one.
|
||||
@@ -591,7 +596,7 @@ function markType(character: string, used: number): string {
|
||||
// `*(*a*)*` builds one `em`: a node holds at most one mark of each type.
|
||||
function applyMark(nodes: readonly Inline[], mark: AdfMark): Inline[] {
|
||||
return nodes.map((node) => {
|
||||
if (!isNode(node) || !takesMark(node, mark.type)) return node
|
||||
if (!isNode(node) || !marksItem(node, mark.type)) return node
|
||||
const marks = nodeMarks(node)
|
||||
return marks.some((carried) => carried.type === mark.type) ? node : { ...node, marks: [mark, ...marks] }
|
||||
})
|
||||
|
||||
@@ -911,7 +911,6 @@ test('gives the hard break inside a mark spelling no marks, and reads a link of
|
||||
|
||||
test('gives an inline node no mark Atlassian\'s schema withholds from it', () => {
|
||||
const mention: AdfNode = { attrs: { id: 'b1c2', text: '@A' }, type: 'mention' }
|
||||
const status: AdfNode = { attrs: { color: 'green', text: 'Done' }, type: 'status' }
|
||||
const media: AdfNode = { attrs: { id: 'm1' }, type: 'mediaInline' }
|
||||
const border: AdfMark = { attrs: { color: '#091e42', size: 2 }, type: 'border' }
|
||||
const read = (markdown: string): AdfNode[] | string => content(losslessMarkdownToAdf(markdown))
|
||||
@@ -920,7 +919,9 @@ test('gives an inline node no mark Atlassian\'s schema withholds from it', () =>
|
||||
assert.deepEqual(read('!adf:underline[a !adf:emoji[🎉]{shortName=":tada:"}]\n'), [
|
||||
{ content: [marked('a ', underline), { attrs: { shortName: ':tada:', text: '🎉' }, type: 'emoji' }], type: 'paragraph' },
|
||||
])
|
||||
assert.deepEqual(read('[!adf:status[Done]{color=green}](/u)\n'), [{ content: [marked('/u', link('/u')), status], type: 'paragraph' }])
|
||||
const unlinkable = 'unsupported-node-shape: the text of this link holds only nodes that take no link: write the link around text beside them'
|
||||
assert.equal(read('[!adf:status[Done]{color=green}](/u)\n'), unlinkable)
|
||||
assert.equal(read('[!adf:status[Done]{color=green}\\\n](/u)\n'), unlinkable)
|
||||
assert.deepEqual(read('[a !adf:inlineCard{url="https://x/"}](/u)\n'), [{ content: [marked('a ', link('/u')), { attrs: { url: 'https://x/' }, type: 'inlineCard' }], type: 'paragraph' }])
|
||||
assert.deepEqual(read('_!adf:mediaInline{id=m1}_\n'), [{ content: [media], type: 'paragraph' }])
|
||||
assert.deepEqual(read('!adf:border[!adf:mediaInline{id=m1}]{color="#091e42" size=2}\n'), [{ content: [{ ...media, marks: [border] }], type: 'paragraph' }])
|
||||
|
||||
Reference in New Issue
Block a user