3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 19:58:28 +01:00

Answer architecture round 1: one owner for the image's ADF shape, the readings' decision, marker paragraphs, the exceptions the README names; items 87 filed and 56 widened

This commit is contained in:
2026-10-09 03:37:29 +02:00
committed by Lillem4n
parent 8370230fee
commit 88aef1c8c4
14 changed files with 81 additions and 52 deletions
+3 -2
View File
@@ -11,9 +11,10 @@ import { commonMarkToAdf, losslessMarkdownToAdf } from '../markdown/parse/markdo
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'corpus', 'commonmark-spec')
const checks = ['count', 'fixpoint', 'text'] as const
const kinds = ['mark-model', 'node-model', 'pending'] as const
type Check = (typeof checks)[number]
type ExceptionKind = 'mark-model' | 'node-model' | 'pending'
type ExceptionKind = (typeof kinds)[number]
type SpecExample = { example: number; html: string; markdown: string; section: string }
@@ -30,7 +31,7 @@ function isCheck(value: unknown): value is Check {
}
function isKind(value: unknown): value is ExceptionKind {
return value === 'mark-model' || value === 'node-model' || value === 'pending'
return kinds.some((kind) => kind === value)
}
function isSpecExample(value: unknown): value is SpecExample {
+8 -20
View File
@@ -1,26 +1,14 @@
import type { AdfNode } from '../../adf/document.ts'
import { holdsOnlyAttributes, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { centeredImage, externalMedia } from '../external-image.ts'
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
import { serializeCanonicalJson } from '../../canonical-json.ts'
import { tryImageLine } from './inline-line.ts'
const centeredMediaSingle = '{"layout":"center"}'
const imageAttributes = ['alt', 'type', 'url']
// An empty `alt` takes the directive form: `![](url)` reads back with no `alt`.
export function tryImage(node: AdfNode): string | undefined {
const image = imageShape(node)
if (image === undefined) return undefined
return tryImageLine(image.alt, image.url)
}
function imageShape(node: AdfNode): { alt: string | undefined; url: string } | undefined {
const content = nodeContent(node)
const media = content[0]
if (!holdsOnlyAttributes(node, ['layout']) || serializeCanonicalJson(nodeAttrs(node), 'compact') !== centeredMediaSingle) return undefined
if (media === undefined || content.length !== 1 || media.type !== 'media' || !holdsOnlyAttributes(media, imageAttributes) || nodeContent(media).length > 0) return undefined
const attrs = nodeAttrs(media)
const alt = attrs['alt']
const url = attrs['url']
if (attrs['type'] !== 'external' || typeof url !== 'string') return undefined
if (alt !== undefined && (typeof alt !== 'string' || alt === '')) return undefined
return { alt, url }
const [media] = nodeContent(node)
const { alt, url } = media === undefined ? {} : nodeAttrs(media)
if (typeof url !== 'string' || (alt !== undefined && typeof alt !== 'string') || alt === '') return undefined
const read = centeredImage([externalMedia(alt ?? '', url)])
return serializeCanonicalJson(node, 'compact') === serializeCanonicalJson(read, 'compact') ? tryImageLine(alt, url) : undefined
}
+10
View File
@@ -0,0 +1,10 @@
import type { AdfNode } from '../adf/document.ts'
// spec/flavour.md, The CommonMark image: the shape `![alt](url)` reads to, and the only one written back as it.
export function centeredImage(content: AdfNode[]): AdfNode {
return { attrs: { layout: 'center' }, content, type: 'mediaSingle' }
}
export function externalMedia(alt: string, url: string): AdfNode {
return { attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' }
}
+3 -3
View File
@@ -6,6 +6,7 @@ import type { LineContainer } from '../line-container.ts'
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark/grammar.ts'
import { backtickRun, closingBacktickRun } from '../commonmark/backtick-runs.ts'
import { centeredImage, externalMedia } from '../external-image.ts'
import { commonMarkLink, linkHref } from '../mark-spellings.ts'
import { delimiterFlags, matchEmphasis, runLength } from '../commonmark/emphasis-matching.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
@@ -309,11 +310,10 @@ function linkedAlt(piece: Piece): Piece {
}
function mediaSingle({ alt, definition, link }: Image): AdfNode {
const attrs = alt === '' ? { type: 'external', url: definition.destination } : { alt, type: 'external', url: definition.destination }
const media: AdfNode = link === undefined ? { attrs, type: 'media' } : { attrs, marks: [linkMark(link)], type: 'media' }
const media = externalMedia(alt, definition.destination)
const { title } = definition
const caption: AdfNode[] = title === undefined || title === '' ? [] : [{ content: [{ text: title, type: 'text' }], type: 'caption' }]
return { attrs: { layout: 'center' }, content: [media, ...caption], type: 'mediaSingle' }
return centeredImage([link === undefined ? media : { ...media, marks: [linkMark(link)] }, ...caption])
}
function linkMark({ destination, title }: LinkDefinition): AdfMark {
+3 -1
View File
@@ -1027,9 +1027,11 @@ test('reads an image no paragraph holds alone as its alt text, linked to its des
assert.deepEqual(firstContent('# ![a](/u)\n'), [marked('a', link('/u'))])
assert.deepEqual(firstContent('!adf:paragraph {localId=p}\n![a](/u)\n!adf:/paragraph\n'), [marked('a', link('/u'))])
assert.deepEqual(firstContent('![a ![b](/c) d\n'), [text('![a '), marked('b', link('/c')), text(' d')])
assert.deepEqual(content(losslessMarkdownToAdf('![a]\n')), [paragraph('![a]')])
assert.deepEqual(content(losslessMarkdownToAdf('a ! b\n')), [paragraph('a ! b')])
})
test('reads an image beside other content in a link as its alt text, the link\'s own text', () => {
test('reads an image inside a link not alone in its paragraph as plain alt text the link marks, dropping its own destination', () => {
assert.deepEqual(firstContent('See [![a](/u "t")](/v).\n'), [text('See '), marked('a', link('/v')), text('.')])
assert.deepEqual(firstContent('See [![](/u)](/v).\n'), [text('See '), marked('/v', link('/v')), text('.')])
assert.deepEqual(firstContent('[b ![a](/u)](/v)\n'), [marked('b a', link('/v'))])
+8 -8
View File
@@ -21,9 +21,9 @@ import { spellStringAttribute, unsupportedNodeShape } from '../directive-syntax.
import { spellsEmpty } from '../empty-keys.ts'
// A paragraph a marker opens: it shares its markdown paragraph with the marker, so no image there stands alone.
type MarkedParagraph = { kind: 'markedParagraph'; position: SourcePosition; text: string }
type MarkerParagraph = { kind: 'markerParagraph'; position: SourcePosition; text: string }
type ReadBlock = Block | MarkedParagraph
type ReadBlock = Block | MarkerParagraph
// `inExpand` is whether an expand holds the blocks, which makes a folded callout a nestedExpand.
type Reading = { claims: Claims; definitions: LinkDefinitions; inExpand: boolean; memo: SpellingMemo }
@@ -123,7 +123,7 @@ function readBlock(block: ReadBlock, reading: Reading, path: ConvertErrorPath, d
return contentNode({ attrs: { level: block.level }, type: 'heading' }, block.text, reading, path, 'heading')
case 'html':
return failure('unmappable-html', `no raw HTML converts at this version: ${block.construct}`, path)
case 'markedParagraph':
case 'markerParagraph':
return contentNode({ type: 'paragraph' }, block.text, reading, path, 'paragraph')
case 'orderedList':
return listNode({ attrs: { order: block.start }, type: 'orderedList' }, block.items, reading, path, depth)
@@ -149,8 +149,8 @@ function markerLine(text: string): { line: string; rest: string } {
return { line: (hardBreak ? line.slice(0, -1) : line).replace(/^[ \t]+/, ''), rest: lineEnd === -1 ? '' : text.slice(lineEnd + 1) }
}
function paragraphsOf(position: SourcePosition, ...texts: string[]): MarkedParagraph[] {
return texts.filter((text) => text !== '').map((text) => ({ kind: 'markedParagraph', position, text }))
function markerParagraphs(position: SourcePosition, ...texts: string[]): MarkerParagraph[] {
return texts.filter((text) => text !== '').map((text) => ({ kind: 'markerParagraph', position, text }))
}
function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
@@ -159,12 +159,12 @@ function quoteNode(blocks: readonly Block[], reading: Reading, path: ConvertErro
if (led === undefined) return containerNode({ type: 'blockquote' }, blocks, reading, path, depth)
const { folded, panelType } = led.marker
const { line, rest } = markerLine(led.text)
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...paragraphsOf(led.position, line, rest), ...body], reading, path, depth + 1))
if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...markerParagraphs(led.position, line, rest), ...body], reading, path, depth + 1))
const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.claims)
if (!title.ok) return title
const text = titleText(title.value.nodes)
const type = reading.inExpand ? 'nestedExpand' : 'expand'
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, inExpand: true }, path, depth + 1))
return filledNode(text === '' ? { type } : { attrs: { title: text }, type }, readBlocks([...markerParagraphs(led.position, rest), ...body], { ...reading, inExpand: true }, path, depth + 1))
}
// docs/decisions.md, A callout title keeps its link targets.
@@ -203,7 +203,7 @@ function bulletNode(items: readonly Block[][], reading: Reading, path: ConvertEr
}
const tasks: AdfNode[] = []
for (const [index, { marker, others, position, text }] of led.entries()) {
const read = readBlocks([...paragraphsOf(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), ...others], reading, [...path, 'content', index], depth + 1)
const read = readBlocks([...markerParagraphs(position, text.replace(/^(?:[ \t\n]|\\\n)+/, '')), ...others], reading, [...path, 'content', index], depth + 1)
if (!read.ok) return read
let beside = read.value.length
while (read.value[beside - 1]?.type === 'taskList') beside -= 1
@@ -1,6 +1,7 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { centeredImage, externalMedia } from '../external-image.ts'
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { flavourClaims, taskMarker } from './conventions.ts'
@@ -405,7 +406,5 @@ function reduceMedia(media: AdfNode, reduction: Reduction): Result<AdfNode[]> {
if (attrs['type'] !== 'external' || typeof url !== 'string') return paragraphOfNode(media, reduction)
const held = attrs['alt']
const alt = typeof held === 'string' ? oneLine(held).trim() : ''
const external: AdfNode = { attrs: alt === '' ? { type: 'external', url: writableHref(url) } : { alt, type: 'external', url: writableHref(url) }, type: 'media' }
const image: AdfNode = { attrs: { layout: 'center' }, content: [external], type: 'mediaSingle' }
return success([image])
return success([centeredImage([externalMedia(alt, writableHref(url))])])
}