From 27fce586a5bb3ead61c2db19996e6b0df6752dd8 Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 24 Aug 2026 22:58:31 +0300 Subject: [PATCH 1/7] Vendor notes markdown rendering library with mermaid, math, and syntax highlighting Pure TypeScript, zero runtime dependencies. Supports: - GFM tables, task lists, definition lists, blockquotes - Fenced code blocks with syntax highlighting - Mermaid diagrams (flowchart, sequence, gantt, pie, state, class, ER, git, mindmap, journey, quadrant, timeline, C4, sankey, xychart) - Math rendering (189484 ... 189484) - Footnotes, link references - Injection-safe HTML output Signed-off-by: Alexander Bokovoy --- src/docs/lib/block-html.ts | 96 ++++ src/docs/lib/frontmatter.ts | 75 +++ src/docs/lib/index.ts | 45 ++ src/docs/lib/md/blocks.ts | 683 +++++++++++++++++++++++ src/docs/lib/md/highlight.ts | 232 ++++++++ src/docs/lib/md/inline.ts | 597 ++++++++++++++++++++ src/docs/lib/md/math.ts | 159 ++++++ src/docs/lib/md/mermaid-diagrams.ts | 817 +++++++++++++++++++++++++++ src/docs/lib/md/mermaid-git.ts | 428 +++++++++++++++ src/docs/lib/md/mermaid-misc.ts | 582 ++++++++++++++++++++ src/docs/lib/md/mermaid-tables.ts | 429 +++++++++++++++ src/docs/lib/md/mermaid.ts | 820 ++++++++++++++++++++++++++++ src/docs/lib/util.ts | 66 +++ 13 files changed, 5029 insertions(+) create mode 100644 src/docs/lib/block-html.ts create mode 100644 src/docs/lib/frontmatter.ts create mode 100644 src/docs/lib/index.ts create mode 100644 src/docs/lib/md/blocks.ts create mode 100644 src/docs/lib/md/highlight.ts create mode 100644 src/docs/lib/md/inline.ts create mode 100644 src/docs/lib/md/math.ts create mode 100644 src/docs/lib/md/mermaid-diagrams.ts create mode 100644 src/docs/lib/md/mermaid-git.ts create mode 100644 src/docs/lib/md/mermaid-misc.ts create mode 100644 src/docs/lib/md/mermaid-tables.ts create mode 100644 src/docs/lib/md/mermaid.ts create mode 100644 src/docs/lib/util.ts diff --git a/src/docs/lib/block-html.ts b/src/docs/lib/block-html.ts new file mode 100644 index 000000000..6e7105a93 --- /dev/null +++ b/src/docs/lib/block-html.ts @@ -0,0 +1,96 @@ +/** + * Per-block HTML renderer — one block at a time (not the full document). + * Vendored from md-notes, returns inner HTML for a single BlockNode. + */ +import type { BlockNode } from './md/blocks.js'; +import { renderInline, escapeHtml as escapeText } from './md/inline.js'; +import type { InlineContext } from './md/inline.js'; +import { renderMermaidSvg } from './md/mermaid.js'; +import { renderMathHtml } from './md/math.js'; +import { highlightCode, isHighlightable } from './md/highlight.js'; + +function slugForHeading(text: string): string { + return text.replace(/[`*_~[\]()#!]/g, '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'heading'; +} + +function escapeAttrId(s: string): string { + return s.replace(/&/g, '&').replace(/"/g, '"').replace(/ }; + +export function blockHtml(block: BlockNode, opts: RenderCtx = {}): string { + const ids = opts.ids ?? new Set(); + switch (block.type) { + case 'heading': { + const lvl = Math.min(6, Math.max(1, block.level)); + if (!block.id) { + const base = slugForHeading(block.text); + let id = base; + let k = 2; + while (ids.has(id)) id = `${base}-${k++}`; + ids.add(id); + block.id = id; + } + return `${renderInline(block.text, opts)}`; + } + case 'paragraph': { + let html = ''; + for (let k = 0; k < block.lines.length; k++) { + const l = block.lines[k]; + if (k > 0) { + const prev = block.lines[k - 1]; + html += /[\\]$/.test(prev) || /[ \t]{2}$/.test(prev) ? '
' : ' '; + } + html += renderInline(l.endsWith('\\') ? l.slice(0, -1) : l, opts); + } + return `

${html}

`; + } + case 'code': + if (/^(mermaid|stateDiagram(-v2)?)$/i.test(block.lang)) { + const svg = renderMermaidSvg(block.code); + if (svg) return `
${svg}
`; + return `
${escapeText(block.code)}
`; + } + const hl = block.lang && isHighlightable(block.lang) ? highlightCode(block.code, block.lang) : ''; + return `
${hl || escapeText(block.code)}
`; + case 'math': + return `
${renderMathHtml(block.tex)}
`; + case 'dl': { + const parts: string[] = ['
']; + for (const e of block.entries) { + for (const t of e.terms) parts.push(`
${renderInline(t, opts)}
`); + for (const d of e.defs) parts.push(`
${d.map((l) => renderInline(l, opts)).join(' ')}
`); + } + parts.push('
'); + return parts.join(''); + } + case 'quote': + return `
${block.children.map((c) => blockHtml(c, { ...opts, ids })).join('')}
`; + case 'list': { + const tag = block.ordered ? 'ol' : 'ul'; + const lis = block.items.map((it) => { + const inner = it.blocks.map((b) => blockHtml(b, { ...opts, ids })).join(''); + if (it.task) { + return `
  • ${inner}
  • `; + } + return `
  • ${inner}
  • `; + }).join(''); + return `<${tag} class="doc-list">${lis}`; + } + case 'table': + return `${[ + '', + ...block.header.map((h, i) => `${renderInline(h, opts)}`), + '', + ...block.rows.map((r) => `${r.map((cell, i) => `${renderInline(cell, opts)}`).join('')}`), + '', + ].join('')}
    `; + case 'hr': + return ''; + } +} \ No newline at end of file diff --git a/src/docs/lib/frontmatter.ts b/src/docs/lib/frontmatter.ts new file mode 100644 index 000000000..8f83b432a --- /dev/null +++ b/src/docs/lib/frontmatter.ts @@ -0,0 +1,75 @@ +/** + * Minimal YAML-subset frontmatter: `key: value` lines, inline lists `[a, b]`, + * optional quotes. Enough for note metadata; no external parser needed. + */ + +export type MetaValue = string | string[]; +export type Meta = Record; + +const DELIM = /^\s*---\s*$|^\s*\.\.\.\s*$/; + +/** Parse leading frontmatter from a document. Returns null if absent/invalid. */ +export function parseFrontmatter(text: string): { meta: Meta | null; body: string } { + const lines = text.split('\n'); + if (lines[0] === undefined || !DELIM.test(lines[0])) return { meta: null, body: text }; + + let close = -1; + for (let i = 1; i < Math.min(lines.length, 60); i++) { + const l = lines[i]; + if (!l.trim()) continue; + if (DELIM.test(l)) { + close = i; + break; + } + if (!/^[A-Za-z_][\w-]*:/.test(l)) return { meta: null, body: text }; // not frontmatter-shaped + } + if (close === -1) return { meta: null, body: text }; + + const meta: Meta = {}; + for (let i = 1; i < close; i++) { + const line = lines[i]; + if (!line.trim()) continue; + const m = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/); + if (!m) continue; + meta[m[1]] = parseValue(m[2].trim()); + } + return { meta, body: lines.slice(close + 1).join('\n').replace(/^\n+/, '') }; +} + +function parseValue(raw: string): MetaValue { + if (raw.startsWith('[') && raw.endsWith(']')) { + const inner = raw.slice(1, -1).trim(); + if (!inner) return []; + return inner.split(',').map((p) => unquote(p.trim())).filter(Boolean); + } + return unquote(raw); +} + +function unquote(s: string): string { + if (s.length >= 2 && ((s[0] === '"' && s.endsWith('"')) || (s[0] === "'" && s.endsWith("'")))) { + return s.slice(1, -1).replace(/\\(["'\\])/g, '$1'); + } + return s; +} + +export interface MetaToSerialize extends Record {} + +/** Serialize a metadata object into the frontmatter block (without body). */ +export function serializeFrontmatter(meta: MetaToSerialize): string { + const lines = ['---']; + for (const [key, value] of Object.entries(meta)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + lines.push(`${key}: ${value.length ? `[${value.join(', ')}]` : '[]'}`); + } else if (value !== '') { + lines.push(`${key}: ${quoteIfNeeded(String(value))}`); + } + } + lines.push('---'); + return lines.join('\n'); +} + +function quoteIfNeeded(s: string): string { + if (/[:\[\],#]/.test(s) || s !== s.trim()) return `"${s.replace(/"/g, '\\"')}"`; + return s; +} diff --git a/src/docs/lib/index.ts b/src/docs/lib/index.ts new file mode 100644 index 000000000..cfba243a5 --- /dev/null +++ b/src/docs/lib/index.ts @@ -0,0 +1,45 @@ +/** + * Markdown rendering library — vendored from md-notes. + * Pure TypeScript, zero runtime dependencies. + * + * Parser + renderer with support for: + * - GFM tables, task lists, definition lists + * - Fenced code blocks with syntax highlighting + * - Math ($$ ... $$) + * - Mermaid diagrams (flowcharts, sequence, gantt, class, state, pie, git, mindmap, er, journey, quadrant, sankey, c4, xychart, timeline) + * - Footnotes, link references + * - Injection-safe HTML output + */ + +import { parseBlocks, renderBlocksHtml, extractDefinitions } from './md/blocks.js'; +import type { BlockNode, RenderOptions } from './md/blocks.js'; +import { renderMathHtml } from './md/math.js'; +import { renderMermaidSvg } from './md/mermaid.js'; +import { parseFrontmatter } from './frontmatter.js'; +import type { Meta } from './frontmatter.js'; + +export interface ParsedDoc { + meta: Meta | null; + blocks: BlockNode[]; + refs: Map; + footnotes: Map; +} + +export function parseMarkdown(source: string): ParsedDoc { + const { meta, body } = parseFrontmatter(source); + const defs = extractDefinitions(body); + return { + meta, + blocks: parseBlocks(defs.cleaned), + refs: defs.refs, + footnotes: defs.footnotes, + }; +} + +export function renderHtml(source: string, opts?: RenderOptions): string { + const doc = parseMarkdown(source); + return renderBlocksHtml(doc.blocks, opts); +} + +export { renderBlocksHtml, parseBlocks, renderMathHtml, renderMermaidSvg }; +export type { BlockNode, RenderOptions, Meta }; \ No newline at end of file diff --git a/src/docs/lib/md/blocks.ts b/src/docs/lib/md/blocks.ts new file mode 100644 index 000000000..bb87b8e94 --- /dev/null +++ b/src/docs/lib/md/blocks.ts @@ -0,0 +1,683 @@ +/** + * Block-level markdown parser. Pure TS, no DOM. + * Every parsed node records the raw source lines it came from — that is what + * makes "click a rendered block → edit its exact markdown" possible in the UI. + */ + +import { renderInline, escapeHtml } from './inline.js'; +import type { InlineContext } from './inline.js'; +import { parseFrontmatter } from '../frontmatter.js'; +import { slugify } from '../util.js'; +import { renderMathHtml } from './math.js'; +import { renderMermaidSvg } from './mermaid.js'; +import { highlightCode, isHighlightable } from './highlight.js'; + +export interface HeadingBlock { type: 'heading'; level: number; text: string; id?: string; start: number; end: number; raw: string } +export interface ParagraphBlock { type: 'paragraph'; lines: string[]; start: number; end: number; raw: string } +export interface CodeBlock { type: 'code'; lang: string; code: string; info?: string; start: number; end: number; raw: string } +export interface QuoteBlock { type: 'quote'; children: BlockNode[]; start: number; end: number; raw: string } +/** Public shape of a list item. */ +export interface ListItem { + blocks: BlockNode[]; + /** GFM-style task list item (`- [ ] text` / `- [x] text`). */ + task?: boolean; + checked?: boolean; +} +export interface ListBlock { type: 'list'; ordered: boolean; items: ListItem[]; start: number; end: number; raw: string } +export interface HrBlock { type: 'hr'; start: number; end: number; raw: string } +export type Align = null | 'left' | 'center' | 'right'; +export interface TableBlock { + type: 'table'; + header: string[]; + aligns: Align[]; + rows: string[][]; + start: number; + end: number; + raw: string; +} +/** Definition list (`term` line followed by `: definition` lines). */ +export interface DefinitionListBlock { + type: 'dl'; + entries: { terms: string[]; defs: string[][] }[]; + start: number; + end: number; + raw: string; +} +/** Display math ($$ … $$). */ +export interface MathBlock { type: 'math'; tex: string; start: number; end: number; raw: string } + +export type BlockNode = + | HeadingBlock + | ParagraphBlock + | CodeBlock + | QuoteBlock + | ListBlock + | HrBlock + | TableBlock + | DefinitionListBlock + | MathBlock; + +const RE_BLANK = /^\s*$/; +const RE_FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})([ \t]*)(.*)$/; +const RE_HEADING = /^ {0,3}(#{1,6})[ \t]+(.*)$/; +const RE_HR = /^ {0,3}((-[ \t]*){3,}|(_[ \t]*){3,}|(\*[ \t]*){3,})$/; +const RE_QUOTE = /^ {0,3}>/; +const RE_ITEM = /^( *)([-*+]|[1-9][0-9]{0,8}[.)])[ \t]+(.*)$/; + +const isBlank = (l: string): boolean => RE_BLANK.test(l); + +/** Parse markdown lines into top-level blocks with absolute line ranges [start, end). */ +export function parseBlocks(lines: string[]): BlockNode[] { + const out: BlockNode[] = []; + let i = 0; + const n = lines.length; + + while (i < n) { + const line = lines[i]; + if (isBlank(line)) { i++; continue; } + + // ---- display math $$…$$ -------------------------------------------------- + { + const mo = /^\s*\$\$\s*(.*?)\s*$/.exec(line); + if (mo) { + const first = mo[1]; + if (/\$\$\s*$/.test(first) && first.replace(/\$\$\s*$/, '').trim() !== '') { + // single-line $$ … $$ + out.push({ type: 'math', tex: first.replace(/\$\$\s*$/, '').trim(), start: i, end: i + 1, raw: line }); + i++; + continue; + } + let closeIdx = -1; + for (let k = i + 1; k < n; k++) { + if (/^\s*\$\$\s*$/.test(lines[k])) { closeIdx = k; break; } + } + const endAbs = closeIdx >= 0 ? closeIdx + 1 : n; + const texLines = lines.slice(i + 1, closeIdx === -1 ? n : closeIdx); + out.push({ type: 'math', tex: texLines.join('\n').trim(), start: i, end: endAbs, raw: lines.slice(i, endAbs).join('\n') }); + i = endAbs; + continue; + } + } + + // ---- fenced code --------------------------------------------------------- + const f = RE_FENCE_OPEN.exec(line); + let fenceDone = false; + if (f && !(f[1][0] === '`' && f[3].includes('`'))) { + const markerChar = f[1][0]; + const markerLen = Math.max(f[1].length, 3); + const closerSrc = markerChar === '`' ? String.raw`\`` : '~'; + const closer = new RegExp(`^ {0,3}${closerSrc}{${markerLen},}[ \t]*$`); + let closeIdx = -1; + for (let k = i + 1; k < n; k++) { + if (closer.test(lines[k])) { closeIdx = k; break; } + } + const endAbs = closeIdx >= 0 ? closeIdx + 1 : n; + const codeLines = lines.slice(i + 1, closeIdx === -1 ? n : closeIdx); + const infoFull = f[3].trim(); + const attrMatch = infoFull.match(/^([^\s{]+)\s*(\{.*\})?$/); + const lang = markerChar === '`' ? firstWord(attrMatch ? attrMatch[1] : infoFull) : ''; + const extraInfo = attrMatch && attrMatch[2] ? attrMatch[2] : undefined; + out.push({ type: 'code', lang, code: codeLines.join('\n'), info: extraInfo, start: i, end: endAbs, raw: lines.slice(i, endAbs).join('\n') }); + i = endAbs; + continue; + } + + // ---- heading --------------------------------------------------------------- + if (!fenceDone) { + const h = RE_HEADING.exec(line); + if (h && !RE_HR.test(line)) { + let text = cleanHeadingText(h[2]); + let customId: string | undefined; + const idm = text.match(/\{#([\w-]+)\}\s*$/); + if (idm) { + customId = idm[1]; + text = text.slice(0, idm.index).trim(); + } + out.push({ type: 'heading', level: h[1].length, text, id: customId, start: i, end: i + 1, raw: line }); + i++; + continue; + } + } + + // ---- horizontal rule ----------------------------------------------------------- + if (!fenceDone && RE_HR.test(line)) { + out.push({ type: 'hr', start: i, end: i + 1, raw: line }); + i++; + continue; + } + + // ---- table -------------------------------------------------------------------------- + if (!fenceDone && line.includes('|') && i + 1 < n && isSeparatorRow(lines[i + 1])) { + const header = splitRow(line); + const aligns = parseAligns(splitRow(lines[i + 1])).slice(0, header.length); + const rows: string[][] = []; + let k = i + 2; + while (k < n && !isBlank(lines[k]) && lines[k].includes('|')) { + rows.push(padCells(splitRow(lines[k]), header.length)); + k++; + } + out.push({ type: 'table', header, aligns, rows, start: i, end: k, raw: lines.slice(i, k).join('\n') }); + i = k; + continue; + } + + // ---- blockquote -------------------------------------------------------------------------- + if (!fenceDone && RE_QUOTE.test(line)) { + const inner: string[] = []; + let k = i; + while (k < n) { + if (RE_QUOTE.test(lines[k])) { + inner.push(lines[k].replace(/^ {0,3}>[ \t]?/, '')); + k++; + continue; + } + // a blank line does not end the quote when another '>' line follows + if (isBlank(lines[k]) && k + 1 < n && RE_QUOTE.test(lines[k + 1])) { + inner.push(''); + k++; + continue; + } + break; + } + const children = parseBlocks(inner); + for (const c of children) { c.start += i; c.end += i; } // rebase onto absolute lines + out.push({ type: 'quote', children, start: i, end: k, raw: lines.slice(i, k).join('\n') }); + i = k; + continue; + } + + // ---- definition list ------------------------------------------------------------------------ + if (!fenceDone && i + 1 < n && /^ {0,3}: [ ]?/.test(lines[i + 1]) && !isBlank(line) + && !RE_HEADING.test(line) && !RE_ITEM.test(line)) { + const entries: DefinitionListBlock['entries'] = []; + let k = i; + while (k < n && !isBlank(lines[k])) { + const terms: string[] = []; + while (k < n && !isBlank(lines[k]) && !/^ {0,3}: /.test(lines[k])) { + terms.push(lines[k]); + k++; + } + const defs: string[][] = []; + while (k < n && /^ {0,3}: /.test(lines[k])) { + defs.push([lines[k].replace(/^ {0,3}: /, '')]); + k++; + // continuation lines indented under the definition + while (k < n && /^ {2,}\S/.test(lines[k])) { + defs[defs.length - 1].push(lines[k].trim()); + k++; + } + } + if (terms.length === 0 || defs.length === 0) break; + entries.push({ terms, defs }); + } + if (entries.length > 0) { + out.push({ type: 'dl', entries, start: i, end: k, raw: lines.slice(i, k).join('\n') }); + i = k; + continue; + } + } + + // ---- list ----------------------------------------------------------------------------- + if (!fenceDone && RE_ITEM.test(line)) { + const res = parseListRun(lines, i); + out.push(...res.blocks); + i = res.nextIndex; + continue; + } + + // ---- paragraph (with setext heading detection) --------------------------------------------- + { + const para: string[] = [line]; + let k = i + 1; + while (k < n && !isBlank(lines[k]) && !startsNewBlock(lines, k) && !/^[ \t]*[-=]+[ \t]*$/.test(lines[k])) { + para.push(lines[k]); + k++; + } + const nextLine = lines[k]; + if (nextLine !== undefined && /^[ \t]*=+[ \t]*$/.test(nextLine)) { + out.push({ type: 'heading', level: 1, text: para.join(' '), start: i, end: k + 1, raw: lines.slice(i, k + 1).join('\n') }); + i = k + 1; + } else if (nextLine !== undefined && /^[ \t]*-+[ \t]*$/.test(nextLine)) { + out.push({ type: 'heading', level: 2, text: para.join(' '), start: i, end: k + 1, raw: lines.slice(i, k + 1).join('\n') }); + i = k + 1; + } else { + out.push({ type: 'paragraph', lines: para, start: i, end: k, raw: para.join('\n') }); + i = k; + } + } + } + + return out; +} + +function cleanHeadingText(s: string): string { + return s.replace(/[ \t]+#+[ \t]*$/, '').trim(); // strip optional closing hashes +} + +/** Does this line begin a new block? (used to stop paragraph collection) */ +function startsNewBlock(lines: string[], k: number): boolean { + const l = lines[k]; + if (l === undefined) return true; + if (RE_FENCE_OPEN.test(l)) return true; + if (RE_HEADING.test(l)) return true; + if (RE_HR.test(l)) return true; + if (RE_QUOTE.test(l)) return true; + if (RE_ITEM.test(l)) return true; + if (l.includes('|') && k + 1 < lines.length && isSeparatorRow(lines[k + 1])) return true; + return false; +} + +// --------------------------------------------------------------------------- +// lists — indentation-driven nesting via a frame stack. +// deeper: indent > top.indent + TOL → new nested list under top's last item +// same: |indent - frame.indent| <= TOL → sibling in that frame's list +// shallower: pop deeper frames first, then re-evaluate +// --------------------------------------------------------------------------- + +/** Parser-side item record: public fields plus bookkeeping. */ +interface InternalItem extends ListItem { lines: string[]; textCol: number; absStart: number } +interface Frame { indent: number; list: ListBlock; lastItem: InternalItem | null; textCol: number } +interface ChildList { list: ListBlock; anchorAbs: number } + +const TOL = 1; // same-level tolerance in spaces + +function parseListRun(lines: string[], i0: number): { blocks: BlockNode[]; nextIndex: number } { + const n = lines.length; + + // extent of the run: items + indented/lazy continuations, until blank or new block + let j = i0; + while (j < n) { + const l = lines[j]; + if (isBlank(l)) break; + if (RE_ITEM.test(l)) { j++; continue; } + if (/^ +\S/.test(l) || /^ +[`>*]/.test(l)) { j++; continue; } // indented continuation / inline fence or quote inside an item + if (!isBlockStartLike(l)) { j++; continue; } // lazy continuation of the open item + break; + } + + const makeList = (ordered: boolean): ListBlock => ({ type: 'list', ordered, items: [], start: i0, end: j, raw: '' }); + + const frames: Frame[] = []; // stack, indent increasing + const blocksOut: ListBlock[] = []; + /** nested lists anchored under a parent item at an absolute source line */ + const childrenOf = new Map(); + + for (let p = i0; p < j; p++) { + const l = lines[p]; + const m = RE_ITEM.exec(l); + + if (!m) { + // continuation of the deepest frame's last item, de-indented toward its text column + const topFrame = frames[frames.length - 1]; + if (topFrame?.lastItem) { + topFrame.lastItem.lines.push(stripIndent(l, Math.max(topFrame.textCol, 1))); + } + continue; + } + + const indent = m[1].length; + while (frames.length > 1 && indent < frames[frames.length - 1].indent - TOL) frames.pop(); + + let target: Frame; + const ordered = /^\d/.test(m[2]); + + if (frames.length === 0 || indent <= frames[0].indent + TOL) { + // root level of this run + if (frames.length > 0 && Math.abs(indent - frames[0].indent) <= TOL) { + target = frames[0]; + } else if (frames.length === 0) { + target = { indent, list: makeList(ordered), lastItem: null, textCol: 2 }; + frames.push(target); + blocksOut.push(target.list); + } else { + // shallower than the root frame by more than TOL → treat as sibling of root anyway + target = frames[0]; + } + } else { + const topFrame = frames[frames.length - 1]; + if (Math.abs(indent - topFrame.indent) <= TOL) { + target = topFrame; // sibling at the deepest level + } else { + // strictly deeper: nest under the last item of the deepest frame + const parentItem = topFrame.lastItem; + if (!parentItem) continue; // malformed — skip + const nl = makeList(ordered); + nl.start = p; + pushChild(childrenOf, parentItem, { list: nl, anchorAbs: p }); + target = { indent, list: nl, lastItem: null, textCol: 2 }; + frames.push(target); + } + } + + const item: InternalItem = { blocks: [], lines: [m[3]], textCol: l.length - m[3].length, absStart: p }; + target.list.items.push(item); + target.lastItem = item; + target.textCol = item.textCol; + } + + // finalize: parse each item's lines into blocks, interleaving anchored sub-lists + // so that prose → sublist → more prose keeps its original order. + const TASK_RE = /^\[( |x|X)\][ \t]+/; + const finalize = (listNode: ListBlock): void => { + for (const it of listNode.items) { + const internal = it as InternalItem; + while (internal.lines.length > 0 && isBlank(internal.lines[internal.lines.length - 1])) internal.lines.pop(); + const tm = TASK_RE.exec(internal.lines[0] ?? ''); + if (tm && internal.lines[0].slice(tm[0].length).trim() !== '') { + it.task = true; + it.checked = tm[1].toLowerCase() === 'x'; + internal.lines[0] = internal.lines[0].slice(tm[0].length); + } + const kids = [...(childrenOf.get(internal) ?? [])].sort((a, b) => a.anchorAbs - b.anchorAbs); + const base = internal.absStart; + const blocks: BlockNode[] = []; + let prev = 0; + for (const kid of kids) { + const rel = Math.max(prev, Math.min(kid.anchorAbs - base, internal.lines.length)); + blocks.push(...parseBlocks(internal.lines.slice(prev, rel))); + finalize(kid.list); + blocks.push(kid.list); + prev = rel + 1; // the anchor line itself belongs to the child's first item + } + blocks.push(...parseBlocks(internal.lines.slice(prev))); + internal.blocks = blocks; + } + }; + for (const b of blocksOut) finalize(b); + + return { blocks: blocksOut, nextIndex: j }; +} + +function pushChild(map: Map, item: InternalItem, child: ChildList): void { + const arr = map.get(item); + if (arr) arr.push(child); + else map.set(item, [child]); +} + +/** Does this line start a structural block (stops list-run lazy continuation)? */ +function isBlockStartLike(l: string): boolean { + if (RE_FENCE_OPEN.test(l)) return true; + if (RE_HEADING.test(l)) return true; + if (RE_HR.test(l)) return true; + if (RE_QUOTE.test(l)) return true; + if (/^[ \t]*[-=]+[ \t]*$/.test(l)) return true; // setext underline + return false; +} + +function stripMarkerless(line: string): string { + return line.replace(/^ +/, ''); +} +void stripMarkerless; + +function stripIndent(line: string, col: number): string { + const sp = leadingSpaces(line); + if (sp === 0) return line; + return line.slice(Math.min(sp, col)); +} + +function leadingSpaces(l: string): number { + return (l.match(/^ */) ?? [''])[0].length; +} + +function padCells(cells: string[], width: number): string[] { + const out = [...cells]; + while (out.length < width) out.push(''); + return out; +} + +// --------------------------------------------------------------------------- +// tables +// --------------------------------------------------------------------------- + +/** + * Split a table row into cells. Handles: leading/trailing pipes (optional), + * escaped `\|`, and pipes inside inline code spans (which do NOT split). + */ +export function splitRow(line: string): string[] { + let s = line.trim(); + if (s.startsWith('|')) s = s.slice(1); + if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1); + const cells: string[] = []; + let cur = ''; + let codeDelim = ''; // opening backtick run while inside a code span ('' = not in code) + let i = 0; + while (i < s.length) { + const c = s[i]; + if (c === '\\' && s[i + 1] === '|') { cur += '|'; i += 2; continue; } // escaped pipe + if (c === '`') { + let run = ''; + while (i < s.length && s[i] === '`') { run += '`'; i++; } + if (codeDelim === '') codeDelim = run; // entering a code span + else if (run.length >= codeDelim.length) codeDelim = ''; // closing it + cur += run; + continue; + } + if (c === '|' && codeDelim === '') { cells.push(cur.trim()); cur = ''; i++; continue; } + cur += c; + i++; + } + cells.push(cur.trim()); + return cells; +} + +export function isSeparatorRow(line: string): boolean { + if (!line.includes('-') || !line.includes('|')) return false; + const cells = splitRow(line); + if (cells.length === 0) return false; + return cells.every((c) => /^:?-+:?$/.test(c)); +} + +export function parseAligns(cells: string[]): Align[] { + return cells.map((c) => { + const left = c.startsWith(':'); + const right = c.length > 1 && c.endsWith(':'); + if (left && right) return 'center'; + if (right) return 'right'; + if (left) return 'left'; + return null; + }); +} + +function firstWord(s: string): string { + const m = s.match(/\S+/); + return m ? m[0] : ''; +} + +// --------------------------------------------------------------------------- +// rendering to HTML strings (print view / exports use this directly) +// --------------------------------------------------------------------------- + +export interface RenderOptions extends InlineContext {} + +interface RenderCtx { usedIds: Set } + +/** Render parsed blocks into an HTML string. All user text is escaped — injection-safe by construction. */ +export function renderBlocksHtml(blocks: BlockNode[], opts?: RenderOptions): string { + const ctx: RenderCtx = { usedIds: new Set() }; + return blocks.map((b) => renderBlock(b, opts, ctx)).join('\n'); +} + +function renderBlock(b: BlockNode, opts?: RenderOptions, ctx: RenderCtx = { usedIds: new Set() }): string { + switch (b.type) { + case 'heading': { + const lvl = Math.min(6, Math.max(1, b.level)); + const base = b.id ?? slugForHeading(b.text); + let id = base; + let k2 = 2; + while (ctx.usedIds.has(id)) id = `${base}-${k2++}`; // dedupe across the document + ctx.usedIds.add(id); + return `${renderInline(b.text, opts)}`; + } + case 'paragraph': { + let html = ''; + for (let k = 0; k < b.lines.length; k++) { + const l = b.lines[k]; + if (k > 0) { + const prev = b.lines[k - 1]; + html += /[\\]$/.test(prev) || /[ \t]{2}$/.test(prev) ? '
    ' : ' '; + } + html += renderInline(l.endsWith('\\') ? l.slice(0, -1) : l, opts); + } + return `

    ${html}

    `; + } + case 'code': { + if (/^(mermaid|stateDiagram(-v2)?)$/i.test(b.lang)) { + const svg = renderMermaidSvg(b.code); + if (svg) return `
    ${svg}
    `; + return `
    ${escapeHtml(b.code)}
    `; + } + const cls = b.lang ? ` class="lang-${b.lang.replace(/[^a-zA-Z0-9+-]/g, '')}"` : ''; + const info = b.info ? ` data-info="${quoteAttrId(b.info)}"` : ''; + const hl = b.lang && isHighlightable(b.lang) ? highlightCode(b.code, b.lang) : ''; + return `${hl || escapeHtml(b.code)}`; + } + case 'quote': + return `
    ${renderBlocksHtml(b.children, opts).replace(/\n/g, '')}
    `; + case 'hr': + return '
    '; + case 'math': + return `
    ${renderMathHtml(b.tex)}
    `; + case 'dl': { + const parts: string[] = ['
    ']; + for (const e of b.entries) { + for (const t of e.terms) parts.push(`
    ${renderInline(t, opts)}
    `); + for (const d of e.defs) parts.push(`
    ${d.map((l) => renderInline(l, opts)).join(' ')}
    `); + } + parts.push('
    '); + return parts.join(''); + } + case 'table': { + const th = b.header.map((c, k) => `${renderInline(c, opts)}`).join(''); + const trs = b.rows + .map((r) => `${r.map((c, k) => `${renderInline(c, opts)}`).join('')}`) + .join(''); + return `${th}${trs}
    `; + } + case 'list': { + const tag = b.ordered ? 'ol' : 'ul'; + const lis = b.items.map((it) => { + const inner = renderBlocksHtml(it.blocks, opts); + if (it.task) { + return `
  • ${inner}
  • `; + } + return `
  • ${inner}
  • `; + }).join(''); + return `<${tag} class="md-list">${lis}`; + } + } +} + +function quoteAttrId(s: string): string { + return s.replace(/&/g, '&').replace(/"/g, '"').replace(/(); + let inFence = false; + for (const line of text.split('\n')) { + if (/^ {0,3}(`{3,}|~{3,})/.test(line)) { inFence = !inFence; continue; } + if (inFence) continue; + const noInlineCode = line.replace(/`[^`\n]*`/g, ' '); + for (const m of noInlineCode.matchAll(/\[\[([^\]]+)\]\]/g)) { + let t = m[1].replace(/^#/, '').split('|')[0] ?? ''; + t = t.replace(/\\([!*&[\]()_`~#|])/g, '$1').trim(); + if (t) seen.add(t); + } + } + return [...seen]; +} + +// --------------------------------------------------------------------------- +// document-level definitions: link references & footnotes +// --------------------------------------------------------------------------- + +export interface ReferenceDef { url: string; title?: string } + +export interface ExtractedDefinitions { + /** Link-reference definitions keyed by lowercase label. */ + refs: Map; + /** Footnote definitions keyed by label, each holding its raw content lines. */ + footnotes: Map; + /** + * The input lines with every definition replaced by blank lines — line + * indices are preserved so block ranges stay valid, but the definitions no + * longer render as paragraphs. + */ + cleaned: string[]; +} + +const RE_LINK_DEF = /^ {0,3}\[([^^\]]+)\]:\s*]+)>?\s*(?:"([^"]*)"|'([^']*)')?\s*$/; +const RE_FOOTNOTE_DEF = /^ {0,3}\[\^([^\]]+)\]:\s*(.*)$/; + +/** Scan a document for `[label]: url` and `[^label]: text` definitions. */ +export function extractDefinitions(text: string): ExtractedDefinitions { + const lines = text.split('\n'); + const refs = new Map(); + const footnotes = new Map(); + const cleaned = [...lines]; + let inFence = false; + + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + if (/^ {0,3}(`{3,}|~{3,})/.test(l)) { inFence = !inFence; continue; } + if (inFence) continue; + // definitions only count at line start after a blank (or another definition) + const prevOk = i === 0 || lines[i - 1].trim() === '' || RE_LINK_DEF.test(lines[i - 1]) || RE_FOOTNOTE_DEF.test(lines[i - 1]); + if (!prevOk) continue; + + const fm = RE_LINK_DEF.exec(l); + if (fm) { + refs.set(fm[1].toLowerCase(), { url: fm[2], title: fm[3] ?? fm[4] ?? undefined }); + cleaned[i] = ''; + continue; + } + + const fn = RE_FOOTNOTE_DEF.exec(l); + if (fn) { + const bodyLines: string[] = []; + const first = fn[2].trim(); + if (first) bodyLines.push(first); + let k = i + 1; + while (k < lines.length) { + const cl = lines[k]; + if (/^ {4,}\S/.test(cl)) { bodyLines.push(cl.trim()); cleaned[k] = ''; k++; continue; } + break; + } + footnotes.set(fn[1], bodyLines); + cleaned[i] = ''; + i = k - 1; // loop's i++ lands on the first unconsumed line + continue; + } + } + return { refs, footnotes, cleaned }; +} + +/** + * Flip the `[ ]`/`[x]` marker of the `index`-th task item within the line + * range [start, end). Returns the flipped line index, or -1 when not found. + * Pure helper so the UI can offer click-to-toggle on rendered checklists. + */ +export function flipTaskInBlock(lines: string[], start: number, end: number, index: number): number { + const re = /^(\s*(?:[-*+]|\d+[.)])[ \t]+\[)([ xX])(\].*)$/; + let seen = -1; + for (let i = Math.max(0, start); i < Math.min(end, lines.length); i++) { + const m = re.exec(lines[i]); + if (!m) continue; + seen++; + if (seen === index) { + lines[i] = m[1] + (m[2] === ' ' ? 'x' : ' ') + m[3]; + return i; + } + } + return -1; +} diff --git a/src/docs/lib/md/highlight.ts b/src/docs/lib/md/highlight.ts new file mode 100644 index 000000000..e1b295925 --- /dev/null +++ b/src/docs/lib/md/highlight.ts @@ -0,0 +1,232 @@ +/** + * Lightweight syntax highlighting for fenced code blocks. Pure TS, no deps. + * Supports 10+ popular languages; unknown languages return '' so callers fall + * back to plain escaped text. + */ + +import { escapeHtml } from './inline.js'; + +interface LangDef { + kw: Set; + bi?: Set; + lc?: string[]; // line comment markers + bc?: [string, string][]; // block comment pairs + strq: string[]; // string delimiters (longest first) + ci?: boolean; // case-insensitive keywords (SQL) + fnParen?: boolean; // ident before '(' → function color +} + +const S = (s: string) => new Set(s.split(' ')); + +const JS_KW = S('break case catch class const continue debugger default delete do else enum export extends false finally for function if implements import in instanceof interface let new null package private protected public readonly return static super switch this throw true try typeof var void while with yield async await of get set type keyof infer satisfies override declare namespace abstract is'); +const JS_BI = S('console Math Object Array String Number Boolean Promise JSON window document require module exports setTimeout setInterval clearTimeout fetch Map Set WeakMap Symbol Error RegExp Date parseInt parseFloat isNaN globalThis process Buffer'); +const PY_KW = S('False None True and as assert async await break class continue def del elif else except finally for from global if import in is lambda nonlocal not or pass raise return try while with yield match case'); +const PY_BI = S('print len range str int float list dict set tuple open input self super isinstance enumerate zip abs min max sum sorted map filter type round repr hash id staticmethod classmethod property Exception ValueError TypeError NotImplementedError __init__ __name__ __main__'); +const BASH_KW = S('if then else elif fi for while until do done case esac in select function time coproc return exit local export readonly declare unset shift source alias trap set'); +const BASH_BI = S('echo cd ls cat grep sed awk curl wget mkdir rmdir rm cp mv chmod chown sudo npm pnpm npx node python pip git docker kubectl make tar ssh scp find which head tail wc sort uniq xargs'); +const SQL_KW = S('select from where insert into values update set delete create table drop alter add column join left right inner outer full cross on group by order having limit offset union all distinct as and or not null primary key foreign references default unique index view between like ilike in exists count sum avg min max begin commit rollback cascade constraint check auto_increment serial integer varchar text boolean timestamp date'); +const GO_KW = S('break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var nil true false iota'); +const GO_BI = S('make len cap append copy delete panic recover print println new close error string int int64 float64 bool byte rune uint any'); +const RUST_KW = S('fn let mut const static if else match loop while for in break continue return struct enum trait impl where use mod pub crate super self Self move ref as dyn unsafe extern true false macro_rules await async dyn'); +const RUST_BI = S('println print vec format Some None Ok Err Result Option String Vec Box Rc Arc u8 u16 u32 u64 usize i8 i16 i32 i64 isize f32 f64 bool char str'); +const C_KW = S('int char float double void long short unsigned signed struct union enum typedef sizeof static extern const volatile register return if else while do for switch case default break continue goto inline restrict NULL'); +const CPP_KW = S('class public private protected template typename namespace using new delete this virtual override final try catch throw operator bool true false nullptr constexpr consteval auto explicit friend inline mutable noexcept static_assert static_cast dynamic_cast reinterpret_cast concept requires'); +const JAVA_KW = S('public private protected class interface extends implements static final void int long double float boolean char byte short new return this super throws try catch finally import package instanceof enum abstract native synchronized transient volatile assert true false null record sealed var yield'); +const CS_KW = S('public private protected internal class struct interface extends implements static readonly void int long double float bool char byte decimal string object var new return this base throws try catch finally using namespace instanceof enum abstract sealed override virtual partial async await out ref params is as null true false nameof'); + +const LANGS: Record = {}; +function def(aliases: string[], d: LangDef): void { + for (const a of aliases) LANGS[a] = d; +} + +def(['js', 'jsx', 'javascript', 'mjs', 'cjs', 'node'], { kw: JS_KW, bi: JS_BI, lc: ['//'], bc: [['/*', '*/']], strq: ['`', '"', "'"], fnParen: true }); +def(['ts', 'tsx', 'typescript'], { kw: JS_KW, bi: JS_BI, lc: ['//'], bc: [['/*', '*/']], strq: ['`', '"', "'"], fnParen: true }); +def(['py', 'python', 'python3'], { kw: PY_KW, bi: PY_BI, lc: ['#'], strq: ['"""', "'''", '"', "'"] }); +def(['bash', 'sh', 'shell', 'zsh', 'console'], { kw: BASH_KW, bi: BASH_BI, lc: ['#'], strq: ['"', "'"] }); +def(['sql'], { kw: SQL_KW, lc: ['--'], bc: [['/*', '*/']], strq: ["'"], ci: true }); +def(['go', 'golang'], { kw: GO_KW, bi: GO_BI, lc: ['//'], bc: [['/*', '*/']], strq: ['`', '"', "'"], fnParen: true }); +def(['rust', 'rs'], { kw: RUST_KW, bi: RUST_BI, lc: ['//'], bc: [['/*', '*/']], strq: ['"', "'"], fnParen: true }); +def(['c', 'h'], { kw: C_KW, lc: ['//'], bc: [['/*', '*/']], strq: ['"', "'"], fnParen: true }); +def(['cpp', 'c++', 'cc', 'hpp'], { kw: new Set([...C_KW, ...CPP_KW]), lc: ['//'], bc: [['/*', '*/']], strq: ['"', "'"], fnParen: true }); +def(['java'], { kw: JAVA_KW, bi: S('System String Integer Boolean List ArrayList HashMap Math'), lc: ['//'], bc: [['/*', '*/']], strq: ['"', "'"], fnParen: true }); +def(['cs', 'csharp'], { kw: CS_KW, bi: S('Console String List Dictionary Math Task'), lc: ['//'], bc: [['/*', '*/']], strq: ['"', "'"], fnParen: true }); +def(['json'], { kw: S('true false null'), lc: [], bc: [], strq: ['"'] }); +def(['yaml', 'yml'], { kw: S('true false null'), lc: ['#'], strq: ['"', "'"] }); + +export const SUPPORTED_HL_LANGS = new Set(Object.keys(LANGS)); + +export function isHighlightable(lang: string): boolean { + const key = lang.toLowerCase().trim(); + return key === 'html' || key === 'xml' || key === 'svg' || key === 'css' || LANGS[key] !== undefined; +} + +/** Tokenize code into colored spans. Returns '' when the language is unknown. */ +export function highlightCode(code: string, langRaw: string): string { + const key = langRaw.toLowerCase().trim(); + if (key === 'html' || key === 'xml' || key === 'svg') return highlightMarkup(code); + if (key === 'css') return highlightCss(code); + const def = LANGS[key]; + if (!def) return ''; + + const out: string[] = []; + const span = (cls: string, text: string): void => { out.push(`${escapeHtml(text)}`); }; + const n = code.length; + let i = 0; + + const startsWith = (s: string): boolean => code.startsWith(s, i); + + outer: while (i < n) { + // block comments + for (const [o, c] of def.bc ?? []) { + if (startsWith(o)) { + let j = code.indexOf(c, i + o.length); + j = j === -1 ? n : j + c.length; + span('com', code.slice(i, j)); + i = j; + continue outer; + } + } + // line comments + for (const lc of def.lc ?? []) { + if (startsWith(lc)) { + let j = code.indexOf('\n', i); + j = j === -1 ? n : j; + span('com', code.slice(i, j)); + i = j; + continue outer; + } + } + // strings + for (const q of def.strq) { + if (!startsWith(q)) continue; + let j = i + q.length; + while (j < n) { + if (code[j] === '\\') { j += 2; continue; } + if (code.startsWith(q, j)) { j += q.length; break; } + if (code[j] === '\n' && q !== '`' && q !== '"""' && q !== "'''") break; // unterminated + j++; + } + span('str', code.slice(i, Math.min(j, n))); + i = Math.min(j, n); + continue outer; + } + const ch = code[i]; + + // numbers + if (/[0-9]/.test(ch) || (ch === '.' && /[0-9]/.test(code[i + 1] ?? ''))) { + const m = /^(0[xXbBoO][0-9a-fA-F_]+|\d[\d_]*(\.\d+)?([eE][+-]?\d+)?)?/.exec(code.slice(i))!; + const num = m[0] || ch; + span('num', num); + i += num.length; + continue; + } + + // bash variables + if (ch === '$' && /^\$\{?[\w]+\}?/.test(code.slice(i))) { + const m = /^\$\{?[\w]+\}?/.exec(code.slice(i))!; + span('var', m[0]); + i += m[0].length; + continue; + } + + // C/C++ preprocessor directives + if (ch === '#' && /^#[ \t]*[a-z]+/i.test(code.slice(i)) && def.kw.has('int')) { + const m = /^#[ \t]*[a-z]+/i.exec(code.slice(i))!; + span('kw', m[0]); + i += m[0].length; + continue; + } + + // identifiers / keywords + if (/[A-Za-z_$@]/.test(ch)) { + const m = /^[A-Za-z_$@#][\w$]*/.exec(code.slice(i)); + if (!m) { out.push(escapeHtml(ch)); i++; continue; } + const word = m[0]; + const probe = def.ci ? word.toLowerCase() : word; + let j = i + word.length; + while (j < n && code[j] === ' ') j++; + const nextIsParen = code[j] === '('; + if (def.kw.has(probe)) span('kw', word); + else if (def.bi?.has(probe)) span('bi', word); + else if ((def.fnParen && nextIsParen) || nextIsParen === false && false) span('fn', word); + else if (/^[A-Z]/.test(word) && word.length > 1) span('bi', word); // Types/Classes + else span('', word); + i += word.length; + continue; + } + + // whitespace & punctuation + if ('{}()[];,.'.includes(ch) || ch === ':') { span('punc', ch); i++; continue; } + out.push(escapeHtml(ch)); + i++; + } + return out.join(''); +} + +/** HTML/XML: tags, attributes, comments, text. */ +function highlightMarkup(code: string): string { + const parts: string[] = []; + let i = 0; + const n = code.length; + while (i < n) { + if (code.startsWith('', i); + j = j === -1 ? n : j + 3; + parts.push(`${escapeHtml(code.slice(i, j))}`); + i = j; + continue; + } + if (code[i] === '<') { + const m = /^<\/?[A-Za-z][\w:-]*/.exec(code.slice(i)); + if (m) { + parts.push(`${escapeHtml(m[0])}`); + i += m[0].length; + // attributes until > + while (i < n && code[i] !== '>') { + if (code.startsWith('>|-x|-\)|->|-->|--))([-+]?)([A-Za-z_][\w]*)\s*:\s*(.*)$/.exec(line); + if (m) { + const [, fromRaw, sig, plusMinus, toRaw, text] = m; + const fromP = participant(fromRaw); + const toP = participant(toRaw); + void fromP; void toP; + const activateTarget = plusMinus === '+'; + const deactivateSource = plusMinus === '-'; + msgCount++; + stack[stack.length - 1].items.push({ + type: 'msg', + msg: { + from: fromRaw, + to: toRaw, + text: text.trim(), + dotted: sig.startsWith('--'), + head: sig.endsWith('>>') ? 'filled' : sig.includes('x') ? 'cross' : sig.endsWith(')') ? 'open' : 'none', + number: autonumber ? autonumber.start + (msgCount - 1) * autonumber.step : undefined, + }, + }); + rowCounter++; + if (activateTarget) activate(toRaw); + if (deactivateSource) deactivate(fromRaw); + continue; + } + // loop/alt bookkeeping verbs and anything unknown: skipped + } + + // close dangling activations at final row + for (const [id, openAt] of openActivations) { + const spans = activationSpans.get(id) ?? []; + spans.push([openAt, Math.max(rowCounter - 1, openAt)]); + activationSpans.set(id, spans); + } + // drop empty box records + const realBoxes = boxes.filter((b) => b.to >= b.from); + return { participants, root, activationSpans, totalRows: rowCounter, autonumber, boxes: realBoxes, title: seqTitle }; +} + +export function renderSequenceSvg(src: string): string | null { + const parsed = parseSequence(src); + if (!parsed || parsed.participants.length === 0) return null; + const { participants, root, activationSpans, boxes, title } = parsed; + + const colW = participants.map((p) => Math.max(84, p.label.length * 7.4 + 30)); + const colX: number[] = []; + let acc = PAD + 40; + for (const w of colW) { + colX.push(acc + w / 2); + acc += w + 24; + } + const colOf = (id: string): number => { + const idx = participants.findIndex((p) => p.id === id); + return idx >= 0 ? colX[idx] : colX[0]; + }; + const W = acc + 16; + + // measure pass + const MSG_H = 48; + const NOTE_H = 42; + const DIV_H = 38; + const FRAME_HEAD = 34; + const FRAME_FOOT = 12; + const BLOCK_LABEL_H = 26; + function measure(items: SeqNode[]): number { + let h = 0; + for (const n of items) { + if (n.type === 'frame') { + h += FRAME_HEAD; + for (let i = 0; i < n.blocks.length; i++) { + if (i > 0) h += BLOCK_LABEL_H; + h += measure(n.blocks[i].items); + } + h += FRAME_FOOT; + } else if (n.type === 'divider') h += DIV_H; + else h += n.type === 'note' ? NOTE_H : MSG_H; + } + return h; + } + const contentH = measure(root); + + const TOP_H = 44 + (title ? 24 : 0); + const H = TOP_H + contentH + 36; + + const parts: string[] = []; + // frame backgrounds collected during drawing, spliced right after the + // open tag so they render behind everything AND stay valid XML + // (parts.unshift put them BEFORE the root element) + const bg: string[] = []; + parts.push( + ``, + ); + if (title) { + parts.push(`${escapeHtml(title)}`); + } + const lineColor = 'var(--border-strong, #6b7280)'; + const surface = 'var(--surface, #fff)'; + + // lifelines + for (let i = 0; i < participants.length; i++) { + parts.push( + ``, + ); + } + // participant header boxes (with optional box grouping) + let bi = 0; + for (let i = 0; i < participants.length; i++) { + const inBox = boxes.find((b) => i >= b.from && i <= b.to); + if (inBox) { + const lastIdx = Math.min(inBox.to, participants.length - 1); + const x1 = colX[i] - colW[i] / 2 - 10; + const x2 = colX[lastIdx] + colW[lastIdx] / 2 + 10; + parts.push( + ``, + ); + if (inBox.title) parts.push(`${escapeHtml(inBox.title)}`); + void bi++; + } + const w = colW[i]; + const x = colX[i] - w / 2; + parts.push( + ``, + `${escapeHtml(participants[i].label)}`, + ); + } + + const drawHead = (x: number, yy: number, dir: 1 | -1, msg: SeqMessage): string => { + const c = lineColor; + if (msg.head === 'filled') return ``; + if (msg.head === 'open') return ``; + if (msg.head === 'cross') return ``; + return ''; + }; + + // collect referenced participants per subtree for frame widths + const colsOfItems = (items: SeqNode[]): number[] => { + const out: number[] = []; + const walk = (list: SeqNode[]): void => { + for (const n of list) { + if (n.type === 'msg') { out.push(colOf(n.msg.from), colOf(n.msg.to)); } + else if (n.type === 'note') { for (const o of n.note.over) out.push(colOf(o)); } + else if (n.type === 'frame') for (const b of n.blocks) walk(b.items); + } + }; + walk(items); + return out.length ? out : [colX[0]]; + }; + + const drawItems = (items: SeqNode[], startY: number): number => { + let y = startY; + for (const n of items) { + if (n.type === 'frame') { + const cols = colsOfItems([n]); + const fx1 = Math.min(...cols) - 34; + const fx2 = Math.max(...cols) + 34; + const innerH = measure(n.blocks.map((b) => ({ items: b.items })).flatMap(() => [])).valueOf(); + void innerH; + let fy = y; + const totalFrameH = FRAME_HEAD + n.blocks.reduce((acc2, b, i2) => acc2 + (i2 > 0 ? BLOCK_LABEL_H : 0) + measure(b.items), 0) + FRAME_FOOT; + fy = y + FRAME_HEAD; + for (let bi2 = 0; bi2 < n.blocks.length; bi2++) { + const blk = n.blocks[bi2]; + if (bi2 > 0) { + parts.push( + ``, + `${escapeHtml(blk.label ?? '')}`, + ); + fy += BLOCK_LABEL_H; + } + fy = drawItems(blk.items, fy); + } + fy += FRAME_FOOT / 2; + bg.push( + ``, + `${escapeHtml(`${n.kind}${n.title ? ` [${n.title}]` : ''}`)}`, + ); + y = fy + FRAME_FOOT / 2; + continue; + } + if (n.type === 'divider') { + parts.push( + ``, + `${escapeHtml(n.text)}`, + ); + y += DIV_H; + continue; + } + if (n.type === 'note') { + const xs = n.note.over.map(colOf); + const cxNote = n.note.pos === 'over' + ? (Math.min(...xs) + Math.max(...xs)) / 2 + : n.note.pos === 'left' ? xs[0] - 70 : xs[0] + 70; + const tw = Math.max(60, n.note.text.length * 6.4 + 18); + parts.push( + ``, + `${escapeHtml(n.note.text)}`, + ); + y += NOTE_H; + continue; + } + // message + const msg = n.msg; + const x1 = colOf(msg.from); + const x2 = colOf(msg.to); + const dash = msg.dotted ? ' stroke-dasharray="4 4"' : ''; + if (msg.from === msg.to) { + parts.push( + ``, + `${escapeHtml(msg.text)}`, + ); + } else { + const dir: 1 | -1 = x2 > x1 ? 1 : -1; + const headX = msg.head === 'none' ? x2 : x2 - 2 * dir; + parts.push(``); + parts.push(drawHead(x2, y + 20, dir, msg)); + const label = (msg.number !== undefined ? `${msg.number}. ` : '') + msg.text; + parts.push( + `${escapeHtml(label)}`, + ); + } + y += MSG_H; + } + return y; + }; + + // activation bars drawn before messages: compute their pixel spans from row order. + // Rows are laid out sequentially over leaves; approximate by mapping row index → + // proportional position across the content area. + drawItems(root, TOP_H + 4); + // frame backgrounds render behind everything: splice them in right after + // the open tag (+ title) + parts.splice(title ? 2 : 1, 0, ...bg); + const bottomY = H - 24; + for (const [pid, spans] of activationSpans) { + const cxAct = colOf(pid); + for (const [r0, r1] of spans) { + const y0 = TOP_H + 4 + r0 * ((contentH || 1) / Math.max(parsed.totalRows, 1)); + const y1 = TOP_H + 4 + (r1 + 1) * ((contentH || 1) / Math.max(parsed.totalRows, 1)); + parts.push( + ``, + ); + } + void bottomY; + } + + parts.push(''); + return parts.join(''); +} + +// --------------------------------------------------------------------------- + +// pie charts +// --------------------------------------------------------------------------- + +const PIE_COLORS = ['#4f46e5', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#64748b']; + +export function renderPieSvg(src: string): string | null { + if (!/^\s*pie\b/.test(src)) return null; + let title = ''; + const header = /^pie(?:\s+showData)?(?:\s+title\s+(.*?))?\s*;?\s*$/i.exec(src.split('\n').map((l) => l.trim().replace(/;+\s*$/, '')).find((l) => /^pie\b/.test(l)) ?? ''); + if (header?.[1]) title = header[1].trim(); + const slices: { label: string; value: number }[] = []; + for (let line of src.split('\n')) { + line = line.trim().replace(/;+\s*$/, ''); + if (!line || line.startsWith('%%') || /^pie\b/.test(line) || /^showData\b/i.test(line)) continue; + let m = /^title\s+(.+)$/i.exec(line); + if (m) { + title = m[1].trim(); + continue; + } + m = /^"([^"]+)"\s*:\s*([\d.]+)$/.exec(line) ?? /^([\w\u00C0-\uFFFF][\w\s-]*?)\s*:\s*([\d.]+)$/.exec(line); + if (m) slices.push({ label: m[1].trim(), value: parseFloat(m[2]) }); + } + if (slices.length === 0) return null; + const total = slices.reduce((s, x) => s + x.value, 0) || 1; + + const W = 460; + const H = Math.max(240, 40 + slices.length * 24); + const cx = 140; + const cy = H / 2 + (title ? 8 : 0); + const r = Math.min(95, H / 2 - 20); + + const parts: string[] = []; + parts.push( + ``, + ); + if (title) { + parts.push(`${escapeHtml(title)}`); + } + + let angle = -Math.PI / 2; + slices.forEach((s, i) => { + const frac = s.value / total; + const a1 = angle + frac * Math.PI * 2; + const x0 = cx + r * Math.cos(angle); + const y0 = cy + r * Math.sin(angle); + const x1 = cx + r * Math.cos(a1); + const y1 = cy + r * Math.sin(a1); + const large = frac > 0.5 ? 1 : 0; + const color = PIE_COLORS[i % PIE_COLORS.length]; + parts.push( + ``, + ); + const mid = (angle + a1) / 2; + if (frac >= 0.04) { + parts.push( + `${Math.round(frac * 100)}%`, + ); + } + // legend + const ly = 36 + i * 24; + parts.push( + ``, + `${escapeHtml(s.label)} — ${s.value}`, + ); + angle = a1; + }); + + parts.push(''); + return parts.join(''); +} + +// --------------------------------------------------------------------------- +// state diagrams +// --------------------------------------------------------------------------- + + +const ID = '(?:\\[\\*\\]|[\\w\\u00C0-\\uFFFF][\\w\\u00C0-\\uFFFF-]*)'; // Unicode-aware state identifier + +export function renderStateSvg(src: string): string | null { + if (!/^\s*stateDiagram(-v2)?\b/.test(src)) return null; + let stTitle = ''; + const labels = new Map(); + const edges: MermaidGraph['edges'] = []; + const nodeIds: string[] = []; + const seen = new Set(); + const pseudoShapes = new Map(); + const compositeMembers = new Map(); + const externallyReferenced = new Set(); + + const ID_RE = new RegExp(ID, 'g'); + const EDGE_RE = new RegExp(`^(\\[\\*\\]|${ID})\\s*-->\\s*(\\[\\*\\]|${ID})(?:\\s*:\\s*(.*))?$`); + const STATE_AS_RE = new RegExp(`^state\\s+"([^"]*)"\\s+as\\s+(${ID})$`); + const STATE_RE = new RegExp(`^state\\s+(${ID})$`); + const STATE_LABEL_RE = new RegExp(`^(${ID})\\s*:\\s*(.+)$`); + const STATE_PSEUDO_RE = new RegExp(`^state\\s+(${ID})\\s*<<(fork|join|choice)>>`); + + const node = (id: string, _shape?: string): string => { + if (!seen.has(id)) { + seen.add(id); + nodeIds.push(id); + } + return id; + }; + + // pass 1 — collect composites & pseudo shapes + let depth = 0; + let curComp: string | null = null; + for (let line of src.split('\n')) { + line = line.trim(); + if (!line || line.startsWith('%%')) continue; + if (/^stateDiagram(-v2)?\b/.test(line)) continue; + + let m = /^title\s+(.+)$/i.exec(line); + if (m) { stTitle = m[1].trim(); continue; } + + let pm = STATE_PSEUDO_RE.exec(line); + if (pm) { + pseudoShapes.set(pm[1], pm[2] === 'choice' ? 'diamond' : pm[2] as 'fork' | 'join'); + node(pm[1], 'round'); + continue; + } + + if (/\{\s*$/.test(line)) { + depth++; + if (depth === 1) { + const am = STATE_AS_RE.exec(line) ?? STATE_RE.exec(line) ?? /^(\w+)/.exec(line); + const id = am?.[2] ?? am?.[1]; + if (id) { + curComp = id; + if (!compositeMembers.has(id)) compositeMembers.set(id, []); + node(id, 'round'); + } + } + continue; + } + + if (depth > 0) { + if (/^}/.test(line)) { depth--; if (depth === 0) curComp = null; continue; } + const tm = EDGE_RE.exec(line); + if (tm && curComp) { + const members = compositeMembers.get(curComp)!; + for (const raw of [tm[1], tm[2]]) { + if (raw !== '[*]' && !members.includes(raw)) members.push(raw); + } + } + continue; + } + + const em = EDGE_RE.exec(line) ?? STATE_AS_RE.exec(line) ?? STATE_LABEL_RE.exec(line); + if (em) { + for (const cand of [em[1], em[2]]) { + if (cand && compositeMembers.has(cand)) externallyReferenced.add(cand); + } + } + } + + // pass 2 — full parse + depth = 0; + for (let line of src.split('\n')) { + line = line.trim().replace(/;+$/, ''); + if (!line || line.startsWith('%%')) continue; + if (/^stateDiagram(-v2)?\b/.test(line)) continue; + if (STATE_PSEUDO_RE.test(line)) continue; + if (/^state\s+"[^"]*"\s+as\s+\w+\s*\{/.test(line)) continue; + if (/\{\s*$/.test(line)) continue; + + let m = STATE_AS_RE.exec(line); + if (m) { + labels.set(m[2], m[1]); + node(m[2], 'round'); + continue; + } + + m = EDGE_RE.exec(line); + if (m) { + const from = m[1] === '[*]' ? node('__start__', 'start') : node(m[1], 'round'); + const to = m[2] === '[*]' ? node('__end__', 'end') : node(m[2], 'round'); + edges.push({ from, to, label: m[3]?.trim() || undefined, style: 'normal', arrow: true }); + continue; + } + + m = STATE_LABEL_RE.exec(line); + if (m) { + if (!labels.has(m[1])) labels.set(m[1], m[2].trim()); + node(m[1], 'round'); + continue; + } + } + + if (nodeIds.length === 0) return null; + + const nodes = nodeIds.map((id): MermaidGraph['nodes'][number] => ({ + id, + label: id === '__start__' || id === '__end__' ? '' : labels.get(id) ?? id, + shape: id === '__start__' ? 'start' as const + : id === '__end__' ? 'end' as const + : pseudoShapes.get(id) ?? 'round' as const, + })); + + const groups = [...compositeMembers.entries()] + .filter(([id, members]) => members.length > 0 && !externallyReferenced.has(id)) + .map(([id, members]) => ({ title: labels.get(id) ?? id, members })); + + return renderGraphSvg( + { direction: 'TB', nodes, edges }, + { title: stTitle || undefined, groups: groups.length ? groups : undefined }, + ); +} + +// gantt charts +// --------------------------------------------------------------------------- + +const DAY_MS = 86_400_000; + +function parseGanttDate(s: string): number | null { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s.trim()); + if (!m) return null; + return Date.UTC(+m[1], +m[2] - 1, +m[3]); +} + +function parseDuration(s: string): number | null { + const m = /^(\d+(?:\.\d+)?)\s*([dwm])$/i.exec(s.trim()); + if (!m) return null; + const n = parseFloat(m[1]); + const unit = m[2].toLowerCase(); + return unit === 'w' ? n * 7 : unit === 'h' ? n / 24 : n; +} + +const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +export function renderGanttSvg(src: string): string | null { + if (!/^\s*gantt\b/.test(src)) return null; + + let title = ''; + let section = ''; + let todayMarker: string | null = 'line'; + interface Row { + kind: 'section'; + label: string; + } + interface Bar { + kind: 'task'; + label: string; + status: string; + start: number; + days: number; + milestone: boolean; + } + type Row2 = Row | Bar; + const rows: Row2[] = []; + const placedById = new Map(); + + for (let line of src.split('\n')) { + line = line.trim().replace(/;+\s*$/, ''); + if (!line || line.startsWith('%%')) continue; + if (/^gantt\b/.test(line)) continue; + + let m = /^title\s+(.+)$/i.exec(line); + if (m) { title = m[1].trim(); continue; } + m = /^(?:todayMarker)\s+(\w+)/i.exec(line); + if (m) { todayMarker = m[1].toLowerCase() === 'off' ? null : (m[1] === 'on' ? 'line' : m[1]); continue; } + if (/^(dateFormat|axisFormat|excludes|includes)\b/i.test(line)) continue; // accepted, ISO assumed + + m = /^section\s+(.+)$/i.exec(line); + if (m) { + section = m[1].trim(); + rows.push({ kind: 'section', label: section }); + continue; + } + + const ci = line.indexOf(':'); + if (ci < 0) continue; + const label = line.slice(0, ci).trim(); + const meta = line.slice(ci + 1).split(',').map((p) => p.trim()).filter(Boolean); + if (meta.length < 2) continue; + + let status = ''; + let milestone = false; + let id = ''; + // leading tags until we find the start token + let i = 0; + for (; i < meta.length - 2; i++) { + const p = meta[i]; + if (/^(done|active|crit|crit,done)$/i.test(p)) status = p.toLowerCase(); + else if (/^milestone$/i.test(p)) milestone = true; + else if (!parseGanttDate(p) && !/^after\s+/i.test(p)) id = p; + else break; + } + const startTok = meta[i] ?? ''; + const durTok = meta[i + 1] ?? ''; + + let start: number | null = parseGanttDate(startTok); + if (start === null && /^after\s+\S+$/i.test(startTok)) { + const refId = startTok.split(/\s+/)[1]; + const ref = placedById.get(refId) ?? placedById.get(label) ?? undefined; + if (ref) start = ref.start + Math.max(ref.days, 0) * DAY_MS; + } + let days: number | null = parseDuration(durTok); + if (days === null) { + const endD = parseGanttDate(durTok); + if (endD !== null && start !== null) days = Math.max(1, Math.round((endD - start) / DAY_MS)); + } + if (milestone) { start = start ?? 0; days = 0; } + if (start === null || days === null) continue; + + const bar: Bar = { kind: 'task', label, status: status.replace(',done', ''), start, days, milestone }; + rows.push(bar); + if (id) placedById.set(id, bar); + + } + + const tasks = rows.filter((r): r is Bar => r.kind === 'task'); + if (tasks.length === 0) return null; + const minStart = Math.min(...tasks.map((t) => t.start)); + const maxEnd = Math.max(...tasks.map((t) => t.start + Math.max(t.days, 0) * DAY_MS)); + const totalDays = Math.max(1, Math.ceil((maxEnd - minStart) / DAY_MS)); + + const LABEL_W = 190; + const RIGHT_PAD = 24; + const dayW = totalDays <= 14 ? 26 : totalDays <= 45 ? 13 : totalDays <= 120 ? 6 : 3; + const plotW = Math.max(240, totalDays * dayW); + const W = LABEL_W + plotW + RIGHT_PAD; + + type Section = { kind: 'section'; label: string }; + const H = 34 + rows.reduce((s, r) => s + (r.kind === 'section' ? 34 : 32), 0) + 40 + (title ? 22 : 0); + const top = (title ? 22 : 0) + 10; + const xOf = (t: number) => LABEL_W + ((t - minStart) / DAY_MS) * dayW; + + const parts: string[] = []; + parts.push( + ``, + ); + if (title) { + parts.push(`${escapeHtml(title)}`); + } + + // time grid + axis labels + const stepCandidates = [1, 2, 7, 14, 30, 60]; + const stepDays = stepCandidates.find((s) => totalDays / s <= 10) ?? 90; + const gridColor = 'var(--border, #e5e7eb)'; + for (let d = 0; d <= totalDays; d += stepDays) { + const gx = LABEL_W + d * dayW; + parts.push(``); + const dt = new Date(minStart + d * DAY_MS); + parts.push( + `${MONTHS_SHORT[dt.getUTCMonth()]} ${dt.getUTCDate()}`, + ); + } + if (todayMarker) { + const today = xOf(Date.now()); + if (today >= LABEL_W && today <= LABEL_W + plotW) { + parts.push(``); + } + } + + let y = top; + const STATUS_COLORS: Record = { + done: '#10b981', + active: '#3b82f6', + crit: '#ef4444', + }; + + for (const r of rows) { + if (r.kind === 'section') { + y += 8; + parts.push(`${escapeHtml(r.label)}`); + y += 26; + continue; + } + const by = y + 6; + if (r.milestone) { + const mx = xOf(r.start); + const s = 9; + parts.push( + ``, + `${escapeHtml(r.label)}`, + ); + } else { + const bx = xOf(r.start); + const bw = Math.max(4, r.days * dayW); + const color = + r.status.includes('crit') ? (STATUS_COLORS.crit) + : r.status.includes('done') ? (STATUS_COLORS.done) + : r.status.includes('active') ? (STATUS_COLORS.active) + : '#818cf8'; + parts.push( + ``, + `${escapeHtml(r.label)}`, + ); + } + y += 32; + } + + parts.push(''); + return parts.join(''); +} diff --git a/src/docs/lib/md/mermaid-git.ts b/src/docs/lib/md/mermaid-git.ts new file mode 100644 index 000000000..d9f7e094e --- /dev/null +++ b/src/docs/lib/md/mermaid-git.ts @@ -0,0 +1,428 @@ +/** + * gitGraph and mindmap renderers. Pure TS → inline SVG. + */ + +import { escapeHtml } from './inline.js'; + +// --------------------------------------------------------------------------- +// gitGraph +// --------------------------------------------------------------------------- + +const GIT_COLORS = ['#4f46e5', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; + +export function renderGitSvg(src: string): string | null { + if (!/^\s*gitGraph\b/.test(src)) return null; + + interface Lane { name: string; color: string; y: number } + interface CommitDot { lane: number; x: number; label?: string; tag?: string; type: 'commit' | 'merge' } + + const lanes: Lane[] = [{ name: 'main', color: GIT_COLORS[0], y: 46 }]; + const laneByName = new Map([['main', 0]]); + let current = 0; + const dots: CommitDot[] = []; + const merges: { fromLane: number; toLane: number; x: number }[] = []; + const branchOuts: { fromLane: number; toLane: number; x: number }[] = []; + + for (let line of src.split('\n')) { + line = line.trim().replace(/;+$/, ''); + if (!line || line.startsWith('%%')) continue; + if (/^gitGraph\b/.test(line)) continue; + + let m = /^commit(?:\s+(.*))?$/.exec(line); + if (m) { + const rest = m[1] ?? ''; + const tagM = /tag:\s*"([^"]*)"/.exec(rest); + dots.push({ lane: current, x: dots.length * 64 + 60, label: undefined, tag: tagM?.[1], type: 'commit' }); + continue; + } + m = /^branch\s+(\S+)/.exec(line); + if (m) { + if (!laneByName.has(m[1])) { + const parentLane = current; + const lane: Lane = { name: m[1], color: GIT_COLORS[lanes.length % GIT_COLORS.length], y: 46 + lanes.length * 66 }; + lanes.push(lane); + laneByName.set(m[1], lanes.length - 1); + const x = dots.length * 64 + 60; + dots.push({ lane: lanes.length - 1, x, label: undefined, tag: undefined, type: 'commit' }); + const parentLast = [...dots].reverse().find((d) => d.lane === parentLane && d.x < x); + if (parentLast) branchOuts.push({ fromLane: parentLane, toLane: lanes.length - 1, x }); + } + current = laneByName.get(m[1])!; + continue; + } + m = /^(checkout|switch)\s+(\S+)/.exec(line); + if (m) { + if (laneByName.has(m[2])) current = laneByName.get(m[2])!; + continue; + } + m = /^merge\s+(\S+)/.exec(line); + if (m) { + const from = laneByName.get(m[1]); + if (from !== undefined) { + merges.push({ fromLane: from, toLane: current, x: dots.length * 64 + 60 }); + dots.push({ lane: current, x: dots.length * 64 + 60, type: 'merge' }); + } + continue; + } + } + + if (dots.length === 0) return null; + const W = dots.length * 64 + 80; + const H = 46 + lanes.length * 66 + 20; + + /** Orthogonal path with rounded corners through the given points. */ + const roundedPath = (pts: [number, number][], r = 12): string => { + let d = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`; + for (let i = 1; i < pts.length - 1; i++) { + const [px2, py2] = pts[i - 1]; + const [cx2, cy2] = pts[i]; + const [nx2, ny2] = pts[i + 1]; + // trim straight segments so the corner arc fits + const inLen = Math.hypot(cx2 - px2, cy2 - py2); + const outLen = Math.hypot(nx2 - cx2, ny2 - cy2); + const rr = Math.min(r, inLen / 2, outLen / 2); + const t1x = cx2 + (px2 - cx2) / (inLen || 1) * rr; + const t1y = cy2 + (py2 - cy2) / (inLen || 1) * rr; + const t2x = cx2 + (nx2 - cx2) / (outLen || 1) * rr; + const t2y = cy2 + (ny2 - cy2) / (outLen || 1) * rr; + d += ` L ${t1x.toFixed(1)} ${t1y.toFixed(1)} Q ${cx2.toFixed(1)} ${cy2.toFixed(1)} ${t2x.toFixed(1)} ${t2y.toFixed(1)}`; + } + const lastPt = pts[pts.length - 1]; + d += ` L ${lastPt[0].toFixed(1)} ${lastPt[1].toFixed(1)}`; + return d; + }; + + const parts: string[] = []; + parts.push( + ``, + ); + + // dotted full-width lane guides (under everything) + for (const lane of lanes) { + parts.push( + ``, + ); + } + + // branch connectors — almost vertical, slight rounding, thick + for (const bo of branchOuts) { + const fromY = lanes[bo.fromLane].y; + const toY = lanes[bo.toLane].y; + const prevX = dots.filter((d) => d.lane === bo.fromLane && d.x < bo.x).slice(-1)[0]?.x ?? bo.x - 64; + parts.push( + ``, + ); + } + + // merge connectors — almost vertical, slight rounding, thick + for (const mg of merges) { + const fromY = lanes[mg.fromLane].y; + const toY = lanes[mg.toLane].y; + const srcX = dots.filter((d) => d.lane === mg.fromLane && d.x <= mg.x).slice(-1)[0]?.x ?? mg.x - 64; + parts.push( + ``, + ); + } + + // colored commit-to-commit segments on top of the dotted guides + for (let li = 0; li < lanes.length; li++) { + const laneDots = dots.filter((d) => d.lane === li).sort((a, b) => a.x - b.x); + for (let k = 1; k < laneDots.length; k++) { + parts.push(``); + } + } + + // branch name chips + for (const lane of lanes) { + parts.push( + ``, + `${escapeHtml(lane.name)}`, + ); + } + + // commit dots + for (const d of dots) { + const lane = lanes[d.lane]; + parts.push(``); + if (d.tag) { + parts.push( + ``, + `${escapeHtml(d.tag)}`, + ); + } + } + + parts.push(''); + return parts.join(''); + + function surface(): string { + return 'var(--surface, #fff)'; + } +} + +// --------------------------------------------------------------------------- +// mindmap — two layouts: left-to-right tree and hub-spoke radial. +// Both are embedded; the UI's Tree/Radial bookmarks swap viewBox + visibility +// via the data-view-* / data-size-* attributes on the . +// --------------------------------------------------------------------------- + +interface MmNode { text: string; shape: 'plain' | 'square' | 'round' | 'circle'; children: MmNode[] } +interface MmWithDepth extends MmNode { depth: number; children: MmWithDepth[] } +interface MmLaid extends MmWithDepth { x: number; y: number; w: number; h: number; kids: MmLaid[] } + +const MM_LEAF_H = 44; +const MM_LEVEL_W = 190; +const MM_HUB_RING = 165; +const MM_PAD = 18; + +const mmSizeOf = (text: string, depth: number): { w: number; h: number } => ({ + w: Math.min(240, Math.max(56, text.length * 7.2 + (depth === 0 ? 40 : 28))), + h: depth === 0 ? 40 : 30, +}); +const mmFontSize = (depth: number): number => (depth === 0 ? 14 : depth === 1 ? 12.5 : 11.5); + +function parseMindmap(src: string): MmWithDepth | null { + const lines: { indent: number; text: string }[] = []; + for (let line of src.split('\n')) { + if (!line.trim() || line.trim().startsWith('%%')) continue; + if (/^mindmap\b/.test(line.trim())) continue; + const indent = line.match(/^\s*/)![0].replace(/\t/g, ' ').length; + lines.push({ indent, text: line.trim() }); + } + if (lines.length === 0) return null; + let idx = 0; + const parseLevel = (indent: number): MmWithDepth => { + const entry = lines[idx++]; + let text = entry.text; + let shape: MmNode['shape'] = 'plain'; + let sm = /^(?:\w+)?\(\((.+)\)\)$/.exec(text); + if (sm) { text = sm[1]; shape = 'circle'; } + else if (/^\[(.+)\]$/.test(text)) { text = text.slice(1, -1); shape = 'square'; } + else if (/^\((.+)\)$/.test(text)) { text = text.slice(1, -1); shape = 'round'; } + else { + sm = /^\{\{(.+)\}\}$/.exec(text); + if (sm) { text = sm[1]; shape = 'round'; } + } + const node: MmWithDepth = { text, shape, children: [], depth: indent }; + while (idx < lines.length && lines[idx].indent > indent) { + node.children.push(parseLevel(lines[idx].indent)); + } + return node; + }; + return parseLevel(lines[0].indent); +} + +interface MmLaid extends MmWithDepth { x: number; y: number; w: number; h: number; fs: number } + +function mmToLaid(n: MmWithDepth, depth: number): MmLaid { + const size = mmSizeOf(n.text, depth); + return { + ...n, + depth, + ...size, + fs: mmFontSize(depth), + x: 0, + y: 0, + kids: n.children.map((c) => mmToLaid(c, depth + 1)), + }; +} + +function mmFlatten(n: MmLaid): MmLaid[] { + const out: MmLaid[] = [n]; + for (const k of n.kids) out.push(...mmFlatten(k)); + return out; +} + +function mmBounds(nodes: MmLaid[]): { minx: number; miny: number; maxx: number; maxy: number } { + let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity; + for (const n of nodes) { + minx = Math.min(minx, n.x - n.w / 2); maxx = Math.max(maxx, n.x + n.w / 2); + miny = Math.min(miny, n.y - n.h / 2); maxy = Math.max(maxy, n.y + n.h / 2); + } + return { minx, miny, maxx, maxy }; +} + +function mmShift(nodes: MmLaid[], dx: number, dy: number): void { + for (const n of nodes) { n.x += dx; n.y += dy; } +} + +export function renderMindmapSvg(src: string): string | null { + const surface = () => 'var(--surface, #fff)'; + const accent = () => 'var(--accent, #4f46e5)'; + const root = parseMindmap(src); + if (!root) return null; + + // ---- left-to-right tree --------------------------------------------------- + const lrRoot = mmToLaid(root, 0); + let leafCursor = 0; + let lrMaxDepth = 0; + const lrAssign = (n: MmLaid): void => { + lrMaxDepth = Math.max(lrMaxDepth, n.depth); + n.x = MM_PAD + n.depth * MM_LEVEL_W + n.w / 2; + if (n.kids.length === 0) { + n.y = leafCursor * MM_LEAF_H + MM_LEAF_H / 2; + leafCursor++; + return; + } + for (const k of n.kids) lrAssign(k); + n.y = (n.kids[0].y + n.kids[n.kids.length - 1].y) / 2; + }; + lrAssign(lrRoot); + const lrNodes = mmFlatten(lrRoot); + const lb = mmBounds(lrNodes); + mmShift(lrNodes, -lb.minx + MM_PAD, -lb.miny + MM_PAD); + const lrW = lb.maxx - lb.minx + MM_PAD * 2; + const lrH = lb.maxy - lb.miny + MM_PAD * 2; + + // ---- hub-spoke radial ------------------------------------------------------- + const hubRoot = mmToLaid(root, 0); + const countLeaves = (n: MmLaid): number => + n.kids.length ? n.kids.reduce((s2, k) => s2 + countLeaves(k), 0) : 1; + const HUB_PALETTE = ['#4f46e5', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']; + let topIdx = -1; + interface HubLaid extends MmLaid { ang: number; rr: number; col: string } + const hubAssign = (n: HubLaid, a0: number, a1: number, color: string): void => { + const mid = (a0 + a1) / 2; + const r = n.depth === 0 ? 0 : MM_HUB_RING + (n.depth - 1) * 150; + n.x = Math.cos(mid) * r; + n.y = Math.sin(mid) * r; + (n as HubLaid).ang = mid; + (n as HubLaid).rr = r; + (n as HubLaid).col = color; + if (n.kids.length === 0) return; + let c0 = a0; + const total = countLeaves(n); + for (const k of n.kids as HubLaid[]) { + const span = (a1 - a0) * (countLeaves(k as HubLaid) / total); + const kcol = n.depth === 0 ? HUB_PALETTE[++topIdx % HUB_PALETTE.length] : color; + (k as HubLaid).col = kcol; + hubAssign(k as HubLaid, c0, c0 + span, kcol); + c0 += span; + } + }; + hubAssign(hubRoot as unknown as HubLaid, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2, accent()); + const hubNodes = mmFlatten(hubRoot); + const hb = mmBounds(hubNodes); + mmShift(hubNodes, -hb.minx + MM_PAD, -hb.miny + MM_PAD); + const hubW = hb.maxx - hb.minx + MM_PAD * 2; + const hubH = hb.maxy - hb.miny + MM_PAD * 2; + + const vbLr = `0 0 ${Math.ceil(lrW)} ${Math.ceil(lrH)}`; + const vbHub = `0 0 ${Math.ceil(hubW)} ${Math.ceil(hubH)}`; + + // ---- SVG -------------------------------------------------------------------- + const uid = `mm${Math.floor(Math.random() * 1e9)}`; + + const parts: string[] = []; + parts.push( + ``, + '' + + `` + + '' + + `` + + '' + + '', + ); + + const drawTree = (): void => { + const wire = (n: MmLaid): void => { + for (const k of n.kids) { + const mx = (n.x + n.w / 2 + k.x - k.w / 2) / 2; + parts.push( + ``, + ); + wire(k); + } + }; + wire(lrRoot); + for (const n of mmFlatten(lrRoot)) { + const x = n.x - n.w / 2; + const yy = n.y - n.h / 2; + const fs = mmFontSize(n.depth); + if (n.shape === 'circle') { + const r = Math.max(n.h, n.w) / 2; + parts.push(``); + } else { + const rx = n.depth === 0 ? 10 : n.shape === 'square' ? 2 : n.h / 2; + parts.push(``); + } + parts.push(`${escapeHtml(n.text)}`); + } + }; + + const drawHub = (): void => { + interface HubLaid extends MmLaid { ang: number; rr: number; col: string } + const all = mmFlatten(hubRoot) as (HubLaid & MmLaid)[]; + const meta = (n: MmLaid): HubLaid => n as unknown as HubLaid; + + parts.push( + '' + + `` + + '' + + `` + + '', + ); + + const wire = (parent: MmLaid, child: MmLaid, color: string, width: number): void => { + const p2 = parent as unknown as HubLaid; + const c2 = child as unknown as HubLaid; + const midAng = ((p2.ang ?? 0) + (c2.ang ?? 0)) / 2; + const midR = (((p2.rr ?? 0) + (c2.rr ?? 0)) / 2) || 60; + const cxp = hubRoot.x + Math.cos(midAng) * midR; + const cyp = hubRoot.y + Math.sin(midAng) * midR; + parts.push( + ``, + ); + }; + + const walkBranch = (n: MmLaid, col: string, width: number): void => { + for (const k of n.kids) { + const kc = (meta(k) as HubLaid).col ?? col; + wire(n, k, kc, width); + walkBranch(k, kc, Math.max(1, width - 0.35)); + } + }; + for (const k of hubRoot.kids) { + const kc = (meta(k) as HubLaid).col ?? accent(); + const cxp = hubRoot.x + Math.cos((meta(k) as HubLaid).ang) * ((meta(k) as HubLaid).rr * 0.4); + const cyp = hubRoot.y + Math.sin((meta(k) as HubLaid).ang) * ((meta(k) as HubLaid).rr * 0.4); + parts.push( + ``, + ); + walkBranch(k, kc, 2); + } + + for (const n of all) { + const hl = meta(n) as HubLaid; + const x = n.x - n.w / 2; + const yy = n.y - n.h / 2; + const fs = mmFontSize(n.depth); + if (n.depth === 0) { + parts.push( + ``, + `${escapeHtml(n.text)}`, + ); + } else if (n.depth === 1) { + parts.push( + ``, + `${escapeHtml(n.text)}`, + ); + } else { + parts.push( + ``, + `${escapeHtml(n.text)}`, + ); + } + } + }; + + parts.push(''); + drawTree(); + parts.push(''); + parts.push(''); + + parts.push(''); + return parts.join(''); +} diff --git a/src/docs/lib/md/mermaid-misc.ts b/src/docs/lib/md/mermaid-misc.ts new file mode 100644 index 000000000..5560d11b3 --- /dev/null +++ b/src/docs/lib/md/mermaid-misc.ts @@ -0,0 +1,582 @@ +/** + * Remaining mermaid diagram families, pure TS → inline SVG: + * journey · quadrantChart · timeline · C4* · sankey-beta · xychart-beta + */ + +import { escapeHtml } from './inline.js'; + +const PALETTE = ['#4f46e5', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6']; +const surface = 'var(--surface, #fff)'; +const dim = 'var(--text-dim, #888)'; +const borderC = 'var(--border, #ccc)'; +const accent = 'var(--accent, #4f46e5)'; + +function svgOpen(w: number, h: number, label: string): string { + return ``; +} + +// --------------------------------------------------------------------------- +// journey +// --------------------------------------------------------------------------- + +export function renderJourneySvg(src: string): string | null { + if (!/^\s*journey\b/.test(src)) return null; + let title = ''; + const sections: { name: string; tasks: { text: string; score: number; actors: string[] }[] }[] = []; + let cur: { name: string; tasks: { text: string; score: number; actors: string[] }[] } | null = null; + + for (let line of src.split('\n')) { + line = line.trim().replace(/;+$/, ''); + if (!line || line.startsWith('%%')) continue; + if (/^journey\b/.test(line)) continue; + let m = /^title\s+(.+)$/i.exec(line); + if (m) { title = m[1].trim(); continue; } + m = /^section\s+(.+)$/i.exec(line); + if (m) { + cur = { name: m[1].trim(), tasks: [] }; + sections.push(cur); + continue; + } + m = /^([^:]+):\s*(-?\d+)\s*:\s*(.*)$/.exec(line); + if (m) { + const task = { text: m[1].trim(), score: Math.max(1, Math.min(5, parseInt(m[2]))), actors: m[3].split(',').map((a) => a.trim()).filter(Boolean) }; + if (!cur) { cur = { name: '', tasks: [] }; sections.push(cur); } + cur.tasks.push(task); + } + } + if (sections.length === 0 || sections.every((s) => s.tasks.length === 0)) return null; + + const SCORE_COLORS = ['#ef4444', '#f97316', '#f59e0b', '#84cc16', '#10b981']; + const FACE = ['✕', '◔_◔', '•__•', '‿', 'ᕕ']; + const CELL_W = 130; + const W = Math.max(...sections.map((s) => s.tasks.length), 3) * CELL_W + 40; + const totalH = (title ? 34 : 14) + sections.length * (96 + 14); + let y = title ? 34 : 14; + const parts: string[] = [svgOpen(W, totalH, 'journey')]; + if (title) { + parts.push(`${escapeHtml(title)}`); + } + for (const sec of sections) { + // section panel + const panelH = 96; + parts.push(``); + parts.push(`${escapeHtml(sec.name)}`); + sec.tasks.forEach((t, i) => { + const cx = 36 + i * CELL_W + CELL_W / 2 - 10; + const cy = y + 52; + parts.push( + ``, + `${FACE[t.score - 1]}`, + `${escapeHtml(t.text)}`, + `${escapeHtml(t.actors.join(', '))}`, + ); + }); + y += panelH + 14; + } + parts.push(''); + return parts.join(''); +} + +// --------------------------------------------------------------------------- +// quadrantChart +// --------------------------------------------------------------------------- + +export function renderQuadrantSvg(src: string): string | null { + if (!/^\s*quadrantChart\b/.test(src)) return null; + let title = ''; + const quadrants: (string | null)[] = [null, null, null, null]; // 1 tr, 2 tl, 3 bl, 4 br + let xLabels: [string, string] = ['Low', 'High']; + let yLabels: [string, string] = ['Low', 'High']; + const points: { text: string; x: number; y: number }[] = []; + + for (let line of src.split('\n')) { + line = line.trim().replace(/;+$/, ''); + if (!line || line.startsWith('%%')) continue; + if (/^quadrantChart\b/.test(line)) continue; + let m = /^title\s+(.+)$/i.exec(line); + if (m) { title = m[1].trim(); continue; } + m = /^x-axis\s+(.+)$/i.exec(line); + if (m) { const p = m[1].split('-->').map((s) => s.trim()); xLabels = [p[0] ?? '', p[1] ?? '']; continue; } + m = /^y-axis\s+(.+)$/i.exec(line); + if (m) { const p = m[1].split('-->').map((s) => s.trim()); yLabels = [p[0] ?? '', p[1] ?? '']; continue; } + m = /^quadrant-(\d)\s+(.+)$/i.exec(line); + if (m) { quadrants[parseInt(m[1]) - 1] = m[2].trim(); continue; } + m = /^(.+?):\s*\[\s*([\d.]+)\s*,\s*([\d.]+)\s*\]$/.exec(line); + if (m) points.push({ text: m[1].trim(), x: parseFloat(m[2]), y: parseFloat(m[3]) }); + } + if (points.length === 0 && !title && quadrants.every((q) => !q)) return null; + + const SIZE = 380; + const PADL = 56; + const PADT = title ? 52 : 28; + const W = SIZE + PADL + 30; + const H = SIZE + PADT + 34; + const px = (v: number) => PADL + v * SIZE; + const py = (v: number) => PADT + (1 - v) * SIZE; + + const parts: string[] = [svgOpen(W, H, 'quadrant chart')]; + if (title) parts.push(`${escapeHtml(title)}`); + parts.push(``, + ``, + ``); + const qText = [ + { t: quadrants[1], x: PADL + 14, y: PADT + 24, anchor: 'start' }, + { t: quadrants[0], x: PADL + SIZE - 14, y: PADT + 24, anchor: 'end' }, + { t: quadrants[2], x: PADL + 14, y: PADT + SIZE - 14, anchor: 'start' }, + { t: quadrants[3], x: PADL + SIZE - 14, y: PADT + SIZE - 14, anchor: 'end' }, + ]; + for (const q of qText) { + if (!q.t) continue; + parts.push(`${escapeHtml(q.t)}`); + } + parts.push( + `${escapeHtml(xLabels[0])} ← ${escapeHtml(xLabels[1])}`, + `${escapeHtml(yLabels[0])} ← ${escapeHtml(yLabels[1])}`, + ); + for (const p of points) { + const cx = px(Math.max(0, Math.min(1, p.x))); + const cy = py(Math.max(0, Math.min(1, p.y))); + parts.push( + ``, + `${escapeHtml(p.text)}`, + ); + } + parts.push(''); + return parts.join(''); +} + +// --------------------------------------------------------------------------- +// timeline +// --------------------------------------------------------------------------- + +export function renderTimelineSvg(src: string): string | null { + if (!/^\s*timeline\b/.test(src)) return null; + interface Period { period: string; events: string[] } + const sections: { name?: string; periods: Period[] }[] = [{ periods: [] }]; + let title = ''; + for (let line of src.split('\n')) { + line = line.trim().replace(/;+$/, ''); + if (!line || line.startsWith('%%')) continue; + if (/^timeline\b/.test(line)) continue; + let m = /^title\s+(.+)$/i.exec(line); + if (m) { title = m[1].trim(); continue; } + m = /^section\s+(.+)$/i.exec(line); + if (m) { sections.push({ name: m[1].trim(), periods: [] }); continue; } + const ci = line.indexOf(':'); + const period = ci === -1 ? line : line.slice(0, ci).trim(); + const events = ci === -1 ? [] : line.slice(ci + 1).split(':').map((e) => e.trim()).filter(Boolean); + sections[sections.length - 1].periods.push({ period, events }); + } + const allPeriods = sections.flatMap((s) => s.periods); + if (allPeriods.length === 0) return null; + + const SPINE_X = 150; + const ROW_H = 44; + const SEC_H = 40; + let H = title ? 40 : 16; + for (const s of sections) { + if (s.name) H += SEC_H; + H += s.periods.length * ROW_H; + } + H += 24; + const W = 560; + + const parts: string[] = [svgOpen(W, H, 'timeline')]; + if (title) parts.push(`${escapeHtml(title)}`); + parts.push(``); + + let y = title ? 52 : 28; + let colorIdx = 0; + for (const s of sections) { + if (s.name) { + const c = PALETTE[colorIdx++ % PALETTE.length]; + parts.push( + ``, + `${escapeHtml(s.name)}`, + ); + y += SEC_H; + } + for (const p of s.periods) { + const dotColor = PALETTE[(colorIdx + 1) % PALETTE.length]; + parts.push(``); + parts.push(`${escapeHtml(p.period)}`); + p.events.forEach((ev, i) => { + parts.push( + ``, + `${escapeHtml(ev)}`, + ); + }); + const rowsUsed = Math.max(1, p.events.length); + y += Math.max(rowsUsed * 26 + 10, ROW_H); + } + } + void allPeriods; + parts.push(''); + return parts.join(''); +} + +// --------------------------------------------------------------------------- +// C4 Context / Container / Component / Dynamic +// --------------------------------------------------------------------------- + +interface C4El { id: string; kind: string; label: string; desc: string; boundary: string | null } +interface C4Rel { from: string; to: string; label: string; style: 'solid' | 'dashed' | 'bi' | 'back' } + +export function renderC4Svg(src: string): string | null { + if (!/^\s*C4(Context|Container|Component|Dynamic)\b/.test(src)) return null; + let title = ''; + const els: C4El[] = []; + const rels: C4Rel[] = []; + const boundaries: { id: string; name: string; members: string[] }[] = []; + const byId = new Map(); + let depth = 0; + let curBoundary: { id: string; name: string } | null = null; + + for (let line of src.split('\n')) { + line = line.trim().replace(/;+$/, ''); + if (!line || line.startsWith('%%')) continue; + if (/^C4(Context|Container|Component|Dynamic)\b/.test(line)) continue; + + let m = /^title\s+(.+)$/i.exec(line); + if (m) { title = m[1].trim(); continue; } + + if (/^(Enterprise_Boundary|System_Boundary|Container_Boundary|Boundary)\s*\(\s*(\w+)\s*,\s*"?([^"]*)"?\s*\)\s*\{?\s*$/i.test(line)) { + const bm = /^(Enterprise_Boundary|System_Boundary|Container_Boundary|Boundary)\s*\(\s*(\w+)\s*,\s*"?([^"]*)"?\s*\)/i.exec(line)!; + boundaries.push({ id: bm[2], name: bm[3], members: [] }); + curBoundary = { id: bm[2], name: bm[3] }; + if (/\{\s*$/.test(line)) depth++; + continue; + } + if (/^\}\s*$/.test(line)) { + depth = Math.max(0, depth - 1); + curBoundary = null; + continue; + } + + m = /^(Person|Person_Ext|System|System_Ext|SystemDb|SystemDb_Ext|SystemQueue|SystemQueue_Ext|Container|Container_Ext|ContainerDb|Component|ComponentDb)\s*\(\s*([\w.]+)\s*,\s*"([^"]*)"\s*(?:,\s*"([^"]*)")?[^)]*\)/i.exec(line); + if (m) { + const el: C4El = { + id: m[2], + kind: m[1], + label: m[3], + desc: (m[4] ?? '').trim(), + boundary: depth > 0 ? curBoundary?.id ?? null : null, + }; + els.push(el); + byId.set(el.id, el); + if (el.boundary) boundaries.find((b) => b.id === el.boundary)?.members.push(el.id); + continue; + } + + m = /^Rel(_Back|Bi)?\s*\(\s*([\w.]+)\s*,\s*([\w.]+)\s*(?:,\s*"([^"]*)")?[^)]*\)/i.exec(line); + if (m) { + rels.push({ + from: m[2], + to: m[3], + label: m[4]?.trim() ?? '', + style: m[1]?.toLowerCase() === 'birel' ? 'bi' : m[1] ? 'back' : 'solid', + }); + continue; + } + // UpdateElementStyle/AddRelTag etc.: skipped + } + if (els.length === 0) return null; + + // grid layout: 3 columns, declaration order + const COLS = 3; + const NODE_W = 200; + const NODE_H = 74; + const GX = 60; + const GY = 64; + const pos = new Map(); + els.forEach((el, i) => { + const col = i % COLS; + const row = Math.floor(i / COLS); + pos.set(el.id, { x: 40 + col * (NODE_W + GX) + NODE_W / 2, y: (title ? 54 : 30) + row * (NODE_H + GY) + NODE_H / 2, w: NODE_W, h: NODE_H }); + }); + const colsX = [...pos.values()].map((p) => p.x + p.w / 2); + const rowsY = [...pos.values()].map((p) => p.y + p.h / 2); + const W = Math.max(...colsX) + NODE_W / 2 + 40; + const H = Math.max(...rowsY) + NODE_H / 2 + 40; + + const parts: string[] = [svgOpen(W, H, 'C4 diagram')]; + if (title) parts.push(`${escapeHtml(title)}`); + + // boundary boxes behind + for (const b of boundaries) { + const memberPos = b.members.map((mid) => pos.get(mid)).filter(Boolean) as Placed[]; + if (memberPos.length === 0) continue; + const x1 = Math.min(...memberPos.map((p) => p.x - p.w / 2)) - 18; + const y1 = Math.min(...memberPos.map((p) => p.y - p.h / 2)) - 32; + const x2 = Math.max(...memberPos.map((p) => p.x + p.w / 2)) + 18; + const y2 = Math.max(...memberPos.map((p) => p.y + p.h / 2)) + 14; + parts.push( + ``, + `${escapeHtml(b.name)}`, + ); + } + + const lineColor = 'var(--border-strong, #6b7280)'; + // relations + for (const r of rels) { + const a = pos.get(r.from); + const b = pos.get(r.to); + if (!a || !b) continue; + const dashAttr = r.style !== 'solid' ? ' stroke-dasharray="6 4"' : ''; + parts.push( + ``, + ); + if (r.label) { + const lx = ((a.x + b.x) / 2); + const ly = Math.min(a.y, b.y) - 26; + const tw = r.label.length * 6.2 + 10; + parts.push( + `${escapeHtml(r.label)}`, + ); + void tw; + } + } + parts.push(''); + + // element boxes + for (const el of els) { + const p = pos.get(el.id)!; + const x = p.x - p.w / 2; + const y = p.y - p.h / 2; + const ext = el.kind.includes('_Ext'); + const isPerson = el.kind.startsWith('Person'); + parts.push( + ``, + `${escapeHtml(el.label)}`, + ); + if (el.desc) { + parts.push( + `${escapeHtml(el.desc.length > 34 ? `${el.desc.slice(0, 33)}…` : el.desc)}`, + ); + } + parts.push( + `[${escapeHtml(el.kind.replace('_Ext', '') )}${ext ? ': external' : ''}]`, + ); + } + + parts.push(''); + return parts.join(''); +} + +interface Placed { x: number; y: number; w: number; h: number } + +// --------------------------------------------------------------------------- +// sankey-beta +// --------------------------------------------------------------------------- + +export function renderSankeySvg(src: string): string | null { + if (!/^\s*sankey(-beta)?\b/.test(src)) return null; + const links: { from: string; to: string; value: number }[] = []; + for (let line of src.split('\n')) { + line = line.trim(); + if (!line || line.startsWith('%%') || /^sankey/.test(line)) continue; + // CSV: optionally quoted fields + const fields = line.match(/("([^"]*)")|([^,]+)/g)?.map((f) => f.replace(/^"|"$/g, '').trim()) ?? []; + if (fields.length !== 3) continue; + const value = parseFloat(fields[2]); + if (Number.isNaN(value) || value <= 0) continue; + links.push({ from: fields[0], to: fields[1], value }); + } + if (links.length === 0) return null; + + const names: string[] = []; + const seenName = new Set(); + for (const l of [links, links].flat()) { + if (!seenName.has(l.from)) { seenName.add(l.from); names.push(l.from); } + if (!seenName.has(l.to)) { seenName.add(l.to); names.push(l.to); } + } + const outSum = new Map(); + const inSum = new Map(); + for (const l of links) { + outSum.set(l.from, (outSum.get(l.from) ?? 0) + l.value); + inSum.set(l.to, (inSum.get(l.to) ?? 0) + l.value); + } + + // column assignment: 0 = only source, 2 = only target, 1 = both + const colOf = new Map(); + for (const n of names) { + colOf.set(n, outSum.has(n) && inSum.has(n) ? 1 : outSum.has(n) ? 0 : 2); + } + + const SCALE_H = 340; + const NODE_W = 22; + const GAP = 14; + const maxValue = (n: string) => Math.max(outSum.get(n) ?? 0, inSum.get(n) ?? 0); + const totalMaxPerCol = [0, 1, 2].map((c) => + Math.max(names.filter((n) => colOf.get(n) === c).reduce((s2, n) => s2 + maxValue(n) + GAP, -GAP), 1), + ); + const unit = Math.min(...totalMaxPerCol.map((t) => SCALE_H / t)); + const H = Math.max(...totalMaxPerCol.map((_, c) => totalMaxPerCol[c] * unit)) + 40; + const colX = [60, 300, 540]; + const W = 640; + + // stack nodes per column + const nodeGeom = new Map(); + const colCursor = [30, 30, 30]; + for (let c = 0; c < 3; c++) { + for (const n of names.filter((nn) => colOf.get(nn) === c)) { + const h = maxValue(n) * unit; + nodeGeom.set(n, { x: colX[c], y: colCursor[c], h }); + colCursor[c] += h + GAP; + } + } + + const parts: string[] = [svgOpen(W, Math.max(H, 120), 'sankey diagram'), '']; + parts.push(''); + const linkColors = PALETTE; + // ribbons + const usedOut = new Map(); + const usedIn = new Map(); + links.forEach((l, i) => { + const gFrom = nodeGeom.get(l.from)!; + const gTo = nodeGeom.get(l.to)!; + const h = l.value * unit; + const y0 = gFrom.y + (usedOut.get(l.from) ?? 0); + const y1 = gTo.y + (usedIn.get(l.to) ?? 0); + usedOut.set(l.from, (usedOut.get(l.from) ?? 0) + h); + usedIn.set(l.to, (usedIn.get(l.to) ?? 0) + h); + const x0 = gFrom.x + NODE_W; + const x1 = gTo.x; + const mx = (x0 + x1) / 2; + parts.push( + `${escapeHtml(`${l.from} → ${l.to}: ${l.value}`)}`, + ); + }); + // ribbon value labels + const usedOut2 = new Map(); + const usedIn2 = new Map(); + links.forEach((l) => { + const gF = nodeGeom.get(l.from)!; + const gT = nodeGeom.get(l.to)!; + const h = l.value * unit; + const y0 = gF.y + (usedOut2.get(l.from) ?? 0); + const y1 = gT.y + (usedIn2.get(l.to) ?? 0); + usedOut2.set(l.from, (usedOut2.get(l.from) ?? 0) + h); + usedIn2.set(l.to, (usedIn2.get(l.to) ?? 0) + h); + if (h < 13) return; // too thin for a label + const mx = (gF.x + NODE_W + gT.x) / 2; + const my = Math.max(y0, y1) + h / 2; + parts.push( + `${escapeHtml(String(l.value))}`, + ); + }); + + // nodes + labels (totals appended) + for (const n of names) { + const gN = nodeGeom.get(n)!; + const total = maxValue(n); + parts.push( + ``, + `${escapeHtml(`${n} · ${total}`)}`, + ); + } + parts.push(''); + return parts.join(''); +} + +// --------------------------------------------------------------------------- +// xychart-beta +// --------------------------------------------------------------------------- + +export function renderXychartSvg(src: string): string | null { + if (!/^\s*xychart(-beta)?\b/.test(src)) return null; + let title = ''; + let yTitle = ''; + let xTitle = ''; + const xCats: string[] = []; + let yRange: [number, number] | null = null; + const series: { type: 'bar' | 'line'; values: number[] }[] = []; + + for (let line of src.split('\n')) { + line = line.trim().replace(/;+$/, ''); + if (!line || line.startsWith('%%')) continue; + if (/^xychart(-beta)?\b/.test(line)) continue; + let m = /^title\s+"?([^"].*?)?"?\s*$/i.exec(line); + if (m) { title = (m[1] ?? '').trim(); continue; } + m = /^x-axis\s+"([^"]*)"\s*\[(.*)\]$/i.exec(line); + if (m) { xTitle = m[1]; xCats.push(...m[2].split(',').map((c) => c.trim().replace(/^"|"$/g, ''))); continue; } + m = /^x-axis\s+\[(.*)\]$/i.exec(line); + if (m) { xCats.push(...m[1].split(',').map((c) => c.trim().replace(/^"|"$/g, ''))); continue; } + m = /^x-axis\s+(\w+)\s+(.+)$/i.exec(line); + if (m) { xTitle = m[1]; continue; } // ranged x-axis: treated as categories-less + m = /^y-axis\s+"?([^"]*?)"?\s+(-?[\d.]+)\s*-->\s*(-?[\d.]+)$/i.exec(line); + if (m) { yTitle = m[1]; yRange = [parseFloat(m[2]), parseFloat(m[3])]; continue; } + m = /^y-axis\s+(-?[\d.]+)\s*-->\s*(-?[\d.]+)$/i.exec(line); + if (m) { yRange = [parseFloat(m[1]), parseFloat(m[2])]; continue; } + m = /^(bar|line)\s+(.+)$/i.exec(line); + if (m) { + const values = [...m[2].matchAll(/-?[\d.]+/g)].map((v) => parseFloat(v[0])); + series.push({ type: m[1].toLowerCase() as 'bar' | 'line', values }); + } + } + if (series.length === 0 || (xCats.length === 0 && series.every((s) => s.values.length === 0))) return null; + + const catCount = Math.max(xCats.length, ...series.map((s) => s.values.length)); + while (xCats.length < catCount) xCats.push(String(xCats.length + 1)); + + const allVals = series.flatMap((s) => s.values); + const dataMin = Math.min(...allVals, 0); + const dataMax = Math.max(...allVals, 1); + const yMin = yRange ? yRange[0] : Math.floor(dataMin); + const yMax = yRange ? yRange[1] : Math.ceil(dataMax); + + const M = { l: 64, r: 24, t: title ? 40 : 18, b: 46 }; + const PLOT_W = 430; + const PLOT_H = 250; + const W = M.l + PLOT_W + M.r; + const H = M.t + PLOT_H + M.b; + const px = (i: number) => M.l + ((i + 0.5) / catCount) * PLOT_W; + const py = (v: number) => M.t + (1 - (v - yMin) / Math.max(yMax - yMin, 1e-9)) * PLOT_H; + + const parts: string[] = [svgOpen(W, H, 'xy chart')]; + if (title) parts.push(`${escapeHtml(title)}`); + parts.push(``); + + // y ticks (5 divisions) + for (let k = 0; k <= 5; k++) { + const v = yMin + ((yMax - yMin) * k) / 5; + const yy = py(v); + parts.push( + ``, + `${Number.isInteger(v) ? v : v.toFixed(1)}`, + ); + } + // x labels + xCats.forEach((c, i) => { + parts.push(`${escapeHtml(c)}`); + }); + if (xTitle) parts.push(`${escapeHtml(xTitle)}`); + if (yTitle) parts.push(`${escapeHtml(yTitle)}`); + + // bars first + const barSeries = series.filter((s) => s.type === 'bar'); + if (barSeries.length > 0) { + const slot = PLOT_W / catCount; + const bw = Math.min(38, (slot * 0.72) / barSeries.length); + barSeries.forEach((s, si) => { + s.values.forEach((v, i) => { + const bx = px(i) - (bw * barSeries.length) / 2 + si * bw; + const by = py(Math.max(v, yMin)); + parts.push( + ``, + ); + }); + }); + } + // lines over bars + series.filter((s) => s.type === 'line').forEach((s, li) => { + const pts = s.values.map((v, i) => `${px(i).toFixed(1)},${py(v).toFixed(1)}`).join(' '); + parts.push( + ``, + ); + s.values.forEach((v, i) => parts.push(``)); + }); + + parts.push(''); + return parts.join(''); +} diff --git a/src/docs/lib/md/mermaid-tables.ts b/src/docs/lib/md/mermaid-tables.ts new file mode 100644 index 000000000..d3a288137 --- /dev/null +++ b/src/docs/lib/md/mermaid-tables.ts @@ -0,0 +1,429 @@ +/** + * Table-style diagram families sharing one renderer: + * erDiagram — entities with attributes, cardinality relations + * classDiagram — classes with members, UML relation markers + * Pure TS → inline SVG. + */ + +import { escapeHtml } from './inline.js'; + +export interface TableRow { text: string } +export interface TableNode { id: string; title: string; rows: string[]; headerFill?: string } +export interface TableEdge { + from: string; + to: string; + label?: string; + /** decorations */ + head: 'none' | 'triangle' | 'arrow' | 'diamond-f' | 'diamond-o' | 'crow-one' | 'crow-many'; + tail: 'none' | 'diamond-f' | 'diamond-o' | 'crow-one' | 'crow-many'; + dotted: boolean; +} +export interface TableGraph { + nodes: TableNode[]; + edges: TableEdge[]; +} + +// --------------------------------------------------------------------------- +// shared layered layout (cycle-aware) + SVG +// --------------------------------------------------------------------------- + +interface Placed { x: number; y: number; w: number; h: number } + +function layoutTables(g: TableGraph, sizeOf: (n: TableNode) => { w: number; h: number }): { + pos: Map; + width: number; + height: number; +} { + const ids = g.nodes.map((n) => n.id); + const level = new Map(ids.map((id) => [id, 0])); + const byId = new Map(g.nodes.map((n) => [n.id, n])); + + // back-edge detection + const outgoing = new Map(ids.map((id) => [id, []])); + for (const e of g.edges) if (byId.has(e.from) && byId.has(e.to)) outgoing.get(e.from)!.push(e.to); + const backEdges = new Set(); + const color = new Map(ids.map((id) => [id, 0])); + const visit = (v: string): void => { + color.set(v, 1); + for (const w of outgoing.get(v) ?? []) { + const c = color.get(w) ?? 0; + if (c === 1) backEdges.add(`${v}->${w}`); + else if (c === 0) visit(w); + } + color.set(v, 2); + }; + for (const id of ids) if ((color.get(id) ?? 0) === 0) visit(id); + + // relax levels along forward edges only + for (let pass = 0; pass < ids.length; pass++) { + let changed = false; + for (const e of g.edges) { + if (!byId.has(e.from) || !byId.has(e.to)) continue; + if (backEdges.has(`${e.from}->${e.to}`)) continue; + const want = (level.get(e.from) ?? 0) + 1; + if (want > (level.get(e.to) ?? 0)) { level.set(e.to, want); changed = true; } + } + if (!changed) break; + } + + const rows = new Map(); + for (const id of ids) { + const l = Math.min(level.get(id) ?? 0, ids.length - 1); + if (!rows.has(l)) rows.set(l, []); + rows.get(l)!.push(id); + } + + const pos = new Map(); + const GAP_X = 60; + const ROW_GAP = 56; + + let crossSize = 0; + const rowSizes = new Map(); + let maxLevel = 0; + for (const [l, row] of rows) { + maxLevel = Math.max(maxLevel, l); + const sizes = row.map((id) => sizeOf(byId.get(id)!)); + rowSizes.set(l, sizes); + crossSize = Math.max(crossSize, sizes.reduce((s2, sz) => s2 + sz.w + GAP_X, -GAP_X)); + } + + let depthSize = 0; + for (const [l, sizes] of rowSizes) depthSize += Math.max(...sizes.map((s2) => s2.h)) + ROW_GAP; + depthSize -= ROW_GAP; + + for (const [l, row] of rows) { + const sizes = rowSizes.get(l)!; + const rowW = sizes.reduce((s2, sz) => s2 + sz.w + GAP_X, -GAP_X); + const rowH = Math.max(...sizes.map((s2) => s2.h)); + let acc = (crossSize - rowW) / 2; + void rowW; + const yBase = [...rowSizes.keys()].sort((a, b) => a - b).slice(0, l) + .reduce((s2, k) => s2 + Math.max(...rowSizes.get(k)!.map((s3) => s3.h)) + ROW_GAP, 0); + row.forEach((id, i) => { + pos.set(id, { x: acc + sizes[i].w / 2, y: yBase + rowH / 2, w: sizes[i].w, h: sizes[i].h }); + acc += sizes[i].w + GAP_X; + }); + } + + return { pos, width: crossSize, height: depthSize }; +} + +function markerDefs(): string { + return ( + '' + + '' + + '' + + '' + + '' + + '' + ); +} + +/** Render a table graph. Returns svg string; empty nodes → null-safe empty svg. */ +export function renderTableSvg( + g: TableGraph, + opts?: { title?: string }, +): string | null { + if (g.nodes.length === 0) return null; + + const CHAR_W = 7.0; + const sizeOf = (n: TableNode): { w: number; h: number } => { + const longest = Math.max(n.title.length, ...n.rows.map((r) => r.length), 8); + return { w: Math.min(320, Math.max(150, longest * CHAR_W + 34)), h: 30 + n.rows.length * 17 + 8 }; + }; + const { pos, width, height } = layoutTables(g, sizeOf); + + const PAD = 26; + const W = width + PAD * 2; + const H = height + PAD * 2 + (opts?.title ? 24 : 0); + + const parts: string[] = []; + parts.push( + ``, + markerDefs(), + ); + if (opts?.title) { + parts.push(`${escapeHtml(opts.title)}`); + } + const lineColor = 'var(--border-strong, #6b7280)'; + const surface = 'var(--surface, #fff)'; + const accent = 'var(--accent, #4f46e5)'; + + interface Deco { x: number; y: number; ux: number; uy: number } + /** Decorations sit ON the box edge; u points FROM the box ALONG the wire. */ + const drawDeco = (d: Deco, kind: TableEdge['head'] | TableEdge['tail']): void => { + const { x, y, ux, uy } = d; + const px2 = -uy; + const py2 = ux; + if (kind === 'crow-many' || kind === 'crow-one') { + const sx2 = x - ux * 10; + const sy2 = y - uy * 10; + parts.push( + ``, + ); + if (kind === 'crow-one') { + parts.push(``); + } + } else if (kind === 'triangle') { + parts.push( + ``, + ); + } else if (kind === 'diamond-f' || kind === 'diamond-o') { + const fill = kind === 'diamond-f' ? 'currentColor' : surface; + parts.push( + ``, + ); + } else if (kind === 'arrow') { + parts.push( + ``, + ); + } + }; + + // edges under boxes — ONE orthogonal connector per relation, decorated once + // per end based on which gap between the two boxes is widest. + for (const e of g.edges) { + const a = pos.get(e.from); + const b = pos.get(e.to); + if (!a || !b) continue; + + const gapR = b.x - b.w / 2 - (a.x + a.w / 2); + const gapL = a.x - a.w / 2 - (b.x + b.w / 2); + const gapD = b.y - b.h / 2 - (a.y + a.h / 2); + const gapU = a.y - a.h / 2 - (b.y + b.h / 2); + const best = Math.max(gapR, gapL, gapD, gapU); + + let d: string; + let sDeco: Deco | null = null; + let tDeco: Deco | null = null; + let lx = 0; + let ly = 0; + + if (best === gapR && gapR > 24) { + const sx = a.x + a.w / 2, sy = a.y; + const ex = b.x - b.w / 2, ey = b.y; + const mx = (sx + ex) / 2; + d = `M ${sx.toFixed(1)} ${sy.toFixed(1)} L ${mx.toFixed(1)} ${sy.toFixed(1)} L ${mx.toFixed(1)} ${ey.toFixed(1)} L ${ex.toFixed(1)} ${ey.toFixed(1)}`; + sDeco = { x: sx, y: sy, ux: 1, uy: 0 }; + tDeco = { x: ex, y: ey, ux: -1, uy: 0 }; + lx = mx; ly = sy - 10; + } else if (best === gapL && gapL > 24) { + const sx = a.x - a.w / 2, sy = a.y; + const ex = b.x + b.w / 2, ey = b.y; + const mx = (sx + ex) / 2; + d = `M ${sx.toFixed(1)} ${sy.toFixed(1)} L ${mx.toFixed(1)} ${sy.toFixed(1)} L ${mx.toFixed(1)} ${ey.toFixed(1)} L ${ex.toFixed(1)} ${ey.toFixed(1)}`; + sDeco = { x: sx, y: sy, ux: -1, uy: 0 }; + tDeco = { x: ex, y: ey, ux: 1, uy: 0 }; + lx = mx; ly = sy - 10; + } else if (best === gapD && gapD > 24) { + const sx = a.x, sy = a.y + a.h / 2; + const ex = b.x, ey = b.y - b.h / 2; + const my2 = (sy + ey) / 2; + d = `M ${sx.toFixed(1)} ${sy.toFixed(1)} L ${sx.toFixed(1)} ${my2.toFixed(1)} L ${ex.toFixed(1)} ${my2.toFixed(1)} L ${ex.toFixed(1)} ${ey.toFixed(1)}`; + sDeco = { x: sx, y: sy, ux: 0, uy: 1 }; + tDeco = { x: ex, y: ey, ux: 0, uy: -1 }; + lx = Math.max(sx, ex) + 8; ly = my2; + } else { + const sx = a.x, sy = a.y - a.h / 2; + const ex = b.x, ey = b.y + b.h / 2; + const my2 = (sy + ey) / 2; + d = `M ${sx.toFixed(1)} ${sy.toFixed(1)} L ${sx.toFixed(1)} ${my2.toFixed(1)} L ${ex.toFixed(1)} ${my2.toFixed(1)} L ${ex.toFixed(1)} ${ey.toFixed(1)}`; + sDeco = { x: sx, y: sy, ux: 0, uy: -1 }; + tDeco = { x: ex, y: ey, ux: 0, uy: 1 }; + lx = Math.max(sx, ex) + 8; ly = my2; + } + + parts.push(``); + if (sDeco) drawDeco(sDeco, e.tail); + if (tDeco) drawDeco(tDeco, e.head); + + if (e.label) { + const tw = e.label.length * 6.4 + 10; + parts.push( + ``, + `${escapeHtml(e.label)}`, + ); + } + } + + // entity/class boxes + for (const n of g.nodes) { + const p = pos.get(n.id); + if (!p) continue; + const x = p.x - p.w / 2; + const y = p.y - p.h / 2; + const hdrH = 28; + parts.push( + ``, + ``, + ``, + `${escapeHtml(n.title)}`, + ); + n.rows.forEach((r, i) => { + parts.push( + `${escapeHtml(r)}`, + ); + }); + } + + parts.push(''); + return parts.join(''); +} + +const CARD_RE = /^[|}o{]{2}$/; + +const CROW_HEAD: Record = { + '||': 'crow-one', + 'o|': 'crow-one', + '}o': 'crow-many', + '|{': 'crow-many', + '}{': 'crow-many', +}; + +export function renderErDiagram(src: string): string | null { + if (!/^\s*erDiagram\b/.test(src)) return null; + const nodes = new Map(); + const edges: TableEdge[] = []; + + const node = (id: string): TableNode => { + let n = nodes.get(id); + if (!n) { + n = { id, title: id, rows: [] }; + nodes.set(id, n); + } + return n; + }; + + let depth = 0; + let curEntity: TableNode | null = null; + for (let line of src.split('\n')) { + line = line.trim(); + if (!line || line.startsWith('%%')) continue; + if (/^erDiagram\b/.test(line)) continue; + + if (/^\w+\s*\{\s*$/.test(line)) { + curEntity = node(line.replace(/\{.*$/, '').trim()); + depth++; + continue; + } + if (depth > 0) { + if (/^\}/.test(line)) { depth--; curEntity = null; continue; } + curEntity?.rows.push(line); + continue; + } + + const m = /^(\w+)\s+([|}o{]{2})\s*--([|}o{]{2})\s+(\w+)\s*(?::\s*(.*))?$/.exec(line); + if (m && CARD_RE.test(m[2]) && CARD_RE.test(m[3])) { + node(m[1]); + node(m[4]); + edges.push({ + from: m[1], + to: m[4], + label: m[5]?.trim() || undefined, + head: (CROW_HEAD[m[3]] ?? 'none') as TableEdge['head'], + tail: (CROW_HEAD[m[2]] ?? 'none') as TableEdge['tail'], + dotted: false, + }); + continue; + } + } + return renderTableSvg({ nodes: [...nodes.values()], edges }); +} + +// --------------------------------------------------------------------------- +// classDiagram +// --------------------------------------------------------------------------- + +interface ClassRelSpec { head: TableEdge['head']; tail: TableEdge['tail']; dotted: boolean } + +const CLASS_RELS: [RegExp, ClassRelSpec][] = [ + [/^<\|--$/, { head: 'triangle', tail: 'none', dotted: false }], + [/^\.\.\|>$/, { head: 'triangle', tail: 'none', dotted: true }], + [/^--\|>$/, { head: 'triangle', tail: 'none', dotted: false }], + [/^\*--$/, { head: 'none', tail: 'diamond-f', dotted: false }], + [/^--\*$/, { head: 'diamond-f', tail: 'none', dotted: false }], + [/^o--$/, { head: 'none', tail: 'diamond-o', dotted: false }], + [/^--o$/, { head: 'diamond-o', tail: 'none', dotted: false }], + [/^\.\.>$/, { head: 'arrow', tail: 'none', dotted: true }], + [/^-->$/, { head: 'arrow', tail: 'none', dotted: false }], + [/^--$/, { head: 'none', tail: 'none', dotted: false }], + [/^\.\.$/, { head: 'none', tail: 'none', dotted: true }], +]; + +export function renderClassDiagram(src: string): string | null { + if (!/^\s*classDiagram\b/.test(src)) return null; + const nodes = new Map(); + const edges: TableEdge[] = []; + const generics = new Map(); // id -> "~T" annotation + + const node = (rawId: string): TableNode => { + let title = rawId; + const gm = /^([\w-]+)(~.+~)$/.exec(rawId); + if (gm) { + title = `${gm[1]}${gm[2]}`; + rawId = gm[1]; + } + let n = nodes.get(rawId); + if (!n) { + n = { id: rawId, title, rows: [] }; + nodes.set(rawId, n); + } + return n; + }; + + let depth = 0; + let curClass: TableNode | null = null; + for (let line of src.split('\n')) { + line = line.trim().replace(/;+$/, ''); + if (!line || line.startsWith('%%')) continue; + if (/^classDiagram\b/.test(line)) continue; + if (/^(namespace\s|direction\b)/.test(line)) continue; + + if (/\{\s*$/.test(line)) { + // `class Note {` / `interface Foo {` — strip the keyword so the node id + // matches the one used in relation lines (otherwise we'd get duplicates) + const body = line.replace(/\{[\s\S]*$/, '').replace(/^(class|interface)\s+/i, '').trim(); + curClass = node(body); + depth++; + continue; + } + if (depth > 0) { + if (/^\}/.test(line)) { depth--; curClass = null; continue; } + const mm = /^([+#~-])?\s*(.*)$/.exec(line); + curClass?.rows.push(`${mm?.[1] ?? ''}${mm?.[2] ?? ''}`.replace(/returns?$|Return(s)?$/i, '')); + continue; + } + + let m = /^class\s+(.+?)\s*$/.exec(line); + if (m && !m[1].includes('--') && !m[1].includes('<|--')) { + const n = node(m[1]); + void n; + continue; + } + + m = /^(\S+)\s*(<\|--|\.\.\|>|--\|>|\*--|--\*|o--|--o|\.\.>|-->|--|\.\.)\s*(\S+)\s*(?::\s*(.*))?$/.exec(line); + if (m) { + const spec = CLASS_RELS.find(([re]) => re.test(m![2]))?.[1] ?? { head: 'arrow' as const, tail: 'none' as const, dotted: false }; + node(m[1]); + node(m[3]); + edges.push({ + from: m[1].replace(/~.*~/, ''), + to: m[3].replace(/~.*~/, ''), + label: m[4]?.trim() || undefined, + head: spec.head, + tail: spec.tail, + dotted: spec.dotted, + }); + continue; + } + + m = /^(\S+)\s*:\s*(.+)$/.exec(line); + if (m) { + const n = node(m[1]); + n.rows.push(m[2].trim()); + continue; + } + } + void generics; + return renderTableSvg({ nodes: [...nodes.values()], edges }); +} diff --git a/src/docs/lib/md/mermaid.ts b/src/docs/lib/md/mermaid.ts new file mode 100644 index 000000000..13e71417b --- /dev/null +++ b/src/docs/lib/md/mermaid.ts @@ -0,0 +1,820 @@ +/** + * Minimal Mermaid `graph`/`flowchart` renderer → inline SVG. Pure TS. + * + * Supports the common subset: direction headers (TB/TD, BT, LR, RL), node + * definitions with shapes ([rect], (rounded), ([stadium]), ((circle)), + * {diamond}, [[sub]]), edge styles (--> , ---, -.-> , ==>), edge labels + * (`A -->|lbl| B` and `A -- lbl --> B`), chained statements, comments (%%) + * and semicolon separators. Unsupported constructs (subgraph styling, + * classDefs, themes…) are skipped gracefully; unparseable input yields null + * so callers can show the raw source instead. + */ + +import { escapeHtml } from './inline.js'; +import { renderSequenceSvg, renderPieSvg, renderStateSvg, renderGanttSvg } from './mermaid-diagrams.js'; +import { renderErDiagram, renderClassDiagram } from './mermaid-tables.js'; +import { renderGitSvg, renderMindmapSvg } from './mermaid-git.js'; +import { renderJourneySvg, renderQuadrantSvg, renderTimelineSvg, renderC4Svg, renderSankeySvg, renderXychartSvg } from './mermaid-misc.js'; + +export interface MermaidNode { + id: string; + label: string; + shape: 'rect' | 'round' | 'stadium' | 'circle' | 'diamond' | 'sub' | 'start' | 'end' | 'fork' | 'join'; +} + +export interface MermaidEdge { + from: string; + to: string; + label?: string; + style: 'normal' | 'dotted' | 'thick'; + arrow: boolean; +} + +export interface MermaidGraph { + direction: 'TB' | 'BT' | 'LR' | 'RL'; + nodes: MermaidNode[]; + edges: MermaidEdge[]; +} + +const OPENERS: [string, string, MermaidNode['shape']][] = [ + ['((', '))', 'circle'], + ['([', '])', 'stadium'], + ['[[', ']]', 'sub'], + ['(', ')', 'round'], + ['{', '}', 'diamond'], + ['[', ']', 'rect'], +]; + +/** Parse a mermaid flowchart. Returns null when the input is not one. */ +export function parseMermaid(src: string): MermaidGraph | null { + const lines = src.split('\n').map((l) => l.trim()); + const headerIdx = lines.findIndex((l) => /^(graph|flowchart)\b/.test(l)); + if (headerIdx === -1) return null; + const hm = /^(?:graph|flowchart)\s+(TB|TD|BT|LR|RL)\s*;?\s*(.*)$/.exec(lines[headerIdx]); + if (!hm) return null; + const dirRaw = hm[1]; + const direction = (dirRaw === 'TD' ? 'TB' : dirRaw) as MermaidGraph['direction']; + + const bodyLines: string[] = []; + const rest0 = hm[2].trim(); // statements sharing the header line + if (rest0) bodyLines.push(rest0.replace(/;+\s*$/, '')); + lines.forEach((t, idx) => { + if (idx === headerIdx) return; + if (!t || t.startsWith('%%')) return; + if (/^(subgraph|end|classDef|class\s|style\s|click\s|linkStyle\s|direction\s)/i.test(t)) return; + bodyLines.push(t.replace(/;+\s*$/, '')); + }); + + const nodes = new Map(); + const edges: MermaidEdge[] = []; + // pre-scan subgraph declarations so edges can reference them as pseudo-nodes + const sgTitles = new Map(); + for (const t of lines) { + const sm = /^subgraph\s+([A-Za-z_][\w.-]*)(?:\s+\[(.*?)\])?\s*;?\s*$/i.exec(t) + ?? /^subgraph\s+\[(.*?)\]\s*;?\s*$/i.exec(t); + if (sm) sgTitles.set(sm[1] ?? '', (sm[2] ?? sm[1] ?? '').trim()); + } + + const getNode = (id: string, label?: string, shape?: MermaidNode['shape']): MermaidNode => { + let n = nodes.get(id); + if (!n) { + n = { id, label: label ?? id, shape: shape ?? 'rect' }; + nodes.set(id, n); + } else if (label !== undefined || shape !== undefined) { + if (shape) n.shape = shape; + if (label !== undefined && (n.label === n.id || label !== '')) n.label = label || n.label; + } + return n; + }; + + for (const stmtRaw of bodyLines.flatMap((l) => l.split(';'))) { + const stmt = stmtRaw.trim(); + if (!stmt) continue; + + // tokenize with `&` endpoint chaining: + // a & b --> c edges a→c, b→c + // a --> b & c edges a→b, a→c + // a --> b & c --> d edges a→b, a→c, b→d, c→d + let rest = stmt.trim(); + const arrowRe = /^(-->|-\.->|==>|---)(?:\|([^|]*)\|)?/; + interface Pending { style: MermaidEdge['style']; arrow: boolean; label?: string; len: number } + let pending: Pending | null = null; + let origin: string[] = []; // sources of the pending arrow + let current: string[] = []; // most recent endpoints + let expectingNode = true; // next token should be a node + let joinSrc = false; // '&' before an arrow → extra source + let justArrow = false; // an arrow's edges were just completed + let dupTgt = false; // '&' after an arrow → duplicate targets + + const pushEdge = (fromId: string, toId: string, spec: Pending): void => { + edges.push({ from: fromId, to: toId, label: spec.label || undefined, style: spec.style, arrow: spec.arrow }); + }; + + /** Read one arrow at the front of `rest`: plain forms, pipe labels and + * spaced labels (`-- text -->`, `-. text .->`, `== text ==>`). */ + const readArrowAt = (r0: string): Pending | null => { + let m = /^(-->|-\.->|==>)(?:\|([^|]*)\|)?/.exec(r0); + if (m) { + return { + len: m[0].length, + style: m[1].includes('.') ? 'dotted' : m[1].startsWith('==') ? 'thick' : 'normal', + arrow: true, + label: m[2]?.trim(), + }; + } + m = /^(---)(?:\|([^|]*)\|)?/.exec(r0); + if (m) { + return { len: m[0].length, style: 'normal', arrow: false, label: m[2]?.trim() }; + } + m = /^--\s*([^-\n>][^>\n]*?)?\s*-->/.exec(r0); + if (m) return { style: 'normal', arrow: true, label: m[1]?.trim(), len: m[0].length }; + m = /^-\.\s*([^-.\n>][^>\n]*?)?\s*\.->/.exec(r0); + if (m) return { style: 'dotted', arrow: true, label: m[1]?.trim(), len: m[0].length }; + m = /^==\s*([^=\n>][^>\n]*?)?\s*==>/.exec(r0); + if (m) return { style: 'thick', arrow: true, label: m[1]?.trim(), len: m[0].length }; + return null; + }; + + while (rest.length > 0) { + rest = rest.replace(/^\s+/, ''); + if (rest.length === 0) break; + + if (!joinSrc && !dupTgt && !expectingNode) { + const spec = readArrowAt(rest); + if (spec) { + origin = [...current]; + current = []; + dupTgt = false; + joinSrc = false; + expectingNode = true; + pending = spec; + rest = rest.slice(spec.len); + continue; + } + } + + if (/^&/.test(rest)) { + rest = rest.slice(1).replace(/^\s+/, ''); + if (pending || !justArrow) { + joinSrc = true; // extra source joins the current endpoint set + expectingNode = true; + } else { + dupTgt = true; // duplicate the last arrow's targets + expectingNode = true; + } + continue; + } + + const res = consumeNode(rest, getNode, nodes); + if (!res) break; + const nid = res.id; + rest = res.rest; + + if (pending) { + for (const fromId of joinSrc ? current : origin.length ? origin : current) { + pushEdge(fromId, nid, pending); + } + pending = null; + current = [nid]; + joinSrc = false; + dupTgt = false; + justArrow = true; + } else if (dupTgt && origin.length > 0) { + for (const fromId of origin) pushEdge(fromId, nid, pending ?? { style: 'normal', arrow: true, len: 0 }); + current.push(nid); + dupTgt = false; + } else if (joinSrc) { + justArrow = false; + current.push(nid); + joinSrc = false; + } else { + current = [nid]; + } + expectingNode = false; + // allow 'a --> b & c': after target node, a following '&' sets dupTgt next pass + } + } + + if (nodes.size === 0) return null; + return { direction, nodes: [...nodes.values()], edges }; +} + + +/** Read one node token (id + optional shape) from the front of `rest`. */ +function consumeNode( + rest: string, + getNode: (id: string, label?: string, shape?: MermaidNode['shape']) => MermaidNode, + _nodes: Map, +): { id: string; rest: string } | null { + const idm = /^[A-Za-z_][\w]*(?:[.-][\w]+)*/.exec(rest); + if (!idm) return null; + let pos = idm[0].length; + const id = idm[0]; + + for (const [open, close, shape] of OPENERS) { + if (rest.startsWith(open, pos)) { + const depthScanFrom = pos + open.length; + let depth = 1; + let end = -1; + for (let i = depthScanFrom; i < rest.length; i++) { + if (rest.startsWith(open, i)) depth++; + else if (rest.startsWith(close, i)) { + depth--; + if (depth === 0) { + end = i; + break; + } + } + } + if (end === -1) return { id, rest: rest.slice(pos) }; + let label = rest.slice(pos + open.length, end).trim(); + if (/^".*"$/.test(label)) label = label.slice(1, -1); + label = label.replace(/^["']|["']$/g, '').replace(/[``]/g, '').replace(//gi, ' '); + getNode(id, label || id, shape); + return { id, rest: rest.slice(end + close.length) }; + } + } + + getNode(id); + return { id, rest: rest.slice(pos) }; +} + +// --------------------------------------------------------------------------- +// layout + SVG +// --------------------------------------------------------------------------- + +const NODE_W_MIN = 64; + +const hhOf = (shape: MermaidNode['shape']): number => (shape === 'fork' || shape === 'join' ? 7 : NODE_H / 2); +const NODE_H = 40; +const CHAR_W = 7.2; +const GAP_X = 56; +const GAP_Y = 76; +const PAD = 24; + +/** Layered layout: level = longest path from a root; cycle-tolerant. */ +export function layoutGraph(g: MermaidGraph): { pos: Map; width: number; height: number } { + const ids = g.nodes.map((n) => n.id); + const nodeById = new Map(g.nodes.map((n) => [n.id, n])); + const incoming = new Map(ids.map((id) => [id, []])); + const outgoing = new Map(ids.map((id) => [id, []])); + for (const e of g.edges) { + if (!nodeById.has(e.from) || !nodeById.has(e.to)) continue; + outgoing.get(e.from)!.push(e.to); + incoming.get(e.to)!.push(e.from); + } + + // longest-path leveling: detect back edges (cycles) first, then relax + // levels only along forward edges so feedback loops don't inflate rows. + const backEdges = new Set(); + { + const color = new Map(ids.map((id) => [id, 0])); // 0=new 1=active 2=done + const visit = (v: string): void => { + color.set(v, 1); + for (const w of outgoing.get(v) ?? []) { + if (!nodeById.has(w)) continue; + const c = color.get(w) ?? 0; + if (c === 1) backEdges.add(`${v}->${w}`); // cycle + else if (c === 0) visit(w); + } + color.set(v, 2); + }; + for (const id of ids) if ((color.get(id) ?? 0) === 0) visit(id); + } + + const level = new Map(ids.map((id) => [id, 0])); + for (let pass = 0; pass < ids.length; pass++) { + let changed = false; + for (const e of g.edges) { + if (!nodeById.has(e.from) || !nodeById.has(e.to)) continue; + if (backEdges.has(`${e.from}->${e.to}`)) continue; // ignore feedback loops + const want = (level.get(e.from) ?? 0) + 1; + if (want > (level.get(e.to) ?? 0)) { + level.set(e.to, want); + changed = true; + } + } + if (!changed) break; + } + + const horizontal = g.direction === 'LR' || g.direction === 'RL'; + const rows = new Map(); + for (const id of ids) { + const l = Math.min(level.get(id) ?? 0, ids.length - 1); + if (!rows.has(l)) rows.set(l, []); + rows.get(l)!.push(id); + } + + const pos = new Map(); + const widthOf = (n: MermaidNode) => + n.shape === 'start' || n.shape === 'end' + ? 40 + : n.shape === 'fork' || n.shape === 'join' + ? 120 + : Math.max(NODE_W_MIN, Math.min(220, n.label.length * CHAR_W + 28)); + const hOf = (n: MermaidNode): number => (n.shape === 'fork' || n.shape === 'join' ? 12 : NODE_H); + + const maxLevel = Math.max(...rows.keys(), 0); + + if (horizontal) { + // levels flow left→right (or right→left); nodes inside a level stack vertically + const colW: number[] = []; + let totalH = 0; + for (let l = 0; l <= maxLevel; l++) { + const row = rows.get(l) ?? []; + colW.push(Math.max(...row.map((id) => widthOf(nodeById.get(id)!)), NODE_W_MIN)); + totalH = Math.max(totalH, row.length * (NODE_H + GAP_Y) - GAP_Y); + } + const cum: number[] = []; + let run = 0; + for (let l = 0; l <= maxLevel; l++) { cum.push(run); run += colW[l] + GAP_X; } + const width = run - GAP_X; + const height = totalH; + for (let l = 0; l <= maxLevel; l++) { + const row = rows.get(l) ?? []; + const accY = (totalH - (row.length * (NODE_H + GAP_Y) - GAP_Y)) / 2; + row.forEach((id, i) => { + const cxCol = cum[l] + colW[l] / 2; + const cy = accY + i * (NODE_H + GAP_Y) + NODE_H / 2; + pos.set(id, g.direction === 'LR' + ? { x: cxCol, y: cy, w: widthOf(nodeById.get(id)!) } + : { x: width - cxCol, y: cy, w: widthOf(nodeById.get(id)!) }); + }); + } + return { pos, width, height }; + } + + // vertical layouts (TB/TD/BT): levels are rows + const crossSize = Math.max( + ...[...rows.values()].map((row) => row.reduce((s, id) => s + widthOf(nodeById.get(id)!) + GAP_X, -GAP_X)), + NODE_W_MIN, + ); + const depthSize = (maxLevel + 1) * (NODE_H + GAP_Y) - GAP_Y; + for (const [l, row] of rows) { + let acc = (crossSize - (row.reduce((s, id) => s + widthOf(nodeById.get(id)!) + GAP_X, -GAP_X))) / 2; + const y = l * (NODE_H + GAP_Y) + NODE_H / 2; + for (const id of row) { + const w = widthOf(nodeById.get(id)!); + const x = acc + w / 2; + pos.set(id, g.direction === 'BT' ? { x, y: depthSize - y, w } : { x, y, w }); + acc += w + GAP_X; + } + } + return { pos, width: crossSize, height: depthSize }; +} + +/** Render a parsed flowchart graph to SVG (shared with other diagram families). */ +export interface GraphStyleOptions { + title?: string; + nodeStyles?: Map>; + groups?: { title: string; members: string[]; style?: Record }[]; + /** Per-edge style overrides indexed by order of appearance ('default' key applies to all). */ + edgeStyles?: Map>; + theme?: string; +} + +export function renderGraphSvg( + g: MermaidGraph, + opts?: GraphStyleOptions, +): string { + const { pos, width, height } = layoutGraph(g); + + // detect back edges early so the canvas reserves side channels for them + const nodeById2 = new Map(g.nodes.map((n) => [n.id, n])); + const lvl0 = new Map(g.nodes.map((n) => [n.id, 0])); + { + const out2 = new Map(g.nodes.map((n) => [n.id, []])); + for (const e of g.edges) if (nodeById2.has(e.from) && nodeById2.has(e.to)) out2.get(e.from)!.push(e.to); + const backSet = new Set(); + const colr = new Map(g.nodes.map((n) => [n.id, 0])); + const vis = (v: string): void => { + colr.set(v, 1); + for (const w of out2.get(v) ?? []) { + const c = colr.get(w) ?? 0; + if (c === 1) backSet.add(`${v}->${w}`); + else if (c === 0) vis(w); + } + colr.set(v, 2); + }; + for (const n of g.nodes) if ((colr.get(n.id) ?? 0) === 0) vis(n.id); + for (let pass = 0; pass < g.nodes.length; pass++) { + let changed = false; + for (const e of g.edges) { + if (!nodeById2.has(e.from) || !nodeById2.has(e.to)) continue; + if (backSet.has(`${e.from}->${e.to}`)) continue; + const want = (lvl0.get(e.from) ?? 0) + 1; + if (want > (lvl0.get(e.to) ?? 0)) { lvl0.set(e.to, want); changed = true; } + } + if (!changed) break; + } + } + let backLeft = false; + let backRight = false; + for (const e of g.edges) { + if (!nodeById2.has(e.from) || !nodeById2.has(e.to)) continue; + if (e.from === e.to) continue; + if ((lvl0.get(e.to) ?? 0) <= (lvl0.get(e.from) ?? 0)) { + const aP = pos.get(e.from)!; + if (aP.x >= width / 2) backRight = true; else backLeft = true; + } + } + + const horizontal = g.direction === 'LR' || g.direction === 'RL'; + const padL = PAD + (backLeft ? 34 : 0); + const padR = PAD + (backRight ? 34 : 0); + const padT = PAD + (opts?.groups?.length ? 28 : 0); // room for subgraph title chips + const W = width + padL + padR; + const H = height + padT + PAD + (horizontal && backLeft ? 30 : 0) + (horizontal && backRight ? 30 : 0); + + const px = (id: string) => { + const p = pos.get(id); + if (!p) return null; + const shape = g.nodes.find((n) => n.id === id)?.shape ?? 'rect'; + return { cx: p.x + padL, cy: p.y + padT, w: p.w, h: hhOf(shape) * 2 }; + }; + + const parts: string[] = []; + parts.push( + ``, + ); + if (opts?.title) { + parts.push(`${escapeHtml(opts.title)}`); + } + parts.push( + '', + ); + + // subgraph group boxes behind everything + for (const grp of opts?.groups ?? []) { + const memberPos = grp.members.map((mid) => px(mid)).filter(Boolean) as { cx: number; cy: number; w: number; h: number }[]; + if (memberPos.length === 0) continue; + const x1 = Math.min(...memberPos.map((m) => m.cx - m.w / 2)) - 18; + const y1 = Math.min(...memberPos.map((m) => m.cy - m.h / 2)) - 26; + const x2 = Math.max(...memberPos.map((m) => m.cx + m.w / 2)) + 18; + const y2 = Math.max(...memberPos.map((m) => m.cy + m.h / 2)) + 14; + const gs = grp.style ?? {}; + const gFill = sanitizeColor(gs.fill) ?? 'var(--accent-soft, #eef)'; + const gStroke = sanitizeColor(gs.stroke) ?? 'var(--border, #ccc)'; + const gDash = gs['stroke-dasharray'] && /^[\w\s,.%-]+$/.test(gs['stroke-dasharray']) ? ` stroke-dasharray="${gs['stroke-dasharray']}"` : ' stroke-dasharray="6 4"'; + parts.push( + ``, + ); + if (grp.title) { + parts.push( + `${escapeHtml(grp.title)}`, + ); + } + } + + // edges first (under the nodes) + const lvl = lvl0; + const backSet = new Set(); + for (const e of g.edges) { + if (!nodeById2.has(e.from) || !nodeById2.has(e.to)) continue; + if ((lvl0.get(e.to) ?? 0) <= (lvl0.get(e.from) ?? 0)) backSet.add(`${e.from}->${e.to}`); + } + + // degree bookkeeping: fan out multiple branches from one node instead of + // stacking them on a single anchor point + const outDeg = new Map(); + const inDeg = new Map(); + for (const e of g.edges) { + if (!nodeById2.has(e.from) || !nodeById2.has(e.to)) continue; + outDeg.set(e.from, (outDeg.get(e.from) ?? 0) + 1); + inDeg.set(e.to, (inDeg.get(e.to) ?? 0) + 1); + } + const outSeen = new Map(); + const inSeen = new Map(); + /** spread anchor along the node edge when several branches share it */ + const spread = (w: number, i: number, n: number): number => + n <= 1 ? 0 : ((i / (n - 1)) - 0.5) * w * 0.72; + + let edgeIndex = -1; + for (const e of g.edges) { + edgeIndex++; + const a = px(e.from); + const b = px(e.to); + if (!a || !b) continue; + let dash = e.style === 'dotted' ? ' stroke-dasharray="4 4"' : ''; + let sw = e.style === 'thick' ? ' stroke-width="2.4"' : ''; + let color = 'var(--border-strong, #6b7280)'; + if (opts?.edgeStyles) { + const es = opts.edgeStyles.get(edgeIndex) ?? opts.edgeStyles.get('default'); + if (es) { + color = sanitizeColor(es.stroke) ?? color; + if (es['stroke-width'] && /^[\d.]+(px)?$/.test(es['stroke-width'])) sw = ` stroke-width="${parseFloat(es['stroke-width'])}"`; + if (es['stroke-dasharray'] && /^[\w\s,.%-]+$/.test(es['stroke-dasharray'])) dash = ` stroke-dasharray="${es['stroke-dasharray']}"`; + } + } + + const oi = outSeen.get(e.from) ?? 0; + outSeen.set(e.from, oi + 1); + const outTotal = outDeg.get(e.from) ?? 1; + const ii = inSeen.get(e.to) ?? 0; + inSeen.set(e.to, ii + 1); + const inTotal = inDeg.get(e.to) ?? 1; + + const fromShape = nodeById2.get(e.from)?.shape ?? 'rect'; + const toShape = nodeById2.get(e.to)?.shape ?? 'rect'; + const ah = hhOf(fromShape); + const bh = hhOf(toShape); + + const isBack = e.from !== e.to && (lvl.get(e.to) ?? 0) <= (lvl.get(e.from) ?? 0); + + let d: string; + let lx: number; + let ly: number; + + if (isBack) { + // route around the outside so feedback loops never cross other nodes + const rightSide = a.cx >= width / 2; + const chx = rightSide ? W - padR + 17 : padL - 17; + if (horizontal) { + // vertical channel above/below the whole diagram + const ahH = hhOf(fromShape); + const bhH = hhOf(toShape); + const yCh = a.cy >= height / 2 ? height + 24 : PAD - 18; + d = `M ${a.cx.toFixed(1)} ${(a.cy + ahH).toFixed(1)} L ${a.cx.toFixed(1)} ${yCh.toFixed(1)} L ${b.cx.toFixed(1)} ${yCh.toFixed(1)} L ${b.cx.toFixed(1)} ${(b.cy + bhH).toFixed(1)}`; + lx = b.cx; + ly = yCh - 8; + } else { + // TD: horizontal channel on the outer side + const sx0 = rightSide ? a.cx + a.w / 2 : a.cx - a.w / 2; + const ex0 = rightSide ? b.cx + b.w / 2 : b.cx - b.w / 2; + d = `M ${sx0.toFixed(1)} ${a.cy.toFixed(1)} L ${chx.toFixed(1)} ${a.cy.toFixed(1)} L ${chx.toFixed(1)} ${b.cy.toFixed(1)} L ${ex0.toFixed(1)} ${b.cy.toFixed(1)}`; + lx = chx + (rightSide ? -8 : 8); + ly = (a.cy + b.cy) / 2; + } + } else if (horizontal) { + // elbow x must sit in the GAP BETWEEN the two columns — averaging the + // y-coords here made wires double back through their own source boxes. + const sx = a.cx + a.w / 2; + const ex = Math.max(b.cx - b.w / 2, sx + 16); + const sy = a.cy + spread(a.h, oi, outTotal); + const ey = b.cy + spread(b.h, ii, inTotal); + const mxx = (sx + ex) / 2; + d = `M ${sx.toFixed(1)} ${sy.toFixed(1)} L ${mxx.toFixed(1)} ${sy.toFixed(1)} L ${mxx.toFixed(1)} ${ey.toFixed(1)} L ${ex.toFixed(1)} ${ey.toFixed(1)}`; + lx = mxx; + ly = sy; + } else { + const sy = a.cy + ah; + const ey = b.cy - bh; + const sx = a.cx + spread(a.w, oi, outTotal); + const ex = b.cx + spread(b.w, ii, inTotal); + const my = (sy + ey) / 2; + d = `M ${sx.toFixed(1)} ${sy.toFixed(1)} L ${sx.toFixed(1)} ${my.toFixed(1)} L ${ex.toFixed(1)} ${my.toFixed(1)} L ${ex.toFixed(1)} ${ey.toFixed(1)}`; + lx = sx; + ly = my; + } + + parts.push(``); + if (e.label) { + const tw = e.label.length * 6.4 + 10; + parts.push( + ``, + `${escapeHtml(e.label)}`, + ); + } + } + + // nodes + for (const n of g.nodes) { + const p = px(n.id); + if (!p) continue; + const x = p.cx - p.w / 2; + const y = p.cy - NODE_H / 2; + const st = opts?.nodeStyles?.get(n.id); + const sv = (k: string, dflt: string): string => sanitizeColor(st?.[k]) ?? dflt; + const common = + `fill="${sv('fill', 'var(--surface, #fff)')}" stroke="${sv('stroke', 'var(--accent, #4f46e5)')}"` + + ` stroke-width="${st?.['stroke-width'] && /^[\d.]+(px)?$/.test(st['stroke-width']) ? parseFloat(st['stroke-width']) : 1.6}"`; + const textColor = sanitizeColor(st?.['color']) ?? 'currentColor'; + switch (n.shape) { + case 'round': + parts.push(``); + break; + case 'stadium': + parts.push(``); + break; + case 'circle': { + const r = Math.max(NODE_H, p.w) / 2; + parts.push(``); + break; + } + case 'diamond': { + const dw = Math.max(p.w * 1.35, 90); + const dh = NODE_H * 1.6; + parts.push( + ``, + ); + break; + } + case 'fork': + case 'join': + parts.push(``); + break; + case 'start': + parts.push(``); + break; + case 'end': + parts.push(``); + parts.push(``); + break; + case 'sub': + parts.push(``); + parts.push( + ``, + ); + break; + default: + parts.push(``); + } + parts.push( + `${escapeHtml(n.label)}`, + ); + } + + parts.push(''); + return parts.join(''); +} + + +/** Attribute-safe escaping. */ +function quoteAttrId(s: string): string { + return s.replace(/&/g, '&').replace(/"/g, '"').replace(/ { + const out: Record = {}; + let depth = 0; + let cur = ''; + const parts: string[] = []; + for (const ch of raw) { + if (ch === '(') depth++; + if (ch === ')') depth--; + if (ch === ',' && depth === 0) { + parts.push(cur); + cur = ''; + continue; + } + cur += ch; + } + parts.push(cur); + for (const part of parts) { + const i = part.indexOf(':'); + if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim(); + } + return out; +} + +/** Full pipeline: mermaid source → SVG markup. Null when unsupported. */ +/** + * Strip mermaid YAML frontmatter (a leading `---` block, as on + * mermaid.js.org). Returns the body plus a flat view of the metadata where + * nested keys are dotted (`config.theme` → `config.theme`). Unknown keys are + * preserved so diagrams stay forward-compatible. + */ +export function extractDiagramFrontmatter(src: string): { fm: Record; body: string } { + const lines = src.split('\n'); + if (!/^---\s*$/.test(lines[0] ?? '')) return { fm: {}, body: src }; + let close = -1; + for (let i = 1; i < lines.length && i < 60; i++) { + if (/^---\s*$|^\.\.\.\s*$/.test(lines[i])) { close = i; break; } + } + if (close === -1) return { fm: {}, body: src }; + + const fm: Record = {}; + const stack: { indent: number; path: string }[] = []; + for (let i = 1; i < close; i++) { + const raw = lines[i]; + if (!raw.trim()) continue; + const kv = /^(\s*)([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(raw); + if (!kv) continue; + const indent = kv[1].length; + const key = kv[2]; + const value = kv[3].trim().replace(/^["']|["']$/g, ''); + while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop(); + const path = stack.length ? `${stack[stack.length - 1].path}.${key}` : key; + if (value !== '') fm[path] = value; + stack.push({ indent, path }); + } + return { fm, body: lines.slice(close + 1).join('\n') }; +} + +/** Families whose renderer understands a plain `title …` statement. */ +const TITLE_STATEMENT_RE = + /^(sequenceDiagram|gantt|journey|quadrantChart|timeline|pie|stateDiagram(-v2)?|C4(Context|Container|Component|Dynamic)|xychart(-beta)?)\b/; + +export function renderMermaidSvg(src: string): string | null { + // mermaid frontmatter: `title` becomes a title statement (for families that + // support one), `config.*` is read (theme applied where supported), + // everything else tolerated. + const { fm, body: fmBody } = extractDiagramFrontmatter(src); + const theme = fm['config.theme'] ?? fm['config.config.theme']; + let body = fmBody; + const noInlineTitle = /^sequenceDiagram|^stateDiagram(-v2)?\b/.test(body.trim()); + if (fm.title && TITLE_STATEMENT_RE.test(body.trim()) && (noInlineTitle || !/^title\s/m.test(body))) { + const ls = body.split('\n'); + const hi = ls.findIndex((l) => l.trim() !== ''); // header line + if (hi >= 0) ls.splice(hi + 1, 0, `title ${fm.title}`); + else ls.push(`title ${fm.title}`); + body = ls.join('\n'); + } + src = body; + + const t = src.trim(); + if (/^sequenceDiagram\b/.test(t)) return renderSequenceSvg(src); + if (/^pie\b/.test(t)) return renderPieSvg(src); + if (/^stateDiagram(-v2)?\b/.test(t)) return renderStateSvg(src); + if (/^gantt\b/.test(t)) return renderGanttSvg(src); + if (/^erDiagram\b/.test(t)) return renderErDiagram(src); + if (/^classDiagram\b/.test(t)) return renderClassDiagram(src); + if (/^gitGraph\b/.test(t)) return renderGitSvg(src); + if (/^mindmap\b/.test(t)) return renderMindmapSvg(src); + if (/^journey\b/.test(t)) return renderJourneySvg(src); + if (/^quadrantChart\b/.test(t)) return renderQuadrantSvg(src); + if (/^timeline\b/.test(t)) return renderTimelineSvg(src); + if (/^C4(Context|Container|Component|Dynamic)\b/.test(t)) return renderC4Svg(src); + if (/^sankey(-beta)?\b/.test(t)) return renderSankeySvg(src); + if (/^xychart(-beta)?\b/.test(t)) return renderXychartSvg(src); + const g = parseMermaid(src); + if (!g) return null; + + // flowchart extras: subgraphs + classDef/class/style directives + const groups: { title: string; members: string[]; style?: Record }[] = []; + const groupIds = new Map(); // subgraph id -> index in groups + const nodeStyles = new Map>(); + const classDefs = new Map>(); + const edgeStyles = new Map>(); + let currentGroup: { id: string; title: string; members: string[] } | null = null; + + for (let line of src.split('\n')) { + line = line.trim().replace(/;+\s*$/, ''); + if (!line || line.startsWith('%%')) continue; + + let m = /^subgraph\s+([A-Za-z_][\w.-]*)(?:\s+\[(.*?)\])?\s*$/i.exec(line) + ?? /^subgraph\s+\[(.*?)\]\s*$/i.exec(line); + if (m) { + const gid = (m[1] ?? '').trim(); + currentGroup = { id: gid, title: m[2] ?? m[1] ?? '', members: [] }; + if (gid) groupIds.set(gid, groups.length); + groups.push(currentGroup); + continue; + } + if (/^end\s*;?$/i.test(line)) { + currentGroup = null; + continue; + } + m = /^classDef\s+([\w-]+)\s+(.+)$/.exec(line); + if (m) { + classDefs.set(m[1], parseStyleProps(m[2])); + continue; + } + m = /^class\s+([\w\s,-]+?)\s+([\w-]+)\s*$/.exec(line); + if (m) { + const props = classDefs.get(m[2]); + if (!props) continue; + for (const id of m[1].split(/[\s,]+/)) { + const gi = groupIds.get(id); + if (gi !== undefined) groups[gi].style = { ...groups[gi].style, ...props }; + else nodeStyles.set(id, { ...nodeStyles.get(id), ...props }); + } + continue; + } + m = /^style\s+([\w-]+)\s+(.+)$/.exec(line); + if (m) { + const props = parseStyleProps(m[2]); + const gi = groupIds.get(m[1]); + if (gi !== undefined) groups[gi].style = { ...groups[gi].style, ...props }; + else nodeStyles.set(m[1], { ...nodeStyles.get(m[1]), ...props }); + continue; + } + m = /^linkStyle\s+(default|[\d\s,]+?)\s+(.+)$/.exec(line); + if (m) { + const props = parseStyleProps(m[2]); + if (m[1].trim() === 'default') edgeStyles.set('default', { ...edgeStyles.get('default'), ...props }); + else for (const idx of m[1].split(/[\s,]+/).map(Number)) if (!Number.isNaN(idx)) edgeStyles.set(idx, { ...edgeStyles.get(idx), ...props }); + continue; + } + // track subgraph membership from plain node declarations inside the block + if (currentGroup) { + const idm = /^([A-Za-z_][\w]*(?:[.-][\w]+)*)/.exec(line); + if (idm && !currentGroup.members.includes(idm[1])) currentGroup.members.push(idm[1]); + } + } + + return renderGraphSvg(g, { + title: fm.title || undefined, + theme, + nodeStyles: nodeStyles.size ? nodeStyles : undefined, + groups: groups.length ? groups : undefined, + edgeStyles: edgeStyles.size ? edgeStyles : undefined, + }); +} diff --git a/src/docs/lib/util.ts b/src/docs/lib/util.ts new file mode 100644 index 000000000..95e2b2a72 --- /dev/null +++ b/src/docs/lib/util.ts @@ -0,0 +1,66 @@ +/** Small pure helpers shared by core + tests. */ + +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'; + +let counter = 0; + +/** Uniquish id: crypto when available, otherwise time+random (pure TS fallback). */ +export function uid(): string { + const g: unknown = (globalThis as Record).crypto; + if (g && typeof (g as { randomUUID?: () => string }).randomUUID === 'function') { + try { + return ((g as { randomUUID: () => string }).randomUUID() ?? '').replace(/-/g, '').slice(0, 12); + } catch { + /* fall through */ + } + } + counter = (counter + 1) % 46_656; + let s = ''; + const rand = Math.random().toString(36).slice(2); + for (let i = 0; i < 9; i++) s += ALPHABET[Math.floor(Math.random() * ALPHABET.length)]; + return Date.now().toString(36) + counter.toString(36).padStart(2, '0') + rand.slice(0, 4); +} + +export function slugify(text: string): string { + const s = text + .toLowerCase() + .normalize('NFKD') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); + return s || 'note'; +} + +/** Compact human timestamp for UI chrome ("Mar 4, 15:02"). */ +export function formatTime(ts: number): string { + const d = new Date(ts); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${months[d.getMonth()]} ${d.getDate()}, ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +/** ISO timestamp for exports. */ +export function isoTime(ts: number): string { + return new Date(ts).toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +/** Normalize a tag name (lowercase, trimmed, stray punctuation removed). */ +export function normalizeTag(raw: string): string { + const t = raw + .trim() + .toLowerCase() + .replace(/^#+/, '') + .replace(/\s+/g, '-') + .replace(/[^a-z0-9_-]/g, ''); + return /^[a-z0-9][a-z0-9_-]*$/.test(t) ? t : ''; +} + +/** Split a tag input (comma separated) into clean unique tags. */ +export function parseTagInput(raw: string): string[] { + const out = new Set(); + for (const part of raw.split(',')) { + const t = normalizeTag(part); + if (t) out.add(t); + } + return [...out]; +} From 151ffaeec50fd1164133c023001d3cebe72bf2bc Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 24 Aug 2026 22:58:42 +0300 Subject: [PATCH 2/7] Add document API functions to api.js - listDocuments, getDocumentTree, getDocument - createDocument, updateDocument, deleteDocument - getPublicDocument - Uses v5 API prefix separate from the default apiVersion Signed-off-by: Alexander Bokovoy --- src/services/api.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/services/api.js b/src/services/api.js index 638fec3dd..4f4261457 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -19,6 +19,7 @@ const apiRoot = CONFIG.api.root; const frontendPrefsId = CONFIG.frontendPreferences.clientId; const apiPrefix = `${apiRoot}/v${apiVersion}`; +const docsApiPrefix = `${apiRoot}/v5`; const getRequestOptions = () => ({ headers: { @@ -990,3 +991,33 @@ export function pinPost({ postId, target }) { export function unpinPost({ postId, target }) { return fetch(`${apiPrefix}/posts/${postId}/unpin`, postRequestOptions('POST', { target })); } +// ── Documents (v5 API) ────────────────────────────────────────────────────── + +export function listDocuments({ limit = 50, offset = 0 } = {}) { + return fetch(`${docsApiPrefix}/documents?limit=${limit}&offset=${offset}`, getRequestOptions()); +} + +export function getDocumentTree() { + return fetch(`${docsApiPrefix}/documents/tree`, getRequestOptions()); +} + +export function getDocument({ docId }) { + return fetch(`${docsApiPrefix}/documents/${docId}`, getRequestOptions()); +} + +export function createDocument({ title, slug, body, parentId, tags, visibility, isPublished }) { + return fetch(`${docsApiPrefix}/documents`, postRequestOptions('POST', { title, slug: slug || undefined, body, parentId, tags, visibility, isPublished })); +} + +export function updateDocument({ docId, title, slug, body, parentId, tags, visibility, isPublished }) { + return fetch(`${docsApiPrefix}/documents/${docId}`, postRequestOptions('PUT', { title, slug: slug || undefined, body, parentId, tags, visibility, isPublished })); +} + +export function deleteDocument({ docId }) { + return fetch(`${docsApiPrefix}/documents/${docId}`, postRequestOptions('DELETE')); +} + +export function getPublicDocument({ slug, username }) { + const path = username ? `/docs/${encodeURIComponent(username)}/${encodeURIComponent(slug)}` : `/docs/${encodeURIComponent(slug)}`; + return fetch(`${apiRoot}${path}`, getRequestOptions()); +} From cc4a2324b6c72657a28373ab04fcf08f2c63467c Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 24 Aug 2026 22:58:54 +0300 Subject: [PATCH 3/7] Add Redux actions and reducers for document management Six action types for documents: LIST, TREE, GET, CREATE, UPDATE, DELETE Action creators with apiRequest pattern matching existing Freefeed conventions Reducers for async states and response data carriers Signed-off-by: Alexander Bokovoy --- src/redux/action-creators.js | 49 ++++++++++++++++++++++++++++++++++++ src/redux/action-types.js | 8 ++++++ src/redux/reducers.js | 29 +++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/src/redux/action-creators.js b/src/redux/action-creators.js index beec9b6b5..fab788ebb 100644 --- a/src/redux/action-creators.js +++ b/src/redux/action-creators.js @@ -1505,3 +1505,52 @@ export function setResolvedRoutes(routes) { payload: { routes }, }; } + +// ── Documents ─────────────────────────────────────────────────────────────── + +export function listDocuments({ limit = 50, offset = 0 } = {}) { + return { + type: ActionTypes.DOCUMENTS_LIST, + apiRequest: Api.listDocuments, + payload: { limit, offset }, + }; +} + +export function getDocumentTree() { + return { + type: ActionTypes.DOCUMENTS_TREE, + apiRequest: Api.getDocumentTree, + }; +} + +export function getDocument(docId) { + return { + type: ActionTypes.DOCUMENT_GET, + apiRequest: Api.getDocument, + payload: { docId }, + }; +} + +export function createDocument({ title, slug, body, parentId, tags, isPublished }) { + return { + type: ActionTypes.DOCUMENT_CREATE, + apiRequest: Api.createDocument, + payload: { title, slug, body, parentId, tags, isPublished }, + }; +} + +export function updateDocument({ docId, title, slug, body, parentId, tags, isPublished }) { + return { + type: ActionTypes.DOCUMENT_UPDATE, + apiRequest: Api.updateDocument, + payload: { docId, title, slug, body, parentId, tags, isPublished }, + }; +} + +export function deleteDocument(docId) { + return { + type: ActionTypes.DOCUMENT_DELETE, + apiRequest: Api.deleteDocument, + payload: { docId }, + }; +} diff --git a/src/redux/action-types.js b/src/redux/action-types.js index 60e5b505c..c731a7fd5 100644 --- a/src/redux/action-types.js +++ b/src/redux/action-types.js @@ -196,3 +196,11 @@ export const PIN_POST = 'PIN_POST'; export const UNPIN_POST = 'UNPIN_POST'; export const REORDER_FEED_ENTRIES = 'REORDER_FEED_ENTRIES'; export const SET_RESOLVED_ROUTES = 'SET_RESOLVED_ROUTES'; + +// Documents +export const DOCUMENTS_LIST = 'DOCUMENTS_LIST'; +export const DOCUMENTS_TREE = 'DOCUMENTS_TREE'; +export const DOCUMENT_GET = 'DOCUMENT_GET'; +export const DOCUMENT_CREATE = 'DOCUMENT_CREATE'; +export const DOCUMENT_UPDATE = 'DOCUMENT_UPDATE'; +export const DOCUMENT_DELETE = 'DOCUMENT_DELETE'; diff --git a/src/redux/reducers.js b/src/redux/reducers.js index 9966325f7..1f96c2d82 100644 --- a/src/redux/reducers.js +++ b/src/redux/reducers.js @@ -2285,3 +2285,32 @@ export { commentPreviewStatuses, commentPreviewsData, } from './reducers/post-previews'; + +// ── Documents ──────────────────────────────────────────────────────────── + +export const documentsList = asyncState(ActionTypes.DOCUMENTS_LIST, (state, action) => { + if (baseType(action.type) === ActionTypes.DOCUMENT_DELETE && isAsync(action.type) && action.type.endsWith('response')) { + const deletedId = action.payload?.err ? null : action.request?.docId; + if (deletedId && Array.isArray(state?.list)) { + return { ...state, list: state.list.filter((d) => d.id !== deletedId) }; + } + } + return state; +}); + +export const documentsListData = fromResponse(ActionTypes.DOCUMENTS_LIST, (action) => action.payload?.documents ?? []); + +export const documentGet = asyncState(ActionTypes.DOCUMENT_GET); +export const documentGetData = fromResponse(ActionTypes.DOCUMENT_GET, (action) => action.payload?.documents ?? null); + +export const documentCreate = asyncState(ActionTypes.DOCUMENT_CREATE); +export const documentCreateData = fromResponse(ActionTypes.DOCUMENT_CREATE, (action) => action.payload?.documents ?? null); + +export const documentUpdate = asyncState(ActionTypes.DOCUMENT_UPDATE); +export const documentUpdateData = fromResponse(ActionTypes.DOCUMENT_UPDATE, (action) => action.payload?.documents ?? null); + +export const documentDelete = asyncState(ActionTypes.DOCUMENT_DELETE); + +export const documentsTree = asyncState(ActionTypes.DOCUMENTS_TREE); +export const documentsTreeData = fromResponse(ActionTypes.DOCUMENTS_TREE, (action) => action.payload ?? { tree: [], tags: [] }); + From cbfbcd8d04c4c257b778bcdaaef947092fac9f6a Mon Sep 17 00:00:00 2001 From: Alexander Bokovoy Date: Mon, 24 Aug 2026 22:59:05 +0300 Subject: [PATCH 4/7] Add document UI components with per-block click-to-edit - DocumentView: rendered markdown with per-block inline editing (click to edit, blur to save locally); toolbar with copy/export/import/slug/visibility - DocumentList: top bar with Browse dropdown showing user's document tree - DocumentsPage: orchestrates state, handles save/delete with tree refresh - PublicDocumentView: read-only public view at /docs/:user/:slug - Inline slug editing and visibility selector with default from user feed Signed-off-by: Alexander Bokovoy --- src/components/docs/DocumentList.jsx | 140 ++++++++++ src/components/docs/DocumentView.jsx | 311 +++++++++++++++++++++ src/components/docs/DocumentsPage.jsx | 75 +++++ src/components/docs/PublicDocumentView.jsx | 80 ++++++ src/components/docs/documents.scss | 101 +++++++ src/components/docs/sample-content.md | 65 +++++ 6 files changed, 772 insertions(+) create mode 100644 src/components/docs/DocumentList.jsx create mode 100644 src/components/docs/DocumentView.jsx create mode 100644 src/components/docs/DocumentsPage.jsx create mode 100644 src/components/docs/PublicDocumentView.jsx create mode 100644 src/components/docs/documents.scss create mode 100644 src/components/docs/sample-content.md diff --git a/src/components/docs/DocumentList.jsx b/src/components/docs/DocumentList.jsx new file mode 100644 index 000000000..06eba9b39 --- /dev/null +++ b/src/components/docs/DocumentList.jsx @@ -0,0 +1,140 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { Icon } from '../fontawesome-icons'; +import { + faFile, + faFolder, + faFolderOpen, + faPlus, + faTag, + faChevronRight, + faChevronDown, + faBars, +} from '@fortawesome/free-solid-svg-icons'; + +import { getDocumentTree } from '../../redux/action-creators'; +import { Throbber } from '../throbber'; + +function TreeItem({ node, depth, selectedId, onSelect }) { + const [expanded, setExpanded] = useState(depth < 1); + const hasChildren = node.children?.length > 0; + + useEffect(() => { + if (node.id === selectedId) { + setExpanded(true); + } + }, [node.id, selectedId]); + + return ( +
    + {hasChildren ? ( + + ) : ( + + )} + + {hasChildren && expanded && ( +
    + {node.children.map((child) => ( + + ))} +
    + )} +
    + ); +} + +export default function DocumentList({ onSelect, onCreate, selectedId, refreshKey }) { + const dispatch = useDispatch(); + const treeStatus = useSelector((state) => state.documentsTree); + const treeData = useSelector((state) => state.documentsTreeData); + const [showTree, setShowTree] = useState(false); + const panelRef = useRef(null); + + // Refresh tree whenever the Browse panel opens + useEffect(() => { + if (showTree) dispatch(getDocumentTree()); + }, [showTree, dispatch]); + + // Also refresh when refreshKey increments (mutation happened) + useEffect(() => { + dispatch(getDocumentTree()); + }, [refreshKey, dispatch]); + useEffect(() => { + if (!showTree) return; + const handleClick = (e) => { + if (panelRef.current && !panelRef.current.contains(e.target)) { + setShowTree(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [showTree]); + + const tree = treeData?.tree || []; + + if (treeStatus.error) { + return
    Failed to load documents
    ; + } + + return ( +
    +
    + + {selectedId && ( + + {treeData && (() => { + const find = (nodes) => { + for (const n of nodes) { + if (n.id === selectedId) return n.title; + if (n.children) { + const found = find(n.children); + if (found) return found; + } + } + return null; + }; + return find(tree) || 'Document'; + })()} + + )} +
    + +
    +
    + + {showTree && ( +
    + {treeStatus.loading ? ( +
    + ) : tree.length === 0 ? ( +
    No documents yet
    + ) : ( +
    + {tree.map((node) => ( + { onSelect(id); setShowTree(false); }} /> + ))} +
    + )} +
    + )} +
    + ); +} \ No newline at end of file diff --git a/src/components/docs/DocumentView.jsx b/src/components/docs/DocumentView.jsx new file mode 100644 index 000000000..7809282b9 --- /dev/null +++ b/src/components/docs/DocumentView.jsx @@ -0,0 +1,311 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { Icon } from '../fontawesome-icons'; +import { faSave, faTrashAlt, faCode, faDownload, faUpload, faCopy, faLink } from '@fortawesome/free-solid-svg-icons'; +import { parseMarkdown } from '../../docs/lib/index'; +import { blockHtml } from '../../docs/lib/block-html'; +function formatDate(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d)) return iso; + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export default function DocumentView({ document: doc, onSave, onDelete }) { + const currentUser = useSelector((state) => state.user); + const createStatus = useSelector((state) => state.documentCreate); + const updateStatus = useSelector((state) => state.documentUpdate); + + const userDefaultVis = currentUser?.isPrivate === '1' ? 'private' + : currentUser?.isProtected === '1' ? 'protected' : 'public'; + + const [title, setTitle] = useState(doc.title); + const [slug, setSlug] = useState(doc.slug); + const [body, setBody] = useState(doc.body || ''); + const [tags, setTags] = useState(doc.tags || []); + const [tagInput, setTagInput] = useState(''); + const [visibility, setVisibility] = useState(doc.visibility || userDefaultVis); + const [showTags, setShowTags] = useState(false); + const [showSource, setShowSource] = useState(false); + const [dirty, setDirty] = useState(false); + const [editStart, setEditStart] = useState(-1); + const [editEnd, setEditEnd] = useState(-1); + const [editValue, setEditValue] = useState(''); + const [editMinH, setEditMinH] = useState(40); + const taRef = useRef(null); + const bodyRef = useRef(null); + const fileInputRef = useRef(null); + + const isNew = !doc.id || doc.id === '__sample__'; + const isOwner = doc.createdBy && currentUser && currentUser.id === doc.createdBy; + const canEdit = isNew || isOwner; + const markDirty = useCallback(() => setDirty(true), []); + const username = doc.createdByUsername || (doc.createdBy && currentUser?.username); + const documentsUrl = username ? `/docs/${username}/${slug}` : `/docs/${slug}`; + + const handleAddTag = useCallback((t) => { + const tag = t.trim().toLowerCase(); + if (tag && !tags.includes(tag)) { setTags([...tags, tag]); markDirty(); } + }, [tags, markDirty]); + + useEffect(() => { + setTitle(doc.title); + setSlug(doc.slug); + setBody(doc.body || ''); + setTags(doc.tags || []); + setVisibility(doc.visibility || userDefaultVis); + setShowSource(false); setDirty(false); + setEditStart(-1); setEditEnd(-1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [doc.id, doc.title, doc.slug, doc.body, doc.tags, doc.visibility]); + + useEffect(() => { + if (!title.trim() && body) { + const m = body.match(/^#\s+(.+)$/m); + if (m) setTitle(m[1].trim()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [body]); + + useEffect(() => { + const el = bodyRef.current; + if (!el || showSource) return; + const handler = (e) => { + const cb = e.target.closest('.doc-task-check'); + if (!cb) return; + e.preventDefault(); + const blockEl = cb.closest('[data-start]'); + if (!blockEl) return; + const start = parseInt(blockEl.dataset.start, 10); + const end = parseInt(blockEl.dataset.end, 10); + if (isNaN(start) || isNaN(end)) return; + const lines = body.split('\n'); + for (let j = start; j < end; j++) { + const line = lines[j]; + const idx = line.indexOf('- ['); + if (idx >= 0) { + const close = line.indexOf(']', idx); + if (close >= 0) { + const checked = line[idx + 3] === 'x' ? ' ' : 'x'; + lines[j] = line.slice(0, idx + 3) + checked + line.slice(idx + 4); + setBody(lines.join('\n')); markDirty(); break; + } + } + } + }; + el.addEventListener('click', handler); + return () => el.removeEventListener('click', handler); + }, [body, showSource, markDirty]); + + const startBlockEdit = useCallback((block, e) => { + if (!canEdit) return; + if (e?.target?.tagName === 'INPUT' && e.target.type === 'checkbox') return; + const lines = body.split('\n'); + setEditStart(block.start); setEditEnd(block.end); + setEditValue(lines.slice(block.start, block.end).join('\n')); + if (e) { const h = e.currentTarget.offsetHeight; if (h > 0) setEditMinH(h); } + setTimeout(() => { if (taRef.current) { taRef.current.focus(); taRef.current.setSelectionRange(taRef.current.value.length, taRef.current.value.length); } }, 0); + }, [body, canEdit]); + + const finishEdit = useCallback((save) => { + if (editStart < 0) return; + if (save && editValue) { + const newLines = editValue.replace(/\n+$/, '').split('\n'); + const lines = body.split('\n'); + lines.splice(editStart, editEnd - editStart, ...newLines); + setBody(lines.join('\n')); markDirty(); + } + setEditStart(-1); setEditEnd(-1); setEditValue(''); + }, [editStart, editEnd, editValue, body, markDirty]); + + const handleEditKeyDown = useCallback((e) => { + if (e.key === 'Escape') { e.stopPropagation(); finishEdit(false); } + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); finishEdit(true); } + }, [finishEdit]); + + const handleSave = useCallback(() => { + const saveTitle = title.trim() || 'Untitled'; + onSave({ title: saveTitle, slug, body, tags, visibility, isPublished: true }); + }, [title, slug, body, tags, visibility, onSave]); + + const handleExportMd = useCallback(() => { + const blob = new Blob([body], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = (title || 'document').replace(/[^a-z0-9_-]/gi, '_') + '.md'; + a.click(); URL.revokeObjectURL(url); + }, [body, title]); + + const handleCopyBody = useCallback(() => { navigator.clipboard.writeText(body).catch(() => {}); }, [body]); + const handleCopyUrl = useCallback(() => { navigator.clipboard.writeText(`${window.location.origin}${documentsUrl}`).catch(() => {}); }, [documentsUrl]); + + const handleImportFile = useCallback((e) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (event) => { + const text = event.target.result; + setBody(text); + const headingMatch = text.match(/^#\s+(.+)$/m); + const baseName = file.name.replace(/\.(md|markdown)$/i, ''); + const source = headingMatch?.[1]?.trim() || baseName; + const auto = source.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/-+/g, '-').substring(0, 200) || 'imported'; + setSlug(auto); + setTitle(headingMatch?.[1]?.trim() || baseName || 'Imported'); + markDirty(); + }; + reader.readAsText(file); + e.target.value = ''; + }, [markDirty]); + + const docData = useMemo(() => parseMarkdown(body), [body]); + const blocks = docData.blocks; + const isEditing = editStart >= 0; + const showTimestamps = doc.id && doc.id !== '__sample__' && (doc.createdAt || doc.updatedAt); + const saveErrorMsg = (isNew ? createStatus : updateStatus).errorText; + + return ( +
    +
    + {canEdit ? ( + { setTitle(e.target.value); markDirty(); }} placeholder="Document title" /> + ) : ( +

    {title}

    + )} +
    + {canEdit && ( + <> + + {dirty && } + {onDelete && } + + )} +
    +
    + + {saveErrorMsg &&
    {saveErrorMsg}
    } + + {showTimestamps && ( +
    + {doc.createdAt && Created {formatDate(doc.createdAt)}} + {doc.updatedAt && doc.updatedAt !== doc.createdAt && Updated {formatDate(doc.updatedAt)}} +
    + )} + +
    + + + {canEdit && ( + <> + + + + )} + + {canEdit && ( +
    + /docs/{username || '…'}/ + { setSlug(e.target.value.replace(/[^a-zA-Z0-9\u0400-\u04FF-]/g, '').substring(0, 200)); markDirty(); }} + placeholder="slug" /> +
    + )} + + {canEdit && ( + + )} + + {!canEdit && visibility !== 'public' && {visibility}} + + {canEdit && ( + + )} + + {doc.id !== '__sample__' && ( + + )} + {doc.id !== '__sample__' && ( + {documentsUrl} + )} +
    + + {showTags && canEdit && ( +
    +
    +
    + +
    + {tags.map((t) => ( + + {t} + + + ))} +
    + setTagInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); if (tagInput.trim()) { handleAddTag(tagInput); setTagInput(''); } } }} + placeholder="Add tag..." /> +
    +
    +
    +
    +
    + )} + +
    + {showSource ? ( +