diff --git a/package-lock.json b/package-lock.json index f990cda4..f35503b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "jira.js": "^5.3.0", "js-yaml": "^4.1.1", "llmist": "^15.19.0", + "marklassian": "^1.1.0", "pg": "^8.18.0", "trello.js": "^1.2.8", "zangief": "latest", @@ -7882,6 +7883,15 @@ "marked": ">=1 <16" } }, + "node_modules/marklassian": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/marklassian/-/marklassian-1.1.0.tgz", + "integrity": "sha512-aR3o6Ig3GM5+iwZFTKBlAHy2Bcdd7auwra8RLWmcmygVq8AeQQKusdhzJ5s9AL1Xeu85Ax7mpuTmTorvKMByMA==", + "license": "MIT", + "dependencies": { + "marked": "^15.0.6 || ^16.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "license": "MIT", diff --git a/package.json b/package.json index b10a26c0..64c5401c 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "jira.js": "^5.3.0", "js-yaml": "^4.1.1", "llmist": "^15.19.0", + "marklassian": "^1.1.0", "pg": "^8.18.0", "trello.js": "^1.2.8", "zangief": "latest", diff --git a/src/pm/jira/adf.ts b/src/pm/jira/adf.ts index 7ff76901..5f74ed1c 100644 --- a/src/pm/jira/adf.ts +++ b/src/pm/jira/adf.ts @@ -3,139 +3,14 @@ * * JIRA Cloud's REST API v3 uses ADF for rich text fields. * These helpers convert between markdown and ADF. + * + * markdownToAdf: delegates to the `marklassian` library which handles + * tables, links, strikethrough, blockquotes, task lists, and more. + * + * adfToPlainText: custom reverse converter (ADF → plain text / markdown). */ -/** - * Convert a simple markdown string to ADF document. - * Handles paragraphs, headings, bullet lists, bold, inline code, and code blocks. - */ -function parseCodeBlock(lines: string[], startIndex: number): { node: unknown; nextIndex: number } { - const lang = lines[startIndex].slice(3).trim(); - const codeLines: string[] = []; - let i = startIndex + 1; - while (i < lines.length && !lines[i].startsWith('```')) { - codeLines.push(lines[i]); - i++; - } - return { - node: { - type: 'codeBlock', - attrs: lang ? { language: lang } : {}, - content: [{ type: 'text', text: codeLines.join('\n') }], - }, - nextIndex: i + 1, // skip closing ``` - }; -} - -function parseBulletList( - lines: string[], - startIndex: number, -): { node: unknown; nextIndex: number } { - const items: unknown[] = []; - let i = startIndex; - while (i < lines.length && lines[i].match(/^[-*]\s+/)) { - const itemText = lines[i].replace(/^[-*]\s+/, ''); - items.push({ - type: 'listItem', - content: [{ type: 'paragraph', content: inlineToAdf(itemText) }], - }); - i++; - } - return { node: { type: 'bulletList', content: items }, nextIndex: i }; -} - -export function markdownToAdf(markdown: string): unknown { - const lines = markdown.split('\n'); - const content: unknown[] = []; - let i = 0; - - while (i < lines.length) { - const line = lines[i]; - - if (line.startsWith('```')) { - const result = parseCodeBlock(lines, i); - content.push(result.node); - i = result.nextIndex; - continue; - } - - const headingMatch = line.match(/^(#{1,6})\s+(.+)/); - if (headingMatch) { - content.push({ - type: 'heading', - attrs: { level: headingMatch[1].length }, - content: inlineToAdf(headingMatch[2]), - }); - i++; - continue; - } - - if (line.match(/^[-*]\s+/)) { - const result = parseBulletList(lines, i); - content.push(result.node); - i = result.nextIndex; - continue; - } - - if (line.trim() === '') { - i++; - continue; - } - - content.push({ - type: 'paragraph', - content: inlineToAdf(line), - }); - i++; - } - - return { - type: 'doc', - version: 1, - content: content.length > 0 ? content : [{ type: 'paragraph', content: [] }], - }; -} - -/** - * Convert inline markdown to ADF inline nodes. - */ -function inlineToAdf(text: string): unknown[] { - const nodes: unknown[] = []; - // Simple approach: handle **bold**, `code`, and plain text - const regex = /(\*\*(.+?)\*\*|`([^`]+)`)/g; - let lastIndex = 0; - let match: RegExpExecArray | null = regex.exec(text); - - while (match !== null) { - // Add plain text before this match - if (match.index > lastIndex) { - nodes.push({ type: 'text', text: text.slice(lastIndex, match.index) }); - } - - if (match[2]) { - // Bold - nodes.push({ type: 'text', text: match[2], marks: [{ type: 'strong' }] }); - } else if (match[3]) { - // Inline code - nodes.push({ type: 'text', text: match[3], marks: [{ type: 'code' }] }); - } - - lastIndex = match.index + match[0].length; - match = regex.exec(text); - } - - // Add remaining plain text - if (lastIndex < text.length) { - nodes.push({ type: 'text', text: text.slice(lastIndex) }); - } - - // Fallback for empty - if (nodes.length === 0 && text) { - nodes.push({ type: 'text', text }); - } - - return nodes; -} +export { markdownToAdf } from 'marklassian'; /** * Convert ADF document to plain text. @@ -164,6 +39,27 @@ function convertAdfNode(n: AdfNode): string[] { return ['```', adfToPlainText(n), '```', '']; case 'text': return [n.text ?? '']; + case 'table': { + const rows = (n.content ?? []) as AdfNode[]; + const rowLines: string[] = []; + let headerSeparatorInserted = false; + for (const row of rows) { + const cells = (row.content ?? []) as AdfNode[]; + const cellTexts = cells.map((cell) => adfToPlainText(cell).trim()); + rowLines.push(`| ${cellTexts.join(' | ')} |`); + // Insert separator after the first row (header row) + if (!headerSeparatorInserted) { + rowLines.push(`| ${cells.map(() => '---').join(' | ')} |`); + headerSeparatorInserted = true; + } + } + return [...rowLines, '']; + } + case 'tableRow': + return [(n.content ?? []).map((cell) => adfToPlainText(cell)).join(' | ')]; + case 'tableHeader': + case 'tableCell': + return [adfToPlainText(n)]; default: return [adfToPlainText(n)]; } diff --git a/tests/unit/pm/jira/adf.test.ts b/tests/unit/pm/jira/adf.test.ts index 28a21550..a88f1bef 100644 --- a/tests/unit/pm/jira/adf.test.ts +++ b/tests/unit/pm/jira/adf.test.ts @@ -55,8 +55,9 @@ describe('markdownToAdf', () => { it('converts code blocks without language', () => { const md = '```\nsome code\n```'; const result = markdownToAdf(md) as { content: unknown[] }; - const block = result.content[0] as { attrs: Record }; - expect(block.attrs).toEqual({}); + // marklassian uses language: 'text' for unlabelled code blocks + const block = result.content[0] as { type: string; content: unknown[] }; + expect(block.type).toBe('codeBlock'); }); it('converts bold text', () => { @@ -86,9 +87,10 @@ describe('markdownToAdf', () => { expect(result.content[1]).toMatchObject({ type: 'paragraph' }); }); - it('returns empty paragraph for empty input', () => { + it('returns empty content for empty input', () => { const result = markdownToAdf('') as { content: unknown[] }; - expect(result.content).toEqual([{ type: 'paragraph', content: [] }]); + // marklassian returns an empty content array for empty input + expect(result.content).toEqual([]); }); it('handles mixed content', () => { @@ -97,6 +99,69 @@ describe('markdownToAdf', () => { const types = result.content.map((n) => n.type); expect(types).toEqual(['heading', 'paragraph', 'bulletList', 'codeBlock']); }); + + it('converts markdown tables to ADF table nodes', () => { + const md = '| Col1 | Col2 |\n| --- | --- |\n| A | B |'; + const result = markdownToAdf(md) as { content: unknown[] }; + expect(result.content).toHaveLength(1); + const table = result.content[0] as { type: string; content: unknown[] }; + expect(table.type).toBe('table'); + // Two rows: header row + data row + expect(table.content).toHaveLength(2); + const headerRow = table.content[0] as { type: string; content: unknown[] }; + expect(headerRow.type).toBe('tableRow'); + const headerCells = headerRow.content as Array<{ type: string }>; + expect(headerCells[0].type).toBe('tableHeader'); + expect(headerCells[1].type).toBe('tableHeader'); + }); + + it('converts markdown table header cell text', () => { + const md = '| Name | Age |\n| --- | --- |\n| Alice | 30 |'; + const result = markdownToAdf(md) as { + content: Array<{ + type: string; + content: Array<{ + type: string; + content: Array<{ type: string; content: Array<{ type: string; content: unknown[] }> }>; + }>; + }>; + }; + const table = result.content[0]; + const headerRow = table.content[0]; + const firstHeaderCell = headerRow.content[0]; + // tableHeader > paragraph > text + const para = firstHeaderCell.content[0]; + expect(para.type).toBe('paragraph'); + }); + + it('converts markdown links to ADF link marks', () => { + const md = '[Click here](https://example.com)'; + const result = markdownToAdf(md) as { content: unknown[] }; + const para = result.content[0] as { content: unknown[] }; + const linkNode = para.content[0] as { type: string; text: string; marks: unknown[] }; + expect(linkNode.type).toBe('text'); + expect(linkNode.text).toBe('Click here'); + expect(linkNode.marks).toContainEqual({ + type: 'link', + attrs: { href: 'https://example.com' }, + }); + }); + + it('converts inline link within text', () => { + const md = 'See [docs](https://docs.example.com) for details'; + const result = markdownToAdf(md) as { content: unknown[] }; + const para = result.content[0] as { content: unknown[] }; + // Should have text nodes including one with a link mark + const hasLink = para.content.some( + (n) => + typeof n === 'object' && + n !== null && + 'marks' in n && + Array.isArray((n as { marks: unknown[] }).marks) && + (n as { marks: Array<{ type: string }> }).marks.some((m) => m.type === 'link'), + ); + expect(hasLink).toBe(true); + }); }); describe('adfToPlainText', () => { @@ -217,6 +282,133 @@ describe('adfToPlainText', () => { const result = adfToPlainText(adf); expect(result).not.toMatch(/\n{3,}/); }); + + it('converts ADF table to markdown table format', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Col1' }] }], + }, + { + type: 'tableHeader', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Col2' }] }], + }, + ], + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }], + }, + { + type: 'tableCell', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] }], + }, + ], + }, + ], + }, + ], + }; + const result = adfToPlainText(adf); + // Should have a header row + expect(result).toContain('| Col1 | Col2 |'); + // Should have separator row + expect(result).toContain('| --- | --- |'); + // Should have data row + expect(result).toContain('| A | B |'); + }); + + it('converts ADF table with inline formatting in cells', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Name', marks: [{ type: 'strong' }] }], + }, + ], + }, + { + type: 'tableHeader', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Value' }] }], + }, + ], + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'key1' }] }], + }, + { + type: 'tableCell', + content: [{ type: 'paragraph', content: [{ type: 'text', text: '100' }] }], + }, + ], + }, + ], + }, + ], + }; + const result = adfToPlainText(adf); + expect(result).toContain('Name'); + expect(result).toContain('Value'); + expect(result).toContain('key1'); + expect(result).toContain('100'); + expect(result).toContain('---'); + }); + + it('handles empty table cells', () => { + const adf = { + type: 'doc', + version: 1, + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }], + }, + { + type: 'tableHeader', + content: [{ type: 'paragraph', content: [] }], + }, + ], + }, + ], + }, + ], + }; + const result = adfToPlainText(adf); + expect(result).toContain('| A |'); + }); }); describe('roundtrip: markdownToAdf -> adfToPlainText', () => { @@ -248,4 +440,36 @@ describe('roundtrip: markdownToAdf -> adfToPlainText', () => { const result = adfToPlainText(adf); expect(result).toContain('code here'); }); + + it('roundtrips a markdown table', () => { + const md = '| Header1 | Header2 |\n| --- | --- |\n| Cell1 | Cell2 |'; + const adf = markdownToAdf(md); + const result = adfToPlainText(adf); + // The result should be a recognizable markdown table + expect(result).toContain('| Header1 | Header2 |'); + expect(result).toContain('| --- | --- |'); + expect(result).toContain('| Cell1 | Cell2 |'); + }); + + it('roundtrips a table with multiple data rows', () => { + const md = '| Name | Score |\n| --- | --- |\n| Alice | 95 |\n| Bob | 87 |\n| Charlie | 92 |'; + const adf = markdownToAdf(md); + const result = adfToPlainText(adf); + expect(result).toContain('Alice'); + expect(result).toContain('Bob'); + expect(result).toContain('Charlie'); + expect(result).toContain('Name'); + expect(result).toContain('Score'); + }); + + it('roundtrips a table with inline formatting in cells', () => { + const md = '| **Bold** | Normal |\n| --- | --- |\n| `code` | plain |'; + const adf = markdownToAdf(md); + const result = adfToPlainText(adf); + // Should preserve cell content (text without marks in plain output) + expect(result).toContain('Bold'); + expect(result).toContain('Normal'); + expect(result).toContain('code'); + expect(result).toContain('plain'); + }); });