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 } }),