Replace /phonestatus follow-ups with /voicestatus, /wanstatus, /phonediag, and /dectdiag; extend /voicediag with relay probes and section-based MPP diagnostics. Co-authored-by: Cursor <cursoragent@cursor.com>
169 lines
5.9 KiB
JavaScript
169 lines
5.9 KiB
JavaScript
// 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<object>} 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),
|
|
};
|
|
}
|