import { slugifyHeading, type TocEntry, } from '../components/visibility/MarkdownView'; /** One parsed FAQ question/answer pair. */ export interface FaqPair { q: string; a: string; } /** * Whether a string carries HTML tags (vs Markdown or plain text). * * @param {string} value - Candidate string. * @return {boolean} True when an HTML tag is present. */ export const looksLikeHtml = (value: string): boolean => /<[a-z][\s\S]*>/i.test(value); /** * Convert HTML to readable plain text. Returns the raw string unchanged when * ``document`` is unavailable (e.g. during a non-browser test run). * * @param {string} value - HTML string. * @return {string} Plain text. */ export function htmlToPlainText(value: string): string { if (typeof document === 'undefined') return value; const container = document.createElement('div'); container.innerHTML = value; return (container.textContent || '').replace(/\n{3,}/g, '\n\n').trim(); } /** * Escape the HTML-significant characters in a string. * * @param {string} value - Raw text. * @return {string} Escaped text safe to splice into HTML. */ export function escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>'); } /** * Drop empty / whitespace-only ``mark`` / ``ins`` / ``del`` tags so the * rendered diff never shows stray highlight slivers. * * @param {string} value - HTML string. * @return {string} HTML without empty diff/highlight tags. */ export const dropEmptyDiffTags = (value: string): string => value.replace(/<(mark|ins|del)[^>]*>(\s*)<\/\1>/gi, '$2'); /** * Produce clean store-ready HTML from a diff: drop deletions entirely, unwrap * insertions and marks. * * @param {string} value - HTML containing ```` / ```` / ````. * @return {string} The final clean HTML. */ export const diffToCleanHtml = (value: string): string => value .replace(/]*>[\s\S]*?<\/del>/gi, '') .replace(/<\/?(ins|mark)[^>]*>/gi, ''); /** * Split a title+description result into title and body. The title is emitted * first, so take the leading h1/h2 (or ``#``/``##``) and leave the sections in * the body. * * @param {string} value - The generated content. * @return {{ title: string; body: string }} Extracted title and remaining body. */ export function splitTitleFromBody(value: string): { title: string; body: string; } { const htmlHeading = /]*>([\s\S]*?)<\/h\1>/i.exec(value); if (htmlHeading) { return { title: htmlHeading[2].replace(/<[^>]+>/g, '').trim(), body: value.replace(htmlHeading[0], '').trim(), }; } const lines = value.replace(/\r\n/g, '\n').split('\n'); for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { const heading = /^#{1,2}\s+(.*)$/.exec(lines[lineIndex].trim()); if (heading) { return { title: heading[1].trim(), body: [...lines.slice(0, lineIndex), ...lines.slice(lineIndex + 1)] .join('\n') .trim(), }; } if (lines[lineIndex].trim()) break; } return { title: '', body: value }; } /** * Inject deterministic ids into HTML headings and collect a TOC so a preview * can anchor-scroll to each section. * * @param {string} html - HTML string. * @return {{ html: string; toc: TocEntry[] }} HTML with heading ids + the TOC. */ export function injectHeadingIds(html: string): { html: string; toc: TocEntry[]; } { const toc: TocEntry[] = []; const seen = new Map(); const withIds = html.replace( /<(h[1-4])([^>]*)>([\s\S]*?)<\/\1>/gi, (match, tag: string, attrs: string, inner: string) => { const text = inner.replace(/<[^>]+>/g, '').trim(); if (!text) return match; const base = slugifyHeading(text); const count = (seen.get(base) ?? 0) + 1; seen.set(base, count); const slug = count > 1 ? `${base}-${count}` : base; toc.push({ level: Number(tag[1]), text, slug }); const attrsWithoutId = attrs.replace(/\s+id="[^"]*"/i, ''); return `<${tag}${attrsWithoutId} id="${slug}">${inner}`; } ); return { html: withIds, toc }; } /** * Parse a plain ``Q: ... / A: ...`` FAQ blob into question/answer pairs. * * @param {string} value - FAQ text. * @return {FaqPair[]} Parsed pairs (empty when the text isn't FAQ-shaped). */ export function parseFaqPairs(value: string): FaqPair[] { const pairs: FaqPair[] = []; const regex = /Q:\s*([\s\S]*?)\n\s*A:\s*([\s\S]*?)(?=\n\s*Q:|$)/g; let match: RegExpExecArray | null = regex.exec(value); while (match !== null) { pairs.push({ q: match[1].trim(), a: match[2].trim() }); match = regex.exec(value); } return pairs; } /** * Build store-ready FAQ HTML (mirrors the content generator's embedded-FAQ * shape) so Copy HTML works for FAQ output too. * * @param {FaqPair[]} pairs - Parsed FAQ pairs. * @return {string} ``section-faq`` HTML. */ export function faqPairsToHtml(pairs: FaqPair[]): string { const items = pairs .map( pair => `
\n

${escapeHtml(pair.q)}

\n

${escapeHtml(pair.a)}

\n
` ) .join('\n'); return `
\n${items}\n
`; }