mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 19:58:28 +01:00
Write an external image a link marks as [](href) in both writers
This commit is contained in:
@@ -626,6 +626,28 @@ test('spells the image form for exactly the centered external media shape', () =
|
||||
assert.equal(code(adfToLosslessMarkdown(document({ attrs: { layout: 'center' }, content: [{ ...media, text: 'x' }], type: 'mediaSingle' }))), 'unsupported-node-shape')
|
||||
})
|
||||
|
||||
test('spells a media only a link marks as the image the link wraps', () => {
|
||||
const url = 'https://example.com/moon.png'
|
||||
const linked = (alt: string | undefined, ...marks: AdfMark[]): AdfDocument =>
|
||||
document({ attrs: { layout: 'center' }, content: [{ attrs: alt === undefined ? { type: 'external', url } : { alt, type: 'external', url }, marks, type: 'media' }], type: 'mediaSingle' })
|
||||
const link = (attrs: AdfAttributes): AdfMark => ({ attrs, type: 'link' })
|
||||
const spelled = (written: AdfDocument, expected: string): void => {
|
||||
assert.equal(markdown(adfToLosslessMarkdown(written)), expected)
|
||||
assert.deepEqual(losslessMarkdownToAdf(expected), { ok: true, value: written })
|
||||
}
|
||||
spelled(linked('The moon', link({ href: 'https://example.com/docs' })), `[](https://example.com/docs)\n`)
|
||||
spelled(linked(undefined, link({ href: 'https://example.com/docs', title: 'Docs "v2"' })), `[](https://example.com/docs "Docs \\"v2\\"")\n`)
|
||||
spelled(linked('a [b] c', link({ href: '/a b' })), `[![a \\[b\\] c](${url})](</a b>)\n`)
|
||||
spelled(linked('m', link({ href: '' })), `[]()\n`)
|
||||
const fallback = (document: AdfDocument): boolean => markdown(adfToLosslessMarkdown(document)).startsWith('!adf:mediaSingle {layout=center}')
|
||||
assert.ok(fallback(linked('m', link({ href: '/d', id: 'x' }))))
|
||||
assert.ok(fallback(linked('m', link({ href: '/d' }), { attrs: { color: '#000', size: 1 }, type: 'border' })))
|
||||
assert.ok(fallback(linked('m', link({ href: 'a\\b' }))))
|
||||
assert.ok(fallback(linked('m', link({ title: 't' }))))
|
||||
const captioned: AdfNode = { attrs: { layout: 'center' }, content: [{ attrs: { alt: 'm', type: 'external', url }, type: 'media' }, { content: [{ text: 'c', type: 'text' }], type: 'caption' }], type: 'mediaSingle' }
|
||||
assert.ok(fallback(document(captioned)))
|
||||
})
|
||||
|
||||
test('spells a mediaSingle the image form does not fit as a directive', () => {
|
||||
const media: AdfNode = { attrs: { type: 'external', url: 'https://example.com/moon.png' }, type: 'media' }
|
||||
const single: AdfNode = { attrs: { layout: 'center' }, content: [media], marks: [{ type: 'border' }], type: 'mediaSingle' }
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import type { AdfNode } from '../../adf/document.ts'
|
||||
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
|
||||
import { centeredImage, externalMedia } from '../external-image.ts'
|
||||
import { nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { serializeCanonicalJson } from '../../canonical-json.ts'
|
||||
import { tryImageLine } from './inline-line.ts'
|
||||
|
||||
export function tryImage(node: AdfNode): string | undefined {
|
||||
const [media] = nodeContent(node)
|
||||
const { alt, url } = media === undefined ? {} : nodeAttrs(media)
|
||||
if (media === undefined) return undefined
|
||||
const { alt, url } = nodeAttrs(media)
|
||||
if (typeof url !== 'string' || (alt !== undefined && typeof alt !== 'string')) return undefined
|
||||
const read = centeredImage([externalMedia(alt ?? '', url)])
|
||||
return serializeCanonicalJson(node, 'compact') === serializeCanonicalJson(read, 'compact') ? tryImageLine(alt, url) : undefined
|
||||
const [mark] = nodeMarks(media)
|
||||
const link = mark === undefined ? undefined : linkDefinition(nodeAttrs(mark))
|
||||
if (mark !== undefined && link === undefined) return undefined
|
||||
const read = centeredImage([externalMedia(alt ?? '', url, link)])
|
||||
return serializeCanonicalJson(node, 'compact') === serializeCanonicalJson(read, 'compact') ? tryImageLine(alt, url, link) : undefined
|
||||
}
|
||||
|
||||
function linkDefinition({ href, title }: Readonly<Record<string, unknown>>): LinkDefinition | undefined {
|
||||
if (typeof href !== 'string') return undefined
|
||||
if (title === undefined) return { destination: href }
|
||||
return typeof title === 'string' ? { destination: href, title } : undefined
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { AdfMark, AdfNode } from '../../adf/document.ts'
|
||||
import type { InlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
|
||||
import type { WrittenFlavour } from '../portable/conventions.ts'
|
||||
import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type MarkRun, type NodeRange } from './line-escaping.ts'
|
||||
import { carriedInline } from '../opaque-carry.ts'
|
||||
import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../mark-spellings.ts'
|
||||
import { escapeUnbalanced, spellDestination } from '../commonmark/link-syntax.ts'
|
||||
import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
|
||||
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { flavourClaims, highlightDelimiter } from '../portable/conventions.ts'
|
||||
import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
@@ -67,12 +68,17 @@ export function tryPipeCell(nodes: readonly AdfNode[], path: ConvertErrorPath, f
|
||||
return emitted.value.line
|
||||
}
|
||||
|
||||
export function tryImageLine(alt: string | undefined, href: string): string | undefined {
|
||||
// `link` wraps the image as the link's whole text: `[](href)`.
|
||||
export function tryImageLine(alt: string | undefined, href: string, link?: LinkDefinition): string | undefined {
|
||||
if (alt !== undefined && (/^[ \t]|[ \t]$|[\n\r]/.test(alt) || holdsNullCharacter(alt))) return undefined
|
||||
const destination = spellDestination(href)
|
||||
if (destination === undefined) return undefined
|
||||
const description: InlineSegment[] = alt === undefined ? [] : [{ escaping: 'bracketed', text: alt }]
|
||||
return attemptLine([syntax('`)], 'paragraph', 'lossless').line
|
||||
if (link === undefined) return attemptLine([syntax('`)], 'paragraph', 'lossless').line
|
||||
const target = spellLinkTarget(link.destination, link.title)
|
||||
if (target === undefined) return undefined
|
||||
const image = [syntax('[, { escaping: 'bracketed-link-target', text: escapeUnbalanced(destination, '[', ']') } satisfies InlineSegment, syntax(')')]
|
||||
return attemptLine([...image, syntax(`](${target})`)], 'paragraph', 'lossless').line
|
||||
}
|
||||
|
||||
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result<EmittedLine> {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { AdfNode } from '../adf/document.ts'
|
||||
import type { LinkDefinition } from './commonmark/link-syntax.ts'
|
||||
import { linkMark } from './mark-spellings.ts'
|
||||
|
||||
// spec/flavour.md, The CommonMark image: `` reads to `centeredImage([externalMedia(alt, url)])`, the only shape `adfToLosslessMarkdown` writes as ``.
|
||||
export function centeredImage(content: AdfNode[]): AdfNode {
|
||||
@@ -6,6 +8,7 @@ export function centeredImage(content: AdfNode[]): AdfNode {
|
||||
}
|
||||
|
||||
// `` builds no `alt`, so a media holding an empty one takes the directive form.
|
||||
export function externalMedia(alt: string, url: string): AdfNode {
|
||||
return { attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' }
|
||||
export function externalMedia(alt: string, url: string, link?: LinkDefinition): AdfNode {
|
||||
const media: AdfNode = { attrs: alt === '' ? { type: 'external', url } : { alt, type: 'external', url }, type: 'media' }
|
||||
return link === undefined ? media : { ...media, marks: [linkMark(link)] }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts'
|
||||
import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts'
|
||||
import type { MarkType } from '../adf/mark-attributes.ts'
|
||||
import type { LinkDefinition } from './commonmark/link-syntax.ts'
|
||||
import { escapeUnbalanced, spellLinkTarget } from './commonmark/link-syntax.ts'
|
||||
import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive-syntax.ts'
|
||||
import { holdsEntityReference } from './commonmark/entity-references.ts'
|
||||
@@ -36,6 +37,10 @@ export function markSpelling(type: string): MarkSpelling | undefined {
|
||||
return { attributes, kind: spelling.kind }
|
||||
}
|
||||
|
||||
export function linkMark({ destination, title }: LinkDefinition): AdfMark {
|
||||
return { attrs: title === undefined ? { href: destination } : { href: destination, title }, type: 'link' }
|
||||
}
|
||||
|
||||
export function linkHref(attrs: AdfAttributes): string | undefined {
|
||||
const href = attrs['href']
|
||||
return typeof href === 'string' ? href : undefined
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { InlineToken } from '../inline-tokens.ts'
|
||||
import type { LineContainer } from '../line-container.ts'
|
||||
import type { LinkDefinition } from '../commonmark/link-syntax.ts'
|
||||
import { centeredImage, externalMedia } from '../external-image.ts'
|
||||
import { commonMarkLink, linkHref } from '../mark-spellings.ts'
|
||||
import { commonMarkLink, linkHref, linkMark } from '../mark-spellings.ts'
|
||||
import { decodeTextEscapes, trimTrailingSpace } from '../commonmark/grammar.ts'
|
||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||
import { highlightDelimiter } from '../portable/conventions.ts'
|
||||
@@ -261,14 +261,9 @@ function linkedAlt(piece: Piece): Piece {
|
||||
}
|
||||
|
||||
function mediaSingle({ alt, definition, link }: Image): AdfNode {
|
||||
const media = externalMedia(alt, definition.destination)
|
||||
const { title } = definition
|
||||
const caption: AdfNode[] = title === undefined || title === '' ? [] : [{ content: [{ text: title, type: 'text' }], type: 'caption' }]
|
||||
return centeredImage([link === undefined ? media : { ...media, marks: [linkMark(link)] }, ...caption])
|
||||
}
|
||||
|
||||
function linkMark({ destination, title }: LinkDefinition): AdfMark {
|
||||
return { attrs: title === undefined ? { href: destination } : { href: destination, title }, type: 'link' }
|
||||
return centeredImage([externalMedia(alt, definition.destination, link), ...caption])
|
||||
}
|
||||
|
||||
// ADF holds no empty text node and a hard break takes no marks, so the destination text carries the link ahead of the text's hard breaks; an empty destination leaves the breaks alone.
|
||||
|
||||
@@ -220,6 +220,13 @@ test('spells a card as a link to its url, or to its data url named by its data n
|
||||
assert.equal(portable(node('blockCard', { data: {} })), '_(link card not included)_\n')
|
||||
})
|
||||
|
||||
test('keeps the target of a link marking an external image', () => {
|
||||
const linked = (href: string, ...marks: AdfMark[]): AdfNode => ({ attrs: { alt: 'Moon', type: 'external', url: 'https://example.com/moon.png' }, marks: [{ attrs: { href, title: 'Docs', id: 'x' }, type: 'link' }, ...marks], type: 'media' })
|
||||
assert.equal(portable(node('mediaSingle', { layout: 'wide' }, linked('https://example.com/docs'))), '[](https://example.com/docs "Docs")\n')
|
||||
assert.equal(portable(node('mediaSingle', {}, linked('a\\b', { attrs: { color: '#000', size: 1 }, type: 'border' }), node('caption', {}, text('The moon.')))), '[](a%5Cb "Docs")\n\nThe moon.\n')
|
||||
assert.equal(portable(linked('')), '[](<> "Docs")\n')
|
||||
})
|
||||
|
||||
test('keeps an external image wherever it stands and spells a stored file as its alt text, else a note', () => {
|
||||
const media = (attrs: AdfAttributes): AdfNode => ({ attrs, type: 'media' })
|
||||
const caption: AdfNode = node('caption', {}, text('The moon.'))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||
import { adfDocumentFault, nodeAttrs, nodeContent } from '../../adf/document.ts'
|
||||
import { adfDocumentFault, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||
import { blockNodeModel, isTaskItem } 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'
|
||||
import { inlineLeaves, isBlockNodeType, oneLine, reduceInline, writableHref } from './inline-reduction.ts'
|
||||
import { inlineLeaves, isBlockNodeType, oneLine, portableLink, reduceInline, writableHref } from './inline-reduction.ts'
|
||||
import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
||||
import { languageSlot } from '../code-language.ts'
|
||||
import { largestNesting } from '../../nesting.ts'
|
||||
@@ -350,5 +350,8 @@ 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() : ''
|
||||
return success([centeredImage([externalMedia(alt, writableHref(url))])])
|
||||
const image = externalMedia(alt, writableHref(url))
|
||||
const mark = nodeMarks(media).find((held) => held.type === 'link')
|
||||
const link = mark === undefined ? undefined : portableLink(mark, 'paragraph')
|
||||
return success([centeredImage([link === undefined ? image : { ...image, marks: [link] }])])
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ function portableMarks(marks: readonly AdfMark[], container: LineContainer, text
|
||||
return kept.filter((mark) => !code || mark.type !== highlight).sort((first, second) => rank(first) - rank(second))
|
||||
}
|
||||
|
||||
function portableLink(mark: AdfMark, container: LineContainer): AdfMark | undefined {
|
||||
export function portableLink(mark: AdfMark, container: LineContainer): AdfMark | undefined {
|
||||
const attrs = nodeAttrs(mark)
|
||||
const held = attrs['href']
|
||||
const title = typeof attrs['title'] === 'string' ? attrs['title'].replace(/[\r\0]/g, '').replace(/\n/g, ' ') : undefined
|
||||
|
||||
Reference in New Issue
Block a user