From c9a2bbd71fc8d698d7534e1c8fb0dcee6988fd05 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:50:41 -0800 Subject: [PATCH] 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 --- electron.vite.config.ts | 4 +- package.json | 4 +- pnpm-lock.yaml | 14 +- src/main/jira/adf-blocks.ts | 113 ---------- src/main/jira/adf-http-link.ts | 2 +- src/main/jira/adf-inline-markdown.ts | 226 +++++++++++++++++++ src/main/jira/adf-markdown.ts | 254 ++++++++++++++++++---- src/main/jira/adf-text-marks.ts | 39 +++- src/main/jira/markdown-adf.test.ts | 310 +++++++++++++++++++++++++-- src/main/jira/markdown-adf.ts | 2 +- 10 files changed, 780 insertions(+), 188 deletions(-) delete mode 100644 src/main/jira/adf-blocks.ts create mode 100644 src/main/jira/adf-inline-markdown.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 3690e6e14d0..c0e44a61bd9 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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' diff --git a/package.json b/package.json index 44ceb783109..33bb1d2102a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8de99191012..1527bb4dfef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/main/jira/adf-blocks.ts b/src/main/jira/adf-blocks.ts deleted file mode 100644 index 62dcf74f3ff..00000000000 --- a/src/main/jira/adf-blocks.ts +++ /dev/null @@ -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)) -} diff --git a/src/main/jira/adf-http-link.ts b/src/main/jira/adf-http-link.ts index 5d4dff32031..d19dd1cc968 100644 --- a/src/main/jira/adf-http-link.ts +++ b/src/main/jira/adf-http-link.ts @@ -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 diff --git a/src/main/jira/adf-inline-markdown.ts b/src/main/jira/adf-inline-markdown.ts new file mode 100644 index 00000000000..3749f545192 --- /dev/null +++ b/src/main/jira/adf-inline-markdown.ts @@ -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' ? ' ' : ' ' + ) +} + +function escapeDestinationEntities(value: string): string { + return value.replace(/&(?=(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[\da-fA-F]+);)/g, '&') +} + +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 ? '
' : '\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)};` +} diff --git a/src/main/jira/adf-markdown.ts b/src/main/jira/adf-markdown.ts index b0413964531..7a32d3d9318 100644 --- a/src/main/jira/adf-markdown.ts +++ b/src/main/jira/adf-markdown.ts @@ -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 +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 } diff --git a/src/main/jira/adf-text-marks.ts b/src/main/jira/adf-text-marks.ts index a816908b76e..43660ef2118 100644 --- a/src/main/jira/adf-text-marks.ts +++ b/src/main/jira/adf-text-marks.ts @@ -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 @@ -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) +} diff --git a/src/main/jira/markdown-adf.test.ts b/src/main/jira/markdown-adf.test.ts index 27674625b1d..f135fd3f23b 100644 --- a/src/main/jira/markdown-adf.test.ts +++ b/src/main/jira/markdown-adf.test.ts @@ -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('**「重要」**です 。次') + 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 \\*\\*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('\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( - ' [www.example.com](http://www.example.com) 。次\n\\&' + 'https://example.com/wiki/Foo_(bar) www.example.com [me@example.com](mailto:me@example.com)。次\n\\&' ) }) @@ -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: ' '.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', '。'], [ 'http://[::1]。next http://user@[::1]:8080/path', - '。next ' + '。next http://user@[::1]:8080/path' ], [ 'https://example.com/Foo_(bar)) www.example.com', - ') [www.example.com](http://www.example.com)' + ') www.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 \\*\\*'], // Intended: an overlapping local-part run does not create a second partial email. - ['a@b.c!x@d.e', '!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: ``, + 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('\\で\n\n`で`\n\n```\nで\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=で)') }) + +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 \\*\\*', + text: '**see https://example.com/x**', + linkText: 'https://example.com/x', + href: 'https://example.com/x' + }, + { + content: [{ type: 'text', text: '' }], + markdown: '\\<\\>', + text: '', + linkText: 'https://example.com', + href: 'https://example.com' + }, + { + content: [ + { type: 'inlineCard', attrs: { url: 'https://x.com/a' } }, + { type: 'text', text: '_note' } + ], + markdown: '\\_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: '****', + 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: '__', + 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() + 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!**b', '**a!**b'], + ['a**!b**', 'a**!b**'], + ['**a!**β', '**a!**β'] + ]) { + 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!~~中') + 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!~~中') + expect(await markdownToAdf(adfToMarkdownText(doc))).toEqual(doc) +}) diff --git a/src/main/jira/markdown-adf.ts b/src/main/jira/markdown-adf.ts index 993e9ab5012..8519ceaf28f 100644 --- a/src/main/jira/markdown-adf.ts +++ b/src/main/jira/markdown-adf.ts @@ -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 } }),