// src/utils/markdownToAdf.js // // Narrow, hand-rolled markdown → Atlassian Document Format converter. // // Grammar supported (deliberately minimal, matches what our chat // renderers actually emit — see commands/voiceStatus.js and // commands/avStatus.js): // // **text** → { type:'text', text, marks:[{type:'strong'}] } // *text* → { type:'text', text, marks:[{type:'em'}] } // [label](url) → { type:'text', text:label, marks:[{type:'link',attrs:{href:url}}] } // \n\n (blank line) → paragraph split // \n (single line) → { type:'hardBreak' } inside the paragraph // anything else → plain { type:'text', text } — emojis, arrows, // bullets, unicode symbols all pass through. // // Explicitly NOT supported: headings, bulletLists, fenced code, tables, // nested marks, block quotes, images. Our renderers don't produce any // of that; if they start to, we'll extend the grammar then rather than // pull in a full CommonMark library for a narrow use case. // // The parser is deliberately forgiving with malformed input. An unclosed // `**` becomes plain text — never an exception. That's the right call // for a converter that runs unattended on live ticket data. const LINK_RE = /\[([^\]\n]+?)\]\(([^)\n]+?)\)/; const STRONG_RE = /\*\*([^*\n][^*\n]*?)\*\*/; const EM_RE = /\*([^*\n]+?)\*/; // Attempt each pattern at the beginning of the remaining slice and // return the earliest match. Ties resolve by pattern order (link, // strong, em) — which happens to be the safe direction (link brackets // can't be confused with `*`, and `**` must be tried before `*`). function findEarliestMatch(text) { const candidates = [ { re: LINK_RE, kind: 'link' }, { re: STRONG_RE, kind: 'strong' }, { re: EM_RE, kind: 'em' }, ]; let best = null; for (const c of candidates) { const m = c.re.exec(text); if (m && (best === null || m.index < best.match.index)) { best = { kind: c.kind, match: m }; // Can't early-exit — a link at index 5 beats a strong at index 0 // is impossible (best keeps track), but a link at index 5 CAN // beat a strong at index 10. } } return best; } /** * Tokenize a single line (no `\n` in the input) into a sequence of * ADF inline nodes. Returns [] for empty input. */ export function tokenizeLine(line) { if (!line) return []; const nodes = []; let cursor = 0; while (cursor < line.length) { const rest = line.slice(cursor); const found = findEarliestMatch(rest); if (!found) { // No more markup — the rest is plain text. pushText(nodes, line.slice(cursor)); break; } // Emit whatever plain text sits before the match. if (found.match.index > 0) { pushText(nodes, rest.slice(0, found.match.index)); } // Emit the marked node. if (found.kind === 'link') { const [, label, url] = found.match; nodes.push({ type: 'text', text: label, marks: [{ type: 'link', attrs: { href: url } }], }); } else { // strong or em — both extract group 1 as inner text. const inner = found.match[1]; nodes.push({ type: 'text', text: inner, marks: [{ type: found.kind }], }); } cursor += found.match.index + found.match[0].length; } return nodes; } // Append a text node while collapsing adjacent runs of plain text. // ADF permits multiple sibling text nodes, but a single collapsed node // keeps the output tidy for humans skimming a rendered ADF payload. function pushText(nodes, text) { if (!text) return; const last = nodes[nodes.length - 1]; if (last && last.type === 'text' && !last.marks) { last.text += text; } else { nodes.push({ type: 'text', text }); } } /** * Convert a markdown string to an array of ADF block nodes (paragraphs * containing inline nodes and hardBreaks). Suitable for splicing into * a larger ADF document's `content` array — e.g. below a fixed header * paragraph in the Jira poller's `buildAdfComment`. * * Blank lines separate paragraphs. Non-blank lines within a paragraph * are joined with `hardBreak` nodes so line-oriented output like the * chat renderers survives visually intact. * * @param {string} markdown * @returns {Array} ADF content nodes (each a `paragraph`). */ export function markdownToAdfContent(markdown) { if (typeof markdown !== 'string' || markdown.trim() === '') return []; // Split into paragraph groups on blank-line boundaries. Preserve // relative order — leading/trailing blank lines just yield empty // groups that we drop. const groups = markdown .split(/\n\s*\n/) .map((g) => g.replace(/\n+$/, '')) .filter((g) => g.length > 0); const paragraphs = []; for (const group of groups) { const lines = group.split('\n'); const content = []; lines.forEach((line, i) => { const inline = tokenizeLine(line); if (inline.length > 0) content.push(...inline); if (i < lines.length - 1) { // Soft line break inside a paragraph. ADF's hardBreak renders // as an in-paragraph line break in the Jira viewer. content.push({ type: 'hardBreak' }); } }); // Skip paragraphs that ended up empty (e.g. a group that was just // whitespace lines). An empty ADF paragraph is legal but noisy. if (content.length === 0) continue; paragraphs.push({ type: 'paragraph', content }); } return paragraphs; } /** * Full-document convenience: wrap `markdownToAdfContent` in a valid * ADF `doc`. Callers that want to embed the paragraphs inside a * larger custom document (like the poller's header + body pattern) * should call `markdownToAdfContent` directly. * * @param {string} markdown * @returns {object} ADF document `{ version:1, type:'doc', content }`. */ export function markdownToAdf(markdown) { return { version: 1, type: 'doc', content: markdownToAdfContent(markdown), }; }