3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-13 04:08:29 +01:00

Give code text no mark but code, link and annotation, in every reader

This commit is contained in:
2026-10-14 20:24:46 +02:00
committed by Lillem4n
parent 3a27b0365d
commit 278ebbbad5
15 changed files with 71 additions and 43 deletions
+8 -3
View File
@@ -1,4 +1,8 @@
import type { AdfNode } from './document.ts'
import type { AttributeVocabulary } from './attribute-vocabulary.ts'
import { nodeMarks } from './document.ts'
export const codeTextMarkTypes: readonly string[] = ['annotation', 'code', 'link']
export type InlineNodeModel = {
attributes: AttributeVocabulary
@@ -33,7 +37,8 @@ export function inlineNodeModel(type: string): InlineNodeModel | undefined {
return Object.hasOwn(inlineNodes, type) ? inlineNodes[type] : undefined
}
// The table withholds nothing from a type it does not list, such as text.
export function takesMark(type: string, markType: string): boolean {
return inlineNodeModel(type)?.markTypes.includes(markType) ?? true
// The table withholds nothing from a type it does not list, such as text; Atlassian's schema withholds from code text all but `codeTextMarkTypes`.
export function takesMark(node: AdfNode, markType: string): boolean {
if (node.type === 'text' && nodeMarks(node).some((mark) => mark.type === 'code')) return codeTextMarkTypes.includes(markType)
return inlineNodeModel(node.type)?.markTypes.includes(markType) ?? true
}
+12 -1
View File
@@ -8,7 +8,7 @@ import test from 'node:test'
import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
import { blockArgument } from '../markdown/block-directive.ts'
import { blockNodes } from '../adf/block-nodes.ts'
import { inlineNodes } from '../adf/inline-nodes.ts'
import { codeTextMarkTypes, inlineNodes } from '../adf/inline-nodes.ts'
import { markVocabularies } from '../adf/mark-attributes.ts'
type Held = Map<string, Set<AttributeKind>>
@@ -81,6 +81,17 @@ test('the inline node table gives a type exactly the mark types the ADF JSON Sch
for (const [type, model] of Object.entries(inlineNodes)) assert.deepEqual(model.markTypes, [...(marked.get(type) ?? [])].sort(), type)
})
test('code text takes exactly the mark types the ADF JSON Schemas give a code_inline_node', () => {
for (const file of schemaFiles) {
const definitions = schemaObject(schemaObject(JSON.parse(readFileSync(join(root, file), 'utf8')), file)['definitions'], `${file} definitions`)
const where = `${file} code_inline_node`
const marks = alternatives(schemaObject(definitions['code_inline_node'], where), definitions, where).flatMap((properties) => properties.get('marks') ?? [])
// allOf narrows text_node's marks, which hold every mark type, to the ones code text takes.
const narrowed = marks.map((schema) => heldMarkTypes(schema, definitions, `${where}.marks`)).filter((types) => !types.includes('every mark type'))
assert.deepEqual(narrowed.flat().sort(), codeTextMarkTypes, file)
}
})
function spelled(): Spelled[] {
return [
...Object.entries(blockNodes).map(([type, model]) => spelledType(type, model.attributes, blockArgument(type))),
@@ -289,6 +289,8 @@ test('carries an inline node holding a mark Atlassian\'s schema withholds from i
assert.equal(carried({ attrs: { id: 'b1c2', text: '@A' }, marks: [{ type: 'strong' }], type: 'mention' }), '!adf:carry{json="{\\"attrs\\":{\\"id\\":\\"b1c2\\",\\"text\\":\\"@A\\"},\\"marks\\":[{\\"type\\":\\"strong\\"}],\\"type\\":\\"mention\\"}"}\n')
assert.equal(carried({ attrs: { id: 'm1' }, marks: [{ type: 'em' }], type: 'mediaInline' }), '!adf:carry{json="{\\"attrs\\":{\\"id\\":\\"m1\\"},\\"marks\\":[{\\"type\\":\\"em\\"}],\\"type\\":\\"mediaInline\\"}"}\n')
assert.equal(carried({ attrs: { id: 'm1' }, marks: [{ attrs: { href: '/u' }, type: 'link' }], type: 'mediaInline' }), '[!adf:mediaInline{id=m1}](/u)\n')
assert.equal(carried({ marks: [{ type: 'strong' }, { type: 'code' }], text: 'x', type: 'text' }), '!adf:carry{json="{\\"marks\\":[{\\"type\\":\\"strong\\"},{\\"type\\":\\"code\\"}],\\"text\\":\\"x\\",\\"type\\":\\"text\\"}"}\n')
assert.equal(carried({ marks: [{ attrs: { href: '/u' }, type: 'link' }, { type: 'code' }], text: 'x', type: 'text' }), '[`x`](/u)\n')
})
test('spells a heading level no ATX heading fits as a directive', () => {
+2 -2
View File
@@ -223,10 +223,10 @@ function nodePath(context: InlineContext, index: number): ConvertErrorPath {
// Decides the text-node, withheld-mark and slot-fault carries up front, so a line holding many of them is emitted once.
function carries(node: AdfNode, carried: ReadonlySet<number>, index: number): boolean {
if (carried.has(index)) return true
if (carried.has(index) || nodeMarks(node).some((mark) => !takesMark(node, mark.type))) 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) => !takesMark(node.type, mark.type))) return true
if (model === undefined) 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
}
+1 -1
View File
@@ -292,7 +292,7 @@ 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 marksItem(item: Inline, type: string): boolean {
return !isNode(item) || takesMark(item.type, type)
return !isNode(item) || takesMark(item, type)
}
function isNode(item: Inline): item is AdfNode {
@@ -931,6 +931,18 @@ test('gives an inline node no mark Atlassian\'s schema withholds from it', () =>
assert.equal(read('!adf:link[!adf:status[Done]{color=green}]{collection=c href="/u"}\n'), 'unsupported-node-shape: the link mark wraps the [content] it marks: this one wraps only nodes that take no link mark: write the link mark around text beside them')
})
test('gives code text no mark but code, link and annotation', () => {
const code: AdfMark = { type: 'code' }
for (const read of [commonMarkToAdf, losslessMarkdownToAdf, portableMarkdownToAdf]) {
assert.deepEqual(content(read('**`npm ci`**\n')), [{ content: [marked('npm ci', code)], type: 'paragraph' }])
assert.deepEqual(content(read('*run `npm ci`*\n')), [{ content: [marked('run ', em), marked('npm ci', code)], type: 'paragraph' }])
assert.deepEqual(content(read('**[`npm ci`](/u)**\n')), [{ content: [marked('npm ci', link('/u'), code)], type: 'paragraph' }])
}
assert.deepEqual(content(portableMarkdownToAdf('~~`npm ci`~~\n')), [{ content: [marked('npm ci', code)], type: 'paragraph' }])
assert.deepEqual(content(losslessMarkdownToAdf('!adf:underline[run `npm ci`]\n')), [{ content: [marked('run ', underline), marked('npm ci', code)], type: 'paragraph' }])
assert.equal(content(losslessMarkdownToAdf('!adf:underline[`npm ci`]\n')), 'unsupported-node-shape: the underline mark wraps the [content] it marks: this one wraps only nodes that take no underline mark: write the underline mark around text beside them')
})
test('decodes the fenced info string the block walk leaves raw', () => {
assert.deepEqual(content(losslessMarkdownToAdf('```java&#8203;script\nx\n```\n')), [
{ attrs: { language: 'java\u200bscript' }, content: [text('x')], type: 'codeBlock' },
@@ -307,10 +307,11 @@ test('keeps a table as a pipe table headed by its first row, one line per cell',
assert.equal(portable(node('table', {}, row())), '')
})
test('keeps code, em, link, strike and strong and drops every other mark, keeping its text', () => {
test('keeps code, em, link, strike and strong and drops every other mark, and code text every mark but link, keeping its text', () => {
const marks: AdfMark[] = [{ type: 'strike' }, { attrs: { type: 'sub' }, type: 'subsup' }, { type: 'underline' }, { attrs: { color: '#f00' }, type: 'textColor' }]
assert.equal(portable(paragraph(text('H'), text('2', ...marks), text('O', em, strong), text('!', { attrs: { size: 1 }, type: 'border' }))), 'H~~2~~_**O**_!\n')
assert.equal(portable(paragraph(text('x', code, strong), text(' '), text('y', { attrs: { x: 1 }, type: 'strong' }), text('z', em, em))), '**`x`** **y**_z_\n')
assert.equal(portable(paragraph(text('x', code, strong), text(' '), text('y', { attrs: { x: 1 }, type: 'strong' }), text('z', em, em))), '`x` **y**_z_\n')
assert.equal(portable(paragraph(text('x', strong, link('/u'), code))), '[`x`](/u)\n')
assert.equal(portable(paragraph(text('site', { attrs: { collection: 'c', href: 'https://e.com', id: 'i' }, type: 'link' }))), '[site](https://e.com)\n')
})
+2 -2
View File
@@ -2,6 +2,7 @@ import type { AdfAttributes, AdfMark, AdfNode } from '../../adf/document.ts'
import type { LineContainer } from '../line-container.ts'
import type { MarkRun } from '../emit/line-escaping.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { codeTextMarkTypes } from '../../adf/inline-nodes.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts'
import { largestNesting } from '../../nesting.ts'
@@ -141,9 +142,8 @@ function portableMarks(marks: readonly AdfMark[], container: LineContainer, text
if (portable !== undefined) kept.push(portable)
}
const rank = (mark: AdfMark): number => (mark.type === highlight ? 0 : mark.type === 'code' ? 2 : 1)
// The reader highlights no code, as Atlassian's schema allows none.
const code = kept.some((mark) => mark.type === 'code')
return kept.filter((mark) => !code || mark.type !== highlight).sort((first, second) => rank(first) - rank(second))
return kept.filter((mark) => !code || codeTextMarkTypes.includes(mark.type)).sort((first, second) => rank(first) - rank(second))
}
export function portableLink(mark: AdfMark, container: LineContainer): AdfMark | undefined {