Read Jira documents directly while preserving Markdown semantics

Use the standard Markdown pipeline once per Cloud publication and retain the direct ADF visitor on read. Preserve emitted boundaries, compact tables and marked whitespace without modifying Markdown libraries.

Co-authored-by: LDH1103 <ldh517525@gmail.com>
This commit is contained in:
Brennan Benson
2026-11-13 21:50:41 -08:00
co-authored by LDH1103
parent 9c0999463c
commit c9a2bbd71f
10 changed files with 780 additions and 188 deletions
+1 -3
View File
@@ -37,9 +37,7 @@ const BUNDLED_MAIN_DEPENDENCIES = new Set([
'remark-parse',
'remark-gfm',
'remark-cjk-friendly',
'mdast-util-to-markdown',
'mdast-util-to-markdown-cjk-friendly',
'mdast-util-gfm',
'markdown-table',
'hast-util-from-html-isomorphic',
// Windows NSIS copies app.asar before external resources; bootstrap must not race resources/node_modules.
'zod'
+1 -3
View File
@@ -245,9 +245,6 @@
"fflate": "0.8.3",
"i18next": "^26.7.2",
"jsonc-parser": "^3.3.1",
"mdast-util-gfm": "3.1.0",
"mdast-util-to-markdown": "2.2.3",
"mdast-util-to-markdown-cjk-friendly": "1.0.2",
"mime-types": "3.0.2",
"node-pty": "^1.1.0",
"posthog-node": "^5.52.5",
@@ -346,6 +343,7 @@
"lint-staged": "^16.4.0",
"lowlight": "^3.3.0",
"lucide-react": "^0.577.0",
"markdown-table": "3.0.4",
"marked": "^17.0.1",
"mermaid": "^11.17.2",
"monaco-editor": "^0.55.1",
+4 -10
View File
@@ -262,15 +262,6 @@ importers:
jsonc-parser:
specifier: ^3.3.1
version: 3.3.1
mdast-util-gfm:
specifier: 3.1.0
version: 3.1.0(supports-color@7.2.0)
mdast-util-to-markdown:
specifier: 2.2.3
version: 2.2.3
mdast-util-to-markdown-cjk-friendly:
specifier: 1.0.2
version: 1.0.2(@types/mdast@4.0.4)(micromark-util-types@2.0.2)
mime-types:
specifier: 3.0.2
version: 3.0.2
@@ -557,6 +548,9 @@ importers:
lowlight:
specifier: ^3.3.0
version: 3.3.0
markdown-table:
specifier: 3.0.4
version: 3.0.4
lucide-react:
specifier: ^0.577.0
version: 0.577.0(@types/react@19.2.18)(react@19.2.8)
@@ -13606,7 +13600,7 @@ snapshots:
mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0)
mdast-util-gfm-table: 2.0.0(supports-color@7.2.0)
mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0)
mdast-util-to-markdown: 2.2.3
mdast-util-to-markdown: 2.1.2
transitivePeerDependencies:
- supports-color
-113
View File
@@ -1,113 +0,0 @@
import type { BlockContent, Heading, ListItem, TableRow } from 'mdast'
import { adfInlineNodes } from './adf-text-marks'
import { adfChildren, adfPositiveInteger, adfRecord, adfString, type AdfRecord } from './adf-nodes'
type MediaRenderer = (node: AdfRecord) => string
function headingDepth(value: unknown): Heading['depth'] {
if (value === 2 || value === 3 || value === 4 || value === 5 || value === 6) {
return value
}
return 1
}
function simpleTable(node: AdfRecord, media: MediaRenderer): BlockContent[] | null {
const rows: TableRow[] = []
for (const rawRow of adfChildren(node.content)) {
const cells = adfChildren(adfRecord(rawRow).content)
const row: TableRow = { type: 'tableRow', children: [] }
for (const rawCell of cells) {
const cell = adfRecord(rawCell)
const attrs = adfRecord(cell.attrs)
const blocks = adfChildren(cell.content)
if (
adfPositiveInteger(attrs.colspan) !== 1 ||
adfPositiveInteger(attrs.rowspan) !== 1 ||
blocks.length !== 1 ||
adfRecord(blocks[0]).type !== 'paragraph'
) {
return null
}
row.children.push({
type: 'tableCell',
children: adfInlineNodes(adfRecord(blocks[0]).content, media, true)
})
}
if (row.children.length) {
rows.push(row)
}
}
return rows.length ? [{ type: 'table', children: rows }] : []
}
function listItem(value: unknown, media: MediaRenderer): ListItem {
return {
type: 'listItem',
spread: false,
children: adfBlockNodes(adfRecord(value).content, media)
}
}
function blockNode(value: unknown, media: MediaRenderer): BlockContent[] {
const node = adfRecord(value)
const children = () => adfBlockNodes(node.content, media)
switch (node.type) {
case 'doc':
return children()
case 'paragraph':
return [{ type: 'paragraph', children: adfInlineNodes(node.content, media) }]
case 'heading':
return [
{
type: 'heading',
depth: headingDepth(adfRecord(node.attrs).level),
children: adfInlineNodes(node.content, media)
}
]
case 'bulletList':
case 'orderedList':
return [
{
type: 'list',
ordered: node.type === 'orderedList',
start:
node.type === 'orderedList' ? adfPositiveInteger(adfRecord(node.attrs).order) : null,
spread: false,
children: adfChildren(node.content).map((item) => listItem(item, media))
}
]
case 'listItem':
return children()
case 'codeBlock':
return [
{
type: 'code',
lang: adfString(adfRecord(node.attrs).language) || null,
value: adfChildren(node.content)
.map((child) => adfString(adfRecord(child).text))
.join('')
}
]
case 'blockquote':
return [{ type: 'blockquote', children: children() }]
case 'rule':
return [{ type: 'thematicBreak' }]
case 'table':
return simpleTable(node, media) ?? children()
case 'media':
case 'mediaInline':
return [{ type: 'paragraph', children: [{ type: 'jiraMedia', value: media(node) }] }]
default: {
const blocks = children()
if (blocks.length) {
return blocks
}
const inline = adfInlineNodes([node], media)
return inline.length ? [{ type: 'paragraph', children: inline }] : []
}
}
}
export function adfBlockNodes(value: unknown, media: MediaRenderer): BlockContent[] {
return adfChildren(value).flatMap((node) => blockNode(node, media))
}
+1 -1
View File
@@ -43,7 +43,7 @@ function normalize(value: string, start: number, end: number): HttpLinkSpan | nu
ipv6Length++
}
}
if (previous && !/[\s\p{P}]/u.test(previous)) {
if (previous && !/[\s\p{P}<]/u.test(previous)) {
return null
}
let tailStart = end
+226
View File
@@ -0,0 +1,226 @@
import type { PhrasingContent } from 'mdast'
import { escapeMarkdownTextLinkDestination } from './adf-media-destination'
function escapeLiteral(value: string, inLink: boolean): string {
return value
.replace(/[\\`*_~[<>]/g, (char, index: number) =>
char === '_' &&
/[\p{L}\p{N}]/u.test(value[index - 1] ?? '') &&
/[\p{L}\p{N}]/u.test(value[index + 1] ?? '')
? char
: `\\${char}`
)
.replace(/]/g, inLink ? '\\]' : ']')
.replace(/&(?=(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[\da-fA-F]+);)/g, '\\&')
}
function escapeBlockMarkers(value: string, lineStart: boolean): string {
return value
.replace(
/^([ \t]*)([#>+=-]|\d+[.)])/gm,
(_match, indent: string, marker: string, offset: number) =>
offset === 0 && !lineStart ? _match : `${indent}${marker.slice(0, -1)}\\${marker.at(-1)}`
)
.replace(/^( {4}|\t)/gm, (indent, _capture, offset: number) =>
offset === 0 && !lineStart ? indent : indent === '\t' ? '&#9;' : '&#32; '
)
}
function escapeDestinationEntities(value: string): string {
return value.replace(/&(?=(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[\da-fA-F]+);)/g, '&amp;')
}
function inlineCode(value: string): string {
let longest = 0
for (const match of value.matchAll(/`+/g)) {
longest = Math.max(longest, match[0].length)
}
const fence = '`'.repeat(longest + 1)
const padding = /^`|`$|^ .* $/s.test(value) && /[^ ]/.test(value) ? ' ' : ''
return `${fence}${padding}${value}${padding}${fence}`
}
type InlineToken = {
value: string
url?: string
explicitLink?: boolean
literal?: boolean
style?: 'open' | 'close'
}
function inlineTokens(
nodes: PhrasingContent[],
inLink: boolean,
inTable: boolean,
tokens: InlineToken[]
): void {
for (const [index, node] of nodes.entries()) {
switch (node.type) {
case 'text':
tokens.push({ value: escapeLiteral(node.value, inLink), literal: true })
break
case 'jiraAutolink':
tokens.push({ value: node.value, url: node.url })
break
case 'jiraMedia':
case 'html':
tokens.push({ value: node.value })
break
case 'inlineCode':
tokens.push({ value: inlineCode(inTable ? node.value.replace(/\|/g, '\\|') : node.value) })
break
case 'break':
tokens.push({ value: inTable ? '<br>' : '\n' })
break
case 'link': {
const url = escapeMarkdownTextLinkDestination(node.url)
if (url) {
tokens.push({ value: '[', explicitLink: true })
}
inlineTokens(node.children, true, inTable, tokens)
if (url) {
tokens.push({ value: `](${escapeDestinationEntities(url)})` })
}
break
}
case 'strong':
case 'emphasis':
case 'delete': {
// Different markers keep adjacent strong/emphasis runs separate without reparsing.
const before = nodes[index - 1]
const after = nodes[index + 1]
const intraword =
node.type === 'emphasis' &&
((before?.type === 'text' && /[\p{L}\p{N}]$/u.test(before.value)) ||
(after?.type === 'text' && /^[\p{L}\p{N}]/u.test(after.value)))
const marker =
node.type === 'strong' ? '**' : node.type === 'delete' ? '~~' : intraword ? '*' : '_'
tokens.push({ value: marker, style: 'open' })
inlineTokens(node.children, inLink, inTable, tokens)
tokens.push({ value: marker, style: 'close' })
break
}
default:
if ('children' in node) {
inlineTokens(node.children, inLink, inTable, tokens)
}
}
}
}
function supportsUriAutolink(value: string): boolean {
for (const char of value) {
if (char.charCodeAt(0) <= 0x20 || char === '<' || char === '>') {
return false
}
}
return true
}
export function renderInlineMarkdown(
nodes: PhrasingContent[],
inTable: boolean,
heading: boolean
): string {
const tokens: InlineToken[] = []
inlineTokens(nodes, false, inTable, tokens)
const rightBoundary: boolean[] = []
let boundary = true
for (let index = tokens.length - 1; index >= 0; index--) {
rightBoundary[index] = boundary
if (tokens[index].value) {
boundary = /^\s/u.test(tokens[index].value)
}
}
const rendered: InlineToken[] = []
boundary = true
let lineStart = true
for (const [index, token] of tokens.entries()) {
if (!token.value) {
continue
}
let value = token.value
if (token.literal) {
value = heading ? value.replace(/#/g, '\\#') : escapeBlockMarkers(value, lineStart)
}
let explicit = token.explicitLink
if (token.url && !(boundary && rightBoundary[index])) {
if (/^https?:\/\//i.test(value) && supportsUriAutolink(value)) {
value = `<${value}>`
} else {
const url = escapeMarkdownTextLinkDestination(token.url)
if (url) {
value = `[${escapeLiteral(value, true)}](${escapeDestinationEntities(url)})`
explicit = true
}
}
}
const previous = rendered.at(-1)
if (explicit && previous?.value.endsWith('!')) {
previous.value = `${previous.value.slice(0, -1)}\\!`
}
rendered.push({ ...token, value })
boundary = /\s$/u.test(value)
lineStart = value.endsWith('\n')
}
const nextCharacters: string[] = []
let next = ''
for (let index = rendered.length - 1; index >= 0; index--) {
nextCharacters[index] = next
next = firstCharacter(rendered[index].value)
}
const output: string[] = []
let encodeNext = false
for (const [index, token] of rendered.entries()) {
let value = token.value
if (encodeNext) {
const first = firstCharacter(value)
value = `${characterEntity(first)}${value.slice(first.length)}`
}
encodeNext = false
const previous = output.at(-1) ?? ''
const before = lastCharacter(previous)
const after = nextCharacters[index]
if (
token.style === 'open' &&
needsFlankingBoundary(before, token.value !== '~~') &&
/[\p{P}\p{S}]/u.test(after)
) {
output[output.length - 1] = `${previous.slice(0, -before.length)}${characterEntity(before)}`
}
if (
token.style === 'close' &&
/[\p{P}\p{S}]/u.test(before) &&
needsFlankingBoundary(after, token.value !== '~~')
) {
encodeNext = true
}
output.push(value)
}
return output.join('')
}
function firstCharacter(value: string): string {
return /^./u.exec(value)?.[0] ?? ''
}
function lastCharacter(value: string): string {
const last = value.charCodeAt(value.length - 1)
return value.slice(last >= 0xdc00 && last <= 0xdfff ? -2 : -1)
}
function needsFlankingBoundary(char: string, cjkAttention: boolean): boolean {
// Only attention delimiters get the shared CJK grammar's relaxed word boundary.
return (
!!char &&
!/[\s\p{P}\p{S}]/u.test(char) &&
!(
cjkAttention &&
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(char)
)
)
}
function characterEntity(char: string): string {
return `&#${char.codePointAt(0)};`
}
+216 -38
View File
@@ -1,13 +1,14 @@
import type { Text } from 'mdast'
import type { JiraMediaMarkdown } from './adf-text-marks'
import { escapeMarkdownLinkDestination } from './adf-media-destination'
import { toMarkdown } from 'mdast-util-to-markdown'
import { gfmToMarkdown } from 'mdast-util-gfm'
import { cjkFriendlyToMarkdown } from 'mdast-util-to-markdown-cjk-friendly'
import { adfBlockNodes } from './adf-blocks'
import { renderAdfInline } from './adf-text-marks'
import { markdownTable } from 'markdown-table'
type JiraAdfRecord = Record<string, unknown>
type MarkdownBlock = {
kind: 'block' | 'list'
text: string
}
export type JiraAdfMediaAttrs = {
id?: string
url?: string
@@ -26,10 +27,22 @@ function asRecord(value: unknown): JiraAdfRecord {
return value && typeof value === 'object' ? (value as JiraAdfRecord) : {}
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : []
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function positiveInteger(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback
}
function headingLevel(value: unknown): number {
return Math.min(Math.max(positiveInteger(value, 1), 1), 6)
}
export function escapeMarkdownAlt(text: string): string {
return text.replace(/[[\]]/g, '')
}
@@ -94,43 +107,208 @@ function renderMediaMarkdown(
return unresolvedMediaPlaceholder(attrs)
}
function renderInline(
value: unknown,
options?: AdfToMarkdownOptions,
inTable = false,
heading = false
): string {
return renderAdfInline(
asArray(value),
(node) => renderMediaMarkdown(node, options),
inTable,
heading
)
}
function joinBlocks(blocks: MarkdownBlock[]): string {
return blocks
.map((block) => block.text)
.filter((text) => text.length > 0)
.join('\n\n')
}
function renderBlocks(content: unknown, options?: AdfToMarkdownOptions): MarkdownBlock[] {
return asArray(content)
.map((node) => renderBlock(node, options))
.filter((block) => block.text.length > 0)
}
function renderListItem(node: unknown, prefix: string, options?: AdfToMarkdownOptions): string {
const blocks = renderBlocks(asRecord(node).content, options)
if (blocks.length === 0) {
return prefix.trimEnd()
}
const lines: string[] = []
const continuationIndent = ' '.repeat(prefix.length)
blocks.forEach((block, blockIndex) => {
const blockLines = block.text.split('\n')
if (blockIndex === 0) {
lines.push(`${prefix}${blockLines[0] ?? ''}`)
blockLines.slice(1).forEach((line) => {
lines.push(line ? `${continuationIndent}${line}` : '')
})
return
}
if (block.kind !== 'list' && !block.text.startsWith('```')) {
lines.push('')
}
blockLines.forEach((line) => {
lines.push(line ? `${continuationIndent}${line}` : '')
})
})
return lines.join('\n')
}
function renderList(
record: JiraAdfRecord,
ordered: boolean,
options?: AdfToMarkdownOptions
): string {
const order = asRecord(record.attrs).order
const start =
ordered && typeof order === 'number' && Number.isInteger(order) && order >= 0 ? order : 1
return asArray(record.content)
.map((item, index) => renderListItem(item, ordered ? `${start + index}. ` : '- ', options))
.join('\n')
}
function renderCodeBlock(record: JiraAdfRecord): MarkdownBlock {
const text = asArray(record.content)
.map((child) => asString(asRecord(child).text))
.join('')
let longest = 2
for (const match of text.matchAll(/`+/g)) {
longest = Math.max(longest, match[0].length)
}
const fence = '`'.repeat(longest + 1)
const language = asString(asRecord(record.attrs).language).replace(/[\r\n`]/g, '')
return { kind: 'block', text: `${fence}${language}\n${text}\n${fence}` }
}
function escapeTablePipes(value: string): string {
const result: string[] = []
let slashes = 0
for (const char of value) {
if (char === '|' && slashes % 2 === 0) {
result.push('\\')
}
result.push(char)
slashes = char === '\\' ? slashes + 1 : 0
}
return result.join('')
}
function renderTable(record: JiraAdfRecord, options?: AdfToMarkdownOptions): string | null {
const rows: string[][] = []
for (const rawRow of asArray(record.content)) {
const cells: string[] = []
for (const rawCell of asArray(asRecord(rawRow).content)) {
const cell = asRecord(rawCell)
const attrs = asRecord(cell.attrs)
const blocks = asArray(cell.content)
if (
positiveInteger(attrs.colspan, 1) !== 1 ||
positiveInteger(attrs.rowspan, 1) !== 1 ||
blocks.length !== 1 ||
asRecord(blocks[0]).type !== 'paragraph'
) {
return null
}
const text = renderInline(asRecord(blocks[0]).content, options, true)
cells.push(escapeTablePipes(text))
}
if (cells.length) {
rows.push(cells)
}
}
return rows.length ? markdownTable(rows, { alignDelimiters: false }) : ''
}
function renderBlockquote(record: JiraAdfRecord, options?: AdfToMarkdownOptions): MarkdownBlock {
const text = joinBlocks(renderBlocks(record.content, options))
return {
kind: 'block',
text: text
.split('\n')
.map((line) => (line ? `> ${line}` : '>'))
.join('\n')
}
}
function renderBlock(node: unknown, options?: AdfToMarkdownOptions): MarkdownBlock {
if (typeof node === 'string') {
return { kind: 'block', text: node }
}
if (Array.isArray(node)) {
return { kind: 'block', text: joinBlocks(renderBlocks(node, options)) }
}
if (!node || typeof node !== 'object') {
return { kind: 'block', text: '' }
}
const record = node as JiraAdfRecord
const type = asString(record.type)
if (type === 'doc') {
return { kind: 'block', text: joinBlocks(renderBlocks(record.content, options)) }
}
if (type === 'paragraph') {
return { kind: 'block', text: renderInline(record.content, options) }
}
if (type === 'heading') {
const prefix = '#'.repeat(headingLevel(asRecord(record.attrs).level))
return {
kind: 'block',
text: `${prefix} ${renderInline(record.content, options, false, true).trim()}`.trim()
}
}
if (type === 'bulletList') {
return { kind: 'list', text: renderList(record, false, options) }
}
if (type === 'orderedList') {
return { kind: 'list', text: renderList(record, true, options) }
}
if (type === 'listItem') {
return { kind: 'list', text: renderListItem(record, '- ', options) }
}
if (type === 'codeBlock') {
return renderCodeBlock(record)
}
if (type === 'blockquote') {
return renderBlockquote(record, options)
}
if (type === 'table') {
const table = renderTable(record, options)
if (table !== null) {
return { kind: 'block', text: table }
}
}
if (type === 'rule') {
return { kind: 'block', text: '---' }
}
if (type === 'mediaSingle' || type === 'mediaGroup') {
const mediaMarkdown = joinBlocks(renderBlocks(record.content, options))
return { kind: 'block', text: mediaMarkdown }
}
if (type === 'media' || type === 'mediaInline') {
return { kind: 'block', text: renderMediaMarkdown(record, options) }
}
return {
kind: 'block',
text: joinBlocks(renderBlocks(record.content, options)) || renderInline([record], options)
}
}
export function adfToMarkdownText(value: unknown, options?: AdfToMarkdownOptions): string {
// Server/DC sends wiki strings rather than ADF; preserve its existing normalization.
if (typeof value === 'string') {
return value
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
const root = asRecord(value)
const content = root.type === 'doc' ? root.content : [value]
return toMarkdown(
{
type: 'root',
children: adfBlockNodes(content, (node) => renderMediaMarkdown(node, options))
},
{
extensions: [gfmToMarkdown(), cjkFriendlyToMarkdown()],
bullet: '-',
emphasis: '*',
strong: '*',
fences: true,
listItemIndent: 'one',
rule: '-',
ruleSpaces: false,
handlers: {
break: () => '\n',
jiraMedia: (node: JiraMediaMarkdown) => node.value,
text: (node: Text, parent, state, info) =>
state
.safe(node.value, {
...info,
beforeNode: parent?.children[(state.indexStack.at(-1) ?? 0) - 1],
afterNode: parent?.children[(state.indexStack.at(-1) ?? 0) + 1]
})
.replace(/([\p{L}\p{N}])\\_(?=[\p{L}\p{N}])/gu, '$1_')
.replace(/\\&(?!(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[\da-fA-F]+);)/g, '&')
}
}
).replace(/\n$/, '')
return renderBlock(value, options).text
}
+34 -5
View File
@@ -4,16 +4,23 @@ import { httpCandidateNormalizer, type HttpLinkSpan } from './adf-http-link'
import { bareEmailSpans } from './adf-bare-email'
import { escapeMarkdownTextLinkDestination } from './adf-media-destination'
import { adfChildren, adfRecord, adfString } from './adf-nodes'
import { renderInlineMarkdown } from './adf-inline-markdown'
export type JiraMediaMarkdown = Literal & { type: 'jiraMedia'; value: string }
type JiraMarkdownLiteral = Literal & {
type: 'jiraMedia' | 'jiraAutolink'
value: string
url?: string
}
declare module 'mdast' {
// oxlint-disable-next-line typescript/consistent-type-definitions -- Module augmentation requires declaration merging.
interface RootContentMap {
jiraMedia: JiraMediaMarkdown
jiraMedia: JiraMarkdownLiteral
jiraAutolink: JiraMarkdownLiteral
}
// oxlint-disable-next-line typescript/consistent-type-definitions -- Module augmentation requires declaration merging.
interface PhrasingContentMap {
jiraMedia: JiraMediaMarkdown
jiraMedia: JiraMarkdownLiteral
jiraAutolink: JiraMarkdownLiteral
}
}
type Wrapper = Extract<PhrasingContent, { type: 'strong' | 'emphasis' | 'delete' | 'link' }>
@@ -57,7 +64,7 @@ function proseNodes(value: string): PhrasingContent[] {
result.push({ type: 'text', value: value.slice(offset, next.start) })
}
const label = value.slice(next.start, next.end)
result.push({ type: 'link', url: next.url, children: [{ type: 'text', value: label }] })
result.push({ type: 'jiraAutolink', value: label, url: next.url })
offset = next.end
}
if (offset < value.length) {
@@ -90,6 +97,18 @@ function appendWrapped(
}
}
export const trailingWhitespaceReader = {
read: (value: string, index: number): string => value[index]
}
function trailingWhitespaceStart(value: string): number {
let end = value.length
while (end > 0 && /^\s$/u.test(trailingWhitespaceReader.read(value, end - 1))) {
end--
}
return end
}
function normalizedStyles(nodes: PhrasingContent[]): PhrasingContent[] {
const normalized = nodes.flatMap((node): PhrasingContent[] => {
if (!('children' in node)) {
@@ -105,7 +124,8 @@ function normalizedStyles(nodes: PhrasingContent[]): PhrasingContent[] {
if (first?.type === 'text') {
first.value = first.value.slice(leading.length)
}
const trailing = last?.type === 'text' ? (/\s+$/.exec(last.value)?.[0] ?? '') : ''
const trailing =
last?.type === 'text' ? last.value.slice(trailingWhitespaceStart(last.value)) : ''
if (last?.type === 'text' && trailing) {
last.value = last.value.slice(0, -trailing.length)
}
@@ -174,3 +194,12 @@ export function adfInlineNodes(
}
return normalizedStyles(result)
}
export function renderAdfInline(
value: unknown,
renderMedia: MediaRenderer,
inTable = false,
heading = false
): string {
return renderInlineMarkdown(adfInlineNodes(value, renderMedia, inTable), inTable, heading)
}
+296 -14
View File
@@ -3,17 +3,20 @@ import { emailCharacterReader } from './adf-bare-email'
import { httpCandidateNormalizer, httpCharacterReader } from './adf-http-link'
import { markdownToAdf } from './markdown-adf'
import { adfToMarkdownText } from './adf-markdown'
import { trailingWhitespaceReader } from './adf-text-marks'
it('round-trips supported block structure and mixed inline marks', async () => {
const source =
'# Heading\n\n**one [two](https://example.com) three**\n\n*em* ~~strike~~ `code` [Ada](mailto:ada@example.com)\n\n3. item\n - nested\n\n> quote\n\n---'
expect(adfToMarkdownText(await markdownToAdf(source))).toBe(source)
expect(adfToMarkdownText(await markdownToAdf(source))).toBe(
'# Heading\n\n**one [two](https://example.com) three**\n\n_em_ ~~strike~~ `code` [Ada](mailto:ada@example.com)\n\n3. item\n - nested\n\n> quote\n\n---'
)
})
it('preserves GFM table cells, literal pipes and inline code on read-back', async () => {
const source = '| Name | Value |\n| --- | --- |\n| **bold** | `a\\|b` |\n| empty | |'
expect(adfToMarkdownText(await markdownToAdf(source))).toBe(
'| Name | Value |\n| -------- | ------ |\n| **bold** | `a\\|b` |\n| empty | |'
'| Name | Value |\n| - | - |\n| **bold** | `a\\|b` |\n| empty | |'
)
})
@@ -99,7 +102,9 @@ it('uses the renderer CJK emphasis and prose URL boundaries', async () => {
}
]
})
expect(adfToMarkdownText(doc)).toBe('**「重要」**です <https://example.com>。次')
expect(adfToMarkdownText(doc)).toBe(
'**「重要」**です [https://example.com](https://example.com)。次'
)
})
it('keeps code whitespace, language and embedded fences including in lists', async () => {
@@ -143,7 +148,7 @@ it('escapes active literal syntax while keeping identifiers and bare links reada
]
}
expect(adfToMarkdownText(doc)).toBe(
'snake_case & plain <https://example.com/a_b?x=1&y=2> <me@example.com> \\*\\*x\\*\\* \\[x]'
'snake_case & plain https://example.com/a_b?x=1&y=2 me@example.com \\*\\*x\\*\\* \\[x]'
)
})
@@ -201,7 +206,7 @@ it('keeps Jira smart links active and ordinary hard breaks readable', () => {
}
]
})
).toBe('<https://example.com/a_b?x=1&y=2>\nnext line')
).toBe('https://example.com/a_b?x=1&y=2\nnext line')
})
it('retains reference destinations used by inert source fallbacks', async () => {
@@ -246,7 +251,7 @@ it('preserves existing parenthesized and www links and split literal entities',
]
})
).toBe(
'<https://example.com/wiki/Foo_(bar)> [www.example.com](http://www.example.com) <me@example.com>。次\n\\&amp;'
'https://example.com/wiki/Foo_(bar) www.example.com [me@example.com](mailto:me@example.com)。次\n\\&amp;'
)
})
@@ -255,14 +260,14 @@ it('bounds real email character reads through document read-back at N and 10N',
const addresses = Math.floor(length / 15)
const fixtures = [
{ value: 'A'.repeat(length), expected: 'A'.repeat(length) },
{ value: `${'a'.repeat(length - 2)}@x`, expected: `${'a'.repeat(length - 2)}\\@x` },
{ value: `${'a'.repeat(length - 2)}@x`, expected: `${'a'.repeat(length - 2)}@x` },
{
value: `${'@a. '.repeat(length / 4 - 1)}@a.x`,
expected: `${'@a. '.repeat(length / 4 - 1)}@a.x`
},
{
value: 'me@example.com '.repeat(addresses) + 'a'.repeat(length % 15),
expected: '<me@example.com> '.repeat(addresses) + 'a'.repeat(length % 15)
expected: 'me@example.com '.repeat(addresses) + 'a'.repeat(length % 15)
}
]
for (const { value, expected } of fixtures) {
@@ -295,19 +300,19 @@ it('preserves prose-separated URLs, IPv6 authorities and surrounding literal del
['https://a.com。https://b.com', '<https://a.com>。<https://b.com>'],
[
'http://[::1]。next http://user@[::1]:8080/path',
'<http://[::1]>。next <http://user@[::1]:8080/path>'
'<http://[::1]>。next http://user@[::1]:8080/path'
],
[
'https://example.com/Foo_(bar)) www.example.com',
'<https://example.com/Foo_(bar)>) [www.example.com](http://www.example.com)'
'<https://example.com/Foo_(bar)>) www.example.com'
],
[
'https://example.com/me@example.com me@example.com',
'<https://example.com/me@example.com> <me@example.com>'
'https://example.com/me@example.com me@example.com'
],
['**see https://example.com/x**', '\\*\\*see <https://example.com/x>\\*\\*'],
// Intended: an overlapping local-part run does not create a second partial email.
['a@b.c!x@d.e', '<a@b.c>!x\\@d.e']
['a@b.c!x@d.e', '[a@b.c](mailto:a@b.c)!x@d.e']
]
for (const [value, expected] of values) {
expect(
@@ -329,7 +334,7 @@ it('bounds actual HTTP normalization reads and admission through document read-b
const fixtures = [
{
value: interior,
expected: `<https://example.com/${')'.repeat(length)}x>`,
expected: `https://example.com/${')'.repeat(length)}x`,
ranges: [[0, interior.length]]
},
{
@@ -383,7 +388,7 @@ it('bounds actual HTTP normalization reads and admission through document read-b
}
})
it('keeps literal entity spellings and code bytes with the CJK serializer', async () => {
it('keeps literal entity spellings and code bytes in direct read-back', async () => {
const doc = {
type: 'doc',
content: [
@@ -407,6 +412,23 @@ it('keeps literal entity spellings and code bytes with the CJK serializer', asyn
}
const markdown = adfToMarkdownText(doc)
expect(markdown).toContain('\\&#x3067;\n\n`&#x3067;`\n\n```\n&#x3067;\n```')
expect(
adfToMarkdownText({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'query',
marks: [{ type: 'link', attrs: { href: 'https://example.com/files?diff=split&w=1' } }]
}
]
}
]
})
).toBe('[query](https://example.com/files?diff=split&w=1)')
expect(await markdownToAdf(markdown)).toMatchObject({
content: expect.arrayContaining([
{
@@ -433,3 +455,263 @@ it('keeps literal entity spellings and code bytes with the CJK serializer', asyn
)
).toBe('![img](https://example.com/?q=&#x3067;)')
})
it('escapes literal blocks without damaging pasted links or adjacent CJK marks', async () => {
expect(
adfToMarkdownText({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '*literal* build log https://example.com/_next/static/a~b*c*.js ok'
},
{ type: 'hardBreak' },
{ type: 'text', text: '# heading' },
{ type: 'hardBreak' },
{ type: 'text', text: '1. item' },
{ type: 'hardBreak' },
{ type: 'text', text: '=== ' }
]
}
]
})
).toBe(
'\\*literal\\* build log https://example.com/_next/static/a~b*c*.js ok\n\\# heading\n1\\. item\n\\=== '
)
expect(
await markdownToAdf(
adfToMarkdownText({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '「甲」', marks: [{ type: 'strong' }] },
{ type: 'text', text: '「乙」', marks: [{ type: 'em' }] }
]
}
]
})
)
).toEqual({
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '「甲」', marks: [{ type: 'strong' }] },
{ type: 'text', text: '「乙」', marks: [{ type: 'em' }] }
]
}
]
})
})
it('keeps bare-link destinations separate from escaped text and mark delimiters', async () => {
const cases = [
{
content: [{ type: 'text', text: '**see https://example.com/x**' }],
markdown: '\\*\\*see <https://example.com/x>\\*\\*',
text: '**see https://example.com/x**',
linkText: 'https://example.com/x',
href: 'https://example.com/x'
},
{
content: [{ type: 'text', text: '<https://example.com>' }],
markdown: '\\<<https://example.com>\\>',
text: '<https://example.com>',
linkText: 'https://example.com',
href: 'https://example.com'
},
{
content: [
{ type: 'inlineCard', attrs: { url: 'https://x.com/a' } },
{ type: 'text', text: '_note' }
],
markdown: '<https://x.com/a>\\_note',
text: 'https://x.com/a_note',
linkText: 'https://x.com/a',
href: 'https://x.com/a'
},
{
content: [{ type: 'text', text: 'https://x.com/a', marks: [{ type: 'strong' }] }],
markdown: '**<https://x.com/a>**',
text: 'https://x.com/a',
linkText: 'https://x.com/a',
href: 'https://x.com/a',
style: 'strong'
},
{
content: [{ type: 'text', text: 'https://x.com/a', marks: [{ type: 'em' }] }],
markdown: '_<https://x.com/a>_',
text: 'https://x.com/a',
linkText: 'https://x.com/a',
href: 'https://x.com/a',
style: 'em'
},
{
content: [{ type: 'text', text: '!www.example.com*' }],
markdown: '\\![www.example.com](http://www.example.com)\\*',
text: '!www.example.com*',
linkText: 'www.example.com',
href: 'http://www.example.com'
},
{
content: [
{ type: 'text', text: '!' },
{ type: 'text', text: 'me@example.com', marks: [{ type: 'strong' }] }
],
markdown: '!**[me@example.com](mailto:me@example.com)**',
text: '!me@example.com',
linkText: 'me@example.com',
href: 'mailto:me@example.com',
style: 'strong'
}
]
for (const { content, markdown, text, linkText, href, style } of cases) {
const actual = adfToMarkdownText({ type: 'doc', content: [{ type: 'paragraph', content }] })
expect(actual).toBe(markdown)
const doc = await markdownToAdf(actual)
expect(doc).toMatchObject({ content: [{ type: 'paragraph' }] })
const blocks = doc.content as { content: { text: string; marks?: unknown[] }[] }[]
expect(blocks[0].content.map((node) => node.text).join('')).toBe(text)
expect(blocks[0].content).toContainEqual({
type: 'text',
text: linkText,
marks: [...(style ? [{ type: style }] : []), { type: 'link', attrs: { href } }]
})
}
})
it('bounds actual trailing-whitespace reads through marked document read-back at N and 10N', () => {
for (const length of [800, 8000]) {
const spaces = ' '.repeat(length)
const cases = [
{ text: `a${spaces}b`, marks: ['strong'], expected: `**a${spaces}b**`, bound: 1, suffix: 1 },
{
text: `a${spaces}`,
marks: ['strong'],
expected: `**a**${spaces}`,
bound: length + 1,
suffix: length + 1
},
{
text: `a${spaces}`,
marks: ['strong', 'em', 'strike'],
expected: `**_~~a~~_**${spaces}`,
bound: 3 * (length + 1),
suffix: length + 1
}
]
for (const { text, marks, expected, bound, suffix } of cases) {
const visited = new Set<number>()
let reads = 0
const probe = vi
.spyOn(trailingWhitespaceReader, 'read')
.mockImplementation((value, index) => {
reads++
visited.add(index)
return value[index]
})
try {
expect(
adfToMarkdownText({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text, marks: marks.map((type) => ({ type })) }]
}
]
})
).toBe(expected)
expect(reads, 'trailing whitespace endpoint coverage').toBeGreaterThan(0)
expect(reads).toBeLessThanOrEqual(bound)
for (let index = text.length - suffix; index < text.length; index++) {
expect(visited.has(index), 'trailing whitespace endpoint coverage').toBe(true)
}
} finally {
probe.mockRestore()
}
}
}
for (const [text, expected] of [
['a\u00a0', '**a**\u00a0'],
['', ''],
[' ', ' ']
]) {
expect(
adfToMarkdownText({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text, marks: [{ type: 'strong' }] }] }
]
})
).toBe(expected)
}
})
it('retains literal heading hashes and punctuation-edged marks on readback', async () => {
for (const [source, markdown] of [
['# a \\#', '# a \\#'],
['# \\#', '# \\#'],
['**a!**&#98;', '**a!**&#98;'],
['&#97;**!b**', '&#97;**!b**'],
['**a!**&#946;', '**a!**&#946;']
]) {
const doc = await markdownToAdf(source)
expect(adfToMarkdownText(doc)).toBe(markdown)
expect(await markdownToAdf(adfToMarkdownText(doc))).toEqual(doc)
}
})
it('bounds table output by cell content at N and 10N without widest-cell padding', () => {
for (const [rowCount, width] of [
[100, 800],
[1000, 8000]
]) {
const rows = Array.from({ length: rowCount }, (_, index) => ({
type: 'tableRow',
content: [index === 0 ? 'a'.repeat(width) : 'a', 'b'].map((text) => ({
type: 'tableCell',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }]
}))
}))
const output = adfToMarkdownText({ type: 'doc', content: [{ type: 'table', content: rows }] })
const cellCharacters = width + 2 * rowCount - 1
expect(output.length).toBeLessThanOrEqual(cellCharacters + 4 * 2 * (rowCount + 2))
expect(output.split('\n')).toHaveLength(rowCount + 1)
expect(output).toBe(
`| ${'a'.repeat(width)} | b |\n| - | - |${'\n| a | b |'.repeat(rowCount - 1)}`
)
}
})
it('retains zero-based ordered-list starts through publication and read-back', async () => {
const doc = await markdownToAdf('0. first\n1. second')
expect(doc).toMatchObject({ content: [{ type: 'orderedList', attrs: { order: 0 } }] })
expect(adfToMarkdownText(doc)).toBe('0. first\n1. second')
})
it('keeps GFM strike boundaries distinct from CJK attention boundaries', async () => {
const doc = await markdownToAdf('~~a!~~&#20013;')
expect(doc).toEqual({
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'a!', marks: [{ type: 'strike' }] },
{ type: 'text', text: '中' }
]
}
]
})
expect(adfToMarkdownText(doc)).toBe('~~a!~~&#20013;')
expect(await markdownToAdf(adfToMarkdownText(doc))).toEqual(doc)
})
+1 -1
View File
@@ -212,7 +212,7 @@ function listNode(node: MdNode, context: ConversionContext): AdfNode {
if (!node.ordered) {
return { type: 'bulletList', content: items }
}
const start = typeof node.start === 'number' && node.start > 0 ? node.start : 1
const start = typeof node.start === 'number' && node.start >= 0 ? node.start : 1
return {
type: 'orderedList',
...(start === 1 ? {} : { attrs: { order: start } }),