mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
Read Jira documents directly while preserving Markdown semantics
Use the standard Markdown pipeline once per Cloud publication and retain the direct ADF visitor on read. Preserve emitted boundaries, compact tables and marked whitespace without modifying Markdown libraries. Co-authored-by: LDH1103 <ldh517525@gmail.com>
This commit is contained in:
co-authored by
LDH1103
parent
9c0999463c
commit
c9a2bbd71f
@@ -37,9 +37,7 @@ const BUNDLED_MAIN_DEPENDENCIES = new Set([
|
||||
'remark-parse',
|
||||
'remark-gfm',
|
||||
'remark-cjk-friendly',
|
||||
'mdast-util-to-markdown',
|
||||
'mdast-util-to-markdown-cjk-friendly',
|
||||
'mdast-util-gfm',
|
||||
'markdown-table',
|
||||
'hast-util-from-html-isomorphic',
|
||||
// Windows NSIS copies app.asar before external resources; bootstrap must not race resources/node_modules.
|
||||
'zod'
|
||||
|
||||
+1
-3
@@ -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",
|
||||
|
||||
Generated
+4
-10
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? '<br>' : '\n' })
|
||||
break
|
||||
case 'link': {
|
||||
const url = escapeMarkdownTextLinkDestination(node.url)
|
||||
if (url) {
|
||||
tokens.push({ value: '[', explicitLink: true })
|
||||
}
|
||||
inlineTokens(node.children, true, inTable, tokens)
|
||||
if (url) {
|
||||
tokens.push({ value: `](${escapeDestinationEntities(url)})` })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'strong':
|
||||
case 'emphasis':
|
||||
case 'delete': {
|
||||
// Different markers keep adjacent strong/emphasis runs separate without reparsing.
|
||||
const before = nodes[index - 1]
|
||||
const after = nodes[index + 1]
|
||||
const intraword =
|
||||
node.type === 'emphasis' &&
|
||||
((before?.type === 'text' && /[\p{L}\p{N}]$/u.test(before.value)) ||
|
||||
(after?.type === 'text' && /^[\p{L}\p{N}]/u.test(after.value)))
|
||||
const marker =
|
||||
node.type === 'strong' ? '**' : node.type === 'delete' ? '~~' : intraword ? '*' : '_'
|
||||
tokens.push({ value: marker, style: 'open' })
|
||||
inlineTokens(node.children, inLink, inTable, tokens)
|
||||
tokens.push({ value: marker, style: 'close' })
|
||||
break
|
||||
}
|
||||
default:
|
||||
if ('children' in node) {
|
||||
inlineTokens(node.children, inLink, inTable, tokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function supportsUriAutolink(value: string): boolean {
|
||||
for (const char of value) {
|
||||
if (char.charCodeAt(0) <= 0x20 || char === '<' || char === '>') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function renderInlineMarkdown(
|
||||
nodes: PhrasingContent[],
|
||||
inTable: boolean,
|
||||
heading: boolean
|
||||
): string {
|
||||
const tokens: InlineToken[] = []
|
||||
inlineTokens(nodes, false, inTable, tokens)
|
||||
const rightBoundary: boolean[] = []
|
||||
let boundary = true
|
||||
for (let index = tokens.length - 1; index >= 0; index--) {
|
||||
rightBoundary[index] = boundary
|
||||
if (tokens[index].value) {
|
||||
boundary = /^\s/u.test(tokens[index].value)
|
||||
}
|
||||
}
|
||||
const rendered: InlineToken[] = []
|
||||
boundary = true
|
||||
let lineStart = true
|
||||
for (const [index, token] of tokens.entries()) {
|
||||
if (!token.value) {
|
||||
continue
|
||||
}
|
||||
let value = token.value
|
||||
if (token.literal) {
|
||||
value = heading ? value.replace(/#/g, '\\#') : escapeBlockMarkers(value, lineStart)
|
||||
}
|
||||
let explicit = token.explicitLink
|
||||
if (token.url && !(boundary && rightBoundary[index])) {
|
||||
if (/^https?:\/\//i.test(value) && supportsUriAutolink(value)) {
|
||||
value = `<${value}>`
|
||||
} else {
|
||||
const url = escapeMarkdownTextLinkDestination(token.url)
|
||||
if (url) {
|
||||
value = `[${escapeLiteral(value, true)}](${escapeDestinationEntities(url)})`
|
||||
explicit = true
|
||||
}
|
||||
}
|
||||
}
|
||||
const previous = rendered.at(-1)
|
||||
if (explicit && previous?.value.endsWith('!')) {
|
||||
previous.value = `${previous.value.slice(0, -1)}\\!`
|
||||
}
|
||||
rendered.push({ ...token, value })
|
||||
boundary = /\s$/u.test(value)
|
||||
lineStart = value.endsWith('\n')
|
||||
}
|
||||
const nextCharacters: string[] = []
|
||||
let next = ''
|
||||
for (let index = rendered.length - 1; index >= 0; index--) {
|
||||
nextCharacters[index] = next
|
||||
next = firstCharacter(rendered[index].value)
|
||||
}
|
||||
const output: string[] = []
|
||||
let encodeNext = false
|
||||
for (const [index, token] of rendered.entries()) {
|
||||
let value = token.value
|
||||
if (encodeNext) {
|
||||
const first = firstCharacter(value)
|
||||
value = `${characterEntity(first)}${value.slice(first.length)}`
|
||||
}
|
||||
encodeNext = false
|
||||
const previous = output.at(-1) ?? ''
|
||||
const before = lastCharacter(previous)
|
||||
const after = nextCharacters[index]
|
||||
if (
|
||||
token.style === 'open' &&
|
||||
needsFlankingBoundary(before, token.value !== '~~') &&
|
||||
/[\p{P}\p{S}]/u.test(after)
|
||||
) {
|
||||
output[output.length - 1] = `${previous.slice(0, -before.length)}${characterEntity(before)}`
|
||||
}
|
||||
if (
|
||||
token.style === 'close' &&
|
||||
/[\p{P}\p{S}]/u.test(before) &&
|
||||
needsFlankingBoundary(after, token.value !== '~~')
|
||||
) {
|
||||
encodeNext = true
|
||||
}
|
||||
output.push(value)
|
||||
}
|
||||
return output.join('')
|
||||
}
|
||||
|
||||
function firstCharacter(value: string): string {
|
||||
return /^./u.exec(value)?.[0] ?? ''
|
||||
}
|
||||
|
||||
function lastCharacter(value: string): string {
|
||||
const last = value.charCodeAt(value.length - 1)
|
||||
return value.slice(last >= 0xdc00 && last <= 0xdfff ? -2 : -1)
|
||||
}
|
||||
|
||||
function needsFlankingBoundary(char: string, cjkAttention: boolean): boolean {
|
||||
// Only attention delimiters get the shared CJK grammar's relaxed word boundary.
|
||||
return (
|
||||
!!char &&
|
||||
!/[\s\p{P}\p{S}]/u.test(char) &&
|
||||
!(
|
||||
cjkAttention &&
|
||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(char)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function characterEntity(char: string): string {
|
||||
return `&#${char.codePointAt(0)};`
|
||||
}
|
||||
+216
-38
@@ -1,13 +1,14 @@
|
||||
import type { Text } from 'mdast'
|
||||
import type { JiraMediaMarkdown } from './adf-text-marks'
|
||||
import { escapeMarkdownLinkDestination } from './adf-media-destination'
|
||||
import { toMarkdown } from 'mdast-util-to-markdown'
|
||||
import { gfmToMarkdown } from 'mdast-util-gfm'
|
||||
import { cjkFriendlyToMarkdown } from 'mdast-util-to-markdown-cjk-friendly'
|
||||
import { adfBlockNodes } from './adf-blocks'
|
||||
import { renderAdfInline } from './adf-text-marks'
|
||||
import { markdownTable } from 'markdown-table'
|
||||
|
||||
type JiraAdfRecord = Record<string, unknown>
|
||||
|
||||
type MarkdownBlock = {
|
||||
kind: 'block' | 'list'
|
||||
text: string
|
||||
}
|
||||
|
||||
export type JiraAdfMediaAttrs = {
|
||||
id?: string
|
||||
url?: string
|
||||
@@ -26,10 +27,22 @@ function asRecord(value: unknown): JiraAdfRecord {
|
||||
return value && typeof value === 'object' ? (value as JiraAdfRecord) : {}
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
function headingLevel(value: unknown): number {
|
||||
return Math.min(Math.max(positiveInteger(value, 1), 1), 6)
|
||||
}
|
||||
|
||||
export function escapeMarkdownAlt(text: string): string {
|
||||
return text.replace(/[[\]]/g, '')
|
||||
}
|
||||
@@ -94,43 +107,208 @@ function renderMediaMarkdown(
|
||||
return unresolvedMediaPlaceholder(attrs)
|
||||
}
|
||||
|
||||
function renderInline(
|
||||
value: unknown,
|
||||
options?: AdfToMarkdownOptions,
|
||||
inTable = false,
|
||||
heading = false
|
||||
): string {
|
||||
return renderAdfInline(
|
||||
asArray(value),
|
||||
(node) => renderMediaMarkdown(node, options),
|
||||
inTable,
|
||||
heading
|
||||
)
|
||||
}
|
||||
|
||||
function joinBlocks(blocks: MarkdownBlock[]): string {
|
||||
return blocks
|
||||
.map((block) => block.text)
|
||||
.filter((text) => text.length > 0)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
function renderBlocks(content: unknown, options?: AdfToMarkdownOptions): MarkdownBlock[] {
|
||||
return asArray(content)
|
||||
.map((node) => renderBlock(node, options))
|
||||
.filter((block) => block.text.length > 0)
|
||||
}
|
||||
|
||||
function renderListItem(node: unknown, prefix: string, options?: AdfToMarkdownOptions): string {
|
||||
const blocks = renderBlocks(asRecord(node).content, options)
|
||||
if (blocks.length === 0) {
|
||||
return prefix.trimEnd()
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
const continuationIndent = ' '.repeat(prefix.length)
|
||||
blocks.forEach((block, blockIndex) => {
|
||||
const blockLines = block.text.split('\n')
|
||||
if (blockIndex === 0) {
|
||||
lines.push(`${prefix}${blockLines[0] ?? ''}`)
|
||||
blockLines.slice(1).forEach((line) => {
|
||||
lines.push(line ? `${continuationIndent}${line}` : '')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (block.kind !== 'list' && !block.text.startsWith('```')) {
|
||||
lines.push('')
|
||||
}
|
||||
blockLines.forEach((line) => {
|
||||
lines.push(line ? `${continuationIndent}${line}` : '')
|
||||
})
|
||||
})
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function renderList(
|
||||
record: JiraAdfRecord,
|
||||
ordered: boolean,
|
||||
options?: AdfToMarkdownOptions
|
||||
): string {
|
||||
const order = asRecord(record.attrs).order
|
||||
const start =
|
||||
ordered && typeof order === 'number' && Number.isInteger(order) && order >= 0 ? order : 1
|
||||
return asArray(record.content)
|
||||
.map((item, index) => renderListItem(item, ordered ? `${start + index}. ` : '- ', options))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function renderCodeBlock(record: JiraAdfRecord): MarkdownBlock {
|
||||
const text = asArray(record.content)
|
||||
.map((child) => asString(asRecord(child).text))
|
||||
.join('')
|
||||
let longest = 2
|
||||
for (const match of text.matchAll(/`+/g)) {
|
||||
longest = Math.max(longest, match[0].length)
|
||||
}
|
||||
const fence = '`'.repeat(longest + 1)
|
||||
const language = asString(asRecord(record.attrs).language).replace(/[\r\n`]/g, '')
|
||||
return { kind: 'block', text: `${fence}${language}\n${text}\n${fence}` }
|
||||
}
|
||||
|
||||
function escapeTablePipes(value: string): string {
|
||||
const result: string[] = []
|
||||
let slashes = 0
|
||||
for (const char of value) {
|
||||
if (char === '|' && slashes % 2 === 0) {
|
||||
result.push('\\')
|
||||
}
|
||||
result.push(char)
|
||||
slashes = char === '\\' ? slashes + 1 : 0
|
||||
}
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
function renderTable(record: JiraAdfRecord, options?: AdfToMarkdownOptions): string | null {
|
||||
const rows: string[][] = []
|
||||
for (const rawRow of asArray(record.content)) {
|
||||
const cells: string[] = []
|
||||
for (const rawCell of asArray(asRecord(rawRow).content)) {
|
||||
const cell = asRecord(rawCell)
|
||||
const attrs = asRecord(cell.attrs)
|
||||
const blocks = asArray(cell.content)
|
||||
if (
|
||||
positiveInteger(attrs.colspan, 1) !== 1 ||
|
||||
positiveInteger(attrs.rowspan, 1) !== 1 ||
|
||||
blocks.length !== 1 ||
|
||||
asRecord(blocks[0]).type !== 'paragraph'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const text = renderInline(asRecord(blocks[0]).content, options, true)
|
||||
cells.push(escapeTablePipes(text))
|
||||
}
|
||||
if (cells.length) {
|
||||
rows.push(cells)
|
||||
}
|
||||
}
|
||||
return rows.length ? markdownTable(rows, { alignDelimiters: false }) : ''
|
||||
}
|
||||
|
||||
function renderBlockquote(record: JiraAdfRecord, options?: AdfToMarkdownOptions): MarkdownBlock {
|
||||
const text = joinBlocks(renderBlocks(record.content, options))
|
||||
return {
|
||||
kind: 'block',
|
||||
text: text
|
||||
.split('\n')
|
||||
.map((line) => (line ? `> ${line}` : '>'))
|
||||
.join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
function renderBlock(node: unknown, options?: AdfToMarkdownOptions): MarkdownBlock {
|
||||
if (typeof node === 'string') {
|
||||
return { kind: 'block', text: node }
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return { kind: 'block', text: joinBlocks(renderBlocks(node, options)) }
|
||||
}
|
||||
if (!node || typeof node !== 'object') {
|
||||
return { kind: 'block', text: '' }
|
||||
}
|
||||
|
||||
const record = node as JiraAdfRecord
|
||||
const type = asString(record.type)
|
||||
if (type === 'doc') {
|
||||
return { kind: 'block', text: joinBlocks(renderBlocks(record.content, options)) }
|
||||
}
|
||||
if (type === 'paragraph') {
|
||||
return { kind: 'block', text: renderInline(record.content, options) }
|
||||
}
|
||||
if (type === 'heading') {
|
||||
const prefix = '#'.repeat(headingLevel(asRecord(record.attrs).level))
|
||||
return {
|
||||
kind: 'block',
|
||||
text: `${prefix} ${renderInline(record.content, options, false, true).trim()}`.trim()
|
||||
}
|
||||
}
|
||||
if (type === 'bulletList') {
|
||||
return { kind: 'list', text: renderList(record, false, options) }
|
||||
}
|
||||
if (type === 'orderedList') {
|
||||
return { kind: 'list', text: renderList(record, true, options) }
|
||||
}
|
||||
if (type === 'listItem') {
|
||||
return { kind: 'list', text: renderListItem(record, '- ', options) }
|
||||
}
|
||||
if (type === 'codeBlock') {
|
||||
return renderCodeBlock(record)
|
||||
}
|
||||
if (type === 'blockquote') {
|
||||
return renderBlockquote(record, options)
|
||||
}
|
||||
if (type === 'table') {
|
||||
const table = renderTable(record, options)
|
||||
if (table !== null) {
|
||||
return { kind: 'block', text: table }
|
||||
}
|
||||
}
|
||||
if (type === 'rule') {
|
||||
return { kind: 'block', text: '---' }
|
||||
}
|
||||
if (type === 'mediaSingle' || type === 'mediaGroup') {
|
||||
const mediaMarkdown = joinBlocks(renderBlocks(record.content, options))
|
||||
return { kind: 'block', text: mediaMarkdown }
|
||||
}
|
||||
if (type === 'media' || type === 'mediaInline') {
|
||||
return { kind: 'block', text: renderMediaMarkdown(record, options) }
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'block',
|
||||
text: joinBlocks(renderBlocks(record.content, options)) || renderInline([record], options)
|
||||
}
|
||||
}
|
||||
|
||||
export function adfToMarkdownText(value: unknown, options?: AdfToMarkdownOptions): string {
|
||||
// Server/DC sends wiki strings rather than ADF; preserve its existing normalization.
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
const root = asRecord(value)
|
||||
const content = root.type === 'doc' ? root.content : [value]
|
||||
return toMarkdown(
|
||||
{
|
||||
type: 'root',
|
||||
children: adfBlockNodes(content, (node) => renderMediaMarkdown(node, options))
|
||||
},
|
||||
{
|
||||
extensions: [gfmToMarkdown(), cjkFriendlyToMarkdown()],
|
||||
bullet: '-',
|
||||
emphasis: '*',
|
||||
strong: '*',
|
||||
fences: true,
|
||||
listItemIndent: 'one',
|
||||
rule: '-',
|
||||
ruleSpaces: false,
|
||||
handlers: {
|
||||
break: () => '\n',
|
||||
jiraMedia: (node: JiraMediaMarkdown) => node.value,
|
||||
text: (node: Text, parent, state, info) =>
|
||||
state
|
||||
.safe(node.value, {
|
||||
...info,
|
||||
beforeNode: parent?.children[(state.indexStack.at(-1) ?? 0) - 1],
|
||||
afterNode: parent?.children[(state.indexStack.at(-1) ?? 0) + 1]
|
||||
})
|
||||
.replace(/([\p{L}\p{N}])\\_(?=[\p{L}\p{N}])/gu, '$1_')
|
||||
.replace(/\\&(?!(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[\da-fA-F]+);)/g, '&')
|
||||
}
|
||||
}
|
||||
).replace(/\n$/, '')
|
||||
return renderBlock(value, options).text
|
||||
}
|
||||
|
||||
@@ -4,16 +4,23 @@ import { httpCandidateNormalizer, type HttpLinkSpan } from './adf-http-link'
|
||||
import { bareEmailSpans } from './adf-bare-email'
|
||||
import { escapeMarkdownTextLinkDestination } from './adf-media-destination'
|
||||
import { adfChildren, adfRecord, adfString } from './adf-nodes'
|
||||
import { renderInlineMarkdown } from './adf-inline-markdown'
|
||||
|
||||
export type JiraMediaMarkdown = Literal & { type: 'jiraMedia'; value: string }
|
||||
type JiraMarkdownLiteral = Literal & {
|
||||
type: 'jiraMedia' | 'jiraAutolink'
|
||||
value: string
|
||||
url?: string
|
||||
}
|
||||
declare module 'mdast' {
|
||||
// oxlint-disable-next-line typescript/consistent-type-definitions -- Module augmentation requires declaration merging.
|
||||
interface RootContentMap {
|
||||
jiraMedia: JiraMediaMarkdown
|
||||
jiraMedia: JiraMarkdownLiteral
|
||||
jiraAutolink: JiraMarkdownLiteral
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-definitions -- Module augmentation requires declaration merging.
|
||||
interface PhrasingContentMap {
|
||||
jiraMedia: JiraMediaMarkdown
|
||||
jiraMedia: JiraMarkdownLiteral
|
||||
jiraAutolink: JiraMarkdownLiteral
|
||||
}
|
||||
}
|
||||
type Wrapper = Extract<PhrasingContent, { type: 'strong' | 'emphasis' | 'delete' | 'link' }>
|
||||
@@ -57,7 +64,7 @@ function proseNodes(value: string): PhrasingContent[] {
|
||||
result.push({ type: 'text', value: value.slice(offset, next.start) })
|
||||
}
|
||||
const label = value.slice(next.start, next.end)
|
||||
result.push({ type: 'link', url: next.url, children: [{ type: 'text', value: label }] })
|
||||
result.push({ type: 'jiraAutolink', value: label, url: next.url })
|
||||
offset = next.end
|
||||
}
|
||||
if (offset < value.length) {
|
||||
@@ -90,6 +97,18 @@ function appendWrapped(
|
||||
}
|
||||
}
|
||||
|
||||
export const trailingWhitespaceReader = {
|
||||
read: (value: string, index: number): string => value[index]
|
||||
}
|
||||
|
||||
function trailingWhitespaceStart(value: string): number {
|
||||
let end = value.length
|
||||
while (end > 0 && /^\s$/u.test(trailingWhitespaceReader.read(value, end - 1))) {
|
||||
end--
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
function normalizedStyles(nodes: PhrasingContent[]): PhrasingContent[] {
|
||||
const normalized = nodes.flatMap((node): PhrasingContent[] => {
|
||||
if (!('children' in node)) {
|
||||
@@ -105,7 +124,8 @@ function normalizedStyles(nodes: PhrasingContent[]): PhrasingContent[] {
|
||||
if (first?.type === 'text') {
|
||||
first.value = first.value.slice(leading.length)
|
||||
}
|
||||
const trailing = last?.type === 'text' ? (/\s+$/.exec(last.value)?.[0] ?? '') : ''
|
||||
const trailing =
|
||||
last?.type === 'text' ? last.value.slice(trailingWhitespaceStart(last.value)) : ''
|
||||
if (last?.type === 'text' && trailing) {
|
||||
last.value = last.value.slice(0, -trailing.length)
|
||||
}
|
||||
@@ -174,3 +194,12 @@ export function adfInlineNodes(
|
||||
}
|
||||
return normalizedStyles(result)
|
||||
}
|
||||
|
||||
export function renderAdfInline(
|
||||
value: unknown,
|
||||
renderMedia: MediaRenderer,
|
||||
inTable = false,
|
||||
heading = false
|
||||
): string {
|
||||
return renderInlineMarkdown(adfInlineNodes(value, renderMedia, inTable), inTable, heading)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,20 @@ import { emailCharacterReader } from './adf-bare-email'
|
||||
import { httpCandidateNormalizer, httpCharacterReader } from './adf-http-link'
|
||||
import { markdownToAdf } from './markdown-adf'
|
||||
import { adfToMarkdownText } from './adf-markdown'
|
||||
import { trailingWhitespaceReader } from './adf-text-marks'
|
||||
|
||||
it('round-trips supported block structure and mixed inline marks', async () => {
|
||||
const source =
|
||||
'# Heading\n\n**one [two](https://example.com) three**\n\n*em* ~~strike~~ `code` [Ada](mailto:ada@example.com)\n\n3. item\n - nested\n\n> quote\n\n---'
|
||||
expect(adfToMarkdownText(await markdownToAdf(source))).toBe(source)
|
||||
expect(adfToMarkdownText(await markdownToAdf(source))).toBe(
|
||||
'# Heading\n\n**one [two](https://example.com) three**\n\n_em_ ~~strike~~ `code` [Ada](mailto:ada@example.com)\n\n3. item\n - nested\n\n> quote\n\n---'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves GFM table cells, literal pipes and inline code on read-back', async () => {
|
||||
const source = '| Name | Value |\n| --- | --- |\n| **bold** | `a\\|b` |\n| empty | |'
|
||||
expect(adfToMarkdownText(await markdownToAdf(source))).toBe(
|
||||
'| Name | Value |\n| -------- | ------ |\n| **bold** | `a\\|b` |\n| empty | |'
|
||||
'| Name | Value |\n| - | - |\n| **bold** | `a\\|b` |\n| empty | |'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -99,7 +102,9 @@ it('uses the renderer CJK emphasis and prose URL boundaries', async () => {
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(adfToMarkdownText(doc)).toBe('**「重要」**です <https://example.com>。次')
|
||||
expect(adfToMarkdownText(doc)).toBe(
|
||||
'**「重要」**です [https://example.com](https://example.com)。次'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps code whitespace, language and embedded fences including in lists', async () => {
|
||||
@@ -143,7 +148,7 @@ it('escapes active literal syntax while keeping identifiers and bare links reada
|
||||
]
|
||||
}
|
||||
expect(adfToMarkdownText(doc)).toBe(
|
||||
'snake_case & plain <https://example.com/a_b?x=1&y=2> <me@example.com> \\*\\*x\\*\\* \\[x]'
|
||||
'snake_case & plain https://example.com/a_b?x=1&y=2 me@example.com \\*\\*x\\*\\* \\[x]'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -201,7 +206,7 @@ it('keeps Jira smart links active and ordinary hard breaks readable', () => {
|
||||
}
|
||||
]
|
||||
})
|
||||
).toBe('<https://example.com/a_b?x=1&y=2>\nnext line')
|
||||
).toBe('https://example.com/a_b?x=1&y=2\nnext line')
|
||||
})
|
||||
|
||||
it('retains reference destinations used by inert source fallbacks', async () => {
|
||||
@@ -246,7 +251,7 @@ it('preserves existing parenthesized and www links and split literal entities',
|
||||
]
|
||||
})
|
||||
).toBe(
|
||||
'<https://example.com/wiki/Foo_(bar)> [www.example.com](http://www.example.com) <me@example.com>。次\n\\&'
|
||||
'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: '<me@example.com> '.repeat(addresses) + 'a'.repeat(length % 15)
|
||||
expected: 'me@example.com '.repeat(addresses) + 'a'.repeat(length % 15)
|
||||
}
|
||||
]
|
||||
for (const { value, expected } of fixtures) {
|
||||
@@ -295,19 +300,19 @@ it('preserves prose-separated URLs, IPv6 authorities and surrounding literal del
|
||||
['https://a.com。https://b.com', '<https://a.com>。<https://b.com>'],
|
||||
[
|
||||
'http://[::1]。next http://user@[::1]:8080/path',
|
||||
'<http://[::1]>。next <http://user@[::1]:8080/path>'
|
||||
'<http://[::1]>。next http://user@[::1]:8080/path'
|
||||
],
|
||||
[
|
||||
'https://example.com/Foo_(bar)) www.example.com',
|
||||
'<https://example.com/Foo_(bar)>) [www.example.com](http://www.example.com)'
|
||||
'<https://example.com/Foo_(bar)>) www.example.com'
|
||||
],
|
||||
[
|
||||
'https://example.com/me@example.com me@example.com',
|
||||
'<https://example.com/me@example.com> <me@example.com>'
|
||||
'https://example.com/me@example.com me@example.com'
|
||||
],
|
||||
['**see https://example.com/x**', '\\*\\*see <https://example.com/x>\\*\\*'],
|
||||
// Intended: an overlapping local-part run does not create a second partial email.
|
||||
['a@b.c!x@d.e', '<a@b.c>!x\\@d.e']
|
||||
['a@b.c!x@d.e', '[a@b.c](mailto:a@b.c)!x@d.e']
|
||||
]
|
||||
for (const [value, expected] of values) {
|
||||
expect(
|
||||
@@ -329,7 +334,7 @@ it('bounds actual HTTP normalization reads and admission through document read-b
|
||||
const fixtures = [
|
||||
{
|
||||
value: interior,
|
||||
expected: `<https://example.com/${')'.repeat(length)}x>`,
|
||||
expected: `https://example.com/${')'.repeat(length)}x`,
|
||||
ranges: [[0, interior.length]]
|
||||
},
|
||||
{
|
||||
@@ -383,7 +388,7 @@ it('bounds actual HTTP normalization reads and admission through document read-b
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps literal entity spellings and code bytes with the CJK serializer', async () => {
|
||||
it('keeps literal entity spellings and code bytes in direct read-back', async () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
@@ -407,6 +412,23 @@ it('keeps literal entity spellings and code bytes with the CJK serializer', asyn
|
||||
}
|
||||
const markdown = adfToMarkdownText(doc)
|
||||
expect(markdown).toContain('\\で\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 <https://example.com/x>\\*\\*',
|
||||
text: '**see https://example.com/x**',
|
||||
linkText: 'https://example.com/x',
|
||||
href: 'https://example.com/x'
|
||||
},
|
||||
{
|
||||
content: [{ type: 'text', text: '<https://example.com>' }],
|
||||
markdown: '\\<<https://example.com>\\>',
|
||||
text: '<https://example.com>',
|
||||
linkText: 'https://example.com',
|
||||
href: 'https://example.com'
|
||||
},
|
||||
{
|
||||
content: [
|
||||
{ type: 'inlineCard', attrs: { url: 'https://x.com/a' } },
|
||||
{ type: 'text', text: '_note' }
|
||||
],
|
||||
markdown: '<https://x.com/a>\\_note',
|
||||
text: 'https://x.com/a_note',
|
||||
linkText: 'https://x.com/a',
|
||||
href: 'https://x.com/a'
|
||||
},
|
||||
{
|
||||
content: [{ type: 'text', text: 'https://x.com/a', marks: [{ type: 'strong' }] }],
|
||||
markdown: '**<https://x.com/a>**',
|
||||
text: 'https://x.com/a',
|
||||
linkText: 'https://x.com/a',
|
||||
href: 'https://x.com/a',
|
||||
style: 'strong'
|
||||
},
|
||||
{
|
||||
content: [{ type: 'text', text: 'https://x.com/a', marks: [{ type: 'em' }] }],
|
||||
markdown: '_<https://x.com/a>_',
|
||||
text: 'https://x.com/a',
|
||||
linkText: 'https://x.com/a',
|
||||
href: 'https://x.com/a',
|
||||
style: 'em'
|
||||
},
|
||||
{
|
||||
content: [{ type: 'text', text: '!www.example.com*' }],
|
||||
markdown: '\\\\*',
|
||||
text: '!www.example.com*',
|
||||
linkText: 'www.example.com',
|
||||
href: 'http://www.example.com'
|
||||
},
|
||||
{
|
||||
content: [
|
||||
{ type: 'text', text: '!' },
|
||||
{ type: 'text', text: 'me@example.com', marks: [{ type: 'strong' }] }
|
||||
],
|
||||
markdown: '!**[me@example.com](mailto:me@example.com)**',
|
||||
text: '!me@example.com',
|
||||
linkText: 'me@example.com',
|
||||
href: 'mailto:me@example.com',
|
||||
style: 'strong'
|
||||
}
|
||||
]
|
||||
for (const { content, markdown, text, linkText, href, style } of cases) {
|
||||
const actual = adfToMarkdownText({ type: 'doc', content: [{ type: 'paragraph', content }] })
|
||||
expect(actual).toBe(markdown)
|
||||
const doc = await markdownToAdf(actual)
|
||||
expect(doc).toMatchObject({ content: [{ type: 'paragraph' }] })
|
||||
const blocks = doc.content as { content: { text: string; marks?: unknown[] }[] }[]
|
||||
expect(blocks[0].content.map((node) => node.text).join('')).toBe(text)
|
||||
expect(blocks[0].content).toContainEqual({
|
||||
type: 'text',
|
||||
text: linkText,
|
||||
marks: [...(style ? [{ type: style }] : []), { type: 'link', attrs: { href } }]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds actual trailing-whitespace reads through marked document read-back at N and 10N', () => {
|
||||
for (const length of [800, 8000]) {
|
||||
const spaces = ' '.repeat(length)
|
||||
const cases = [
|
||||
{ text: `a${spaces}b`, marks: ['strong'], expected: `**a${spaces}b**`, bound: 1, suffix: 1 },
|
||||
{
|
||||
text: `a${spaces}`,
|
||||
marks: ['strong'],
|
||||
expected: `**a**${spaces}`,
|
||||
bound: length + 1,
|
||||
suffix: length + 1
|
||||
},
|
||||
{
|
||||
text: `a${spaces}`,
|
||||
marks: ['strong', 'em', 'strike'],
|
||||
expected: `**_~~a~~_**${spaces}`,
|
||||
bound: 3 * (length + 1),
|
||||
suffix: length + 1
|
||||
}
|
||||
]
|
||||
for (const { text, marks, expected, bound, suffix } of cases) {
|
||||
const visited = new Set<number>()
|
||||
let reads = 0
|
||||
const probe = vi
|
||||
.spyOn(trailingWhitespaceReader, 'read')
|
||||
.mockImplementation((value, index) => {
|
||||
reads++
|
||||
visited.add(index)
|
||||
return value[index]
|
||||
})
|
||||
try {
|
||||
expect(
|
||||
adfToMarkdownText({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text, marks: marks.map((type) => ({ type })) }]
|
||||
}
|
||||
]
|
||||
})
|
||||
).toBe(expected)
|
||||
expect(reads, 'trailing whitespace endpoint coverage').toBeGreaterThan(0)
|
||||
expect(reads).toBeLessThanOrEqual(bound)
|
||||
for (let index = text.length - suffix; index < text.length; index++) {
|
||||
expect(visited.has(index), 'trailing whitespace endpoint coverage').toBe(true)
|
||||
}
|
||||
} finally {
|
||||
probe.mockRestore()
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [text, expected] of [
|
||||
['a\u00a0', '**a**\u00a0'],
|
||||
['', ''],
|
||||
[' ', ' ']
|
||||
]) {
|
||||
expect(
|
||||
adfToMarkdownText({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text, marks: [{ type: 'strong' }] }] }
|
||||
]
|
||||
})
|
||||
).toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('retains literal heading hashes and punctuation-edged marks on readback', async () => {
|
||||
for (const [source, markdown] of [
|
||||
['# a \\#', '# a \\#'],
|
||||
['# \\#', '# \\#'],
|
||||
['**a!**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)
|
||||
})
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
Reference in New Issue
Block a user