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('')
})
+
+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: '\\\\*',
+ 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 } }),