All files / src/aggregator markdown-renderer.ts

94.36% Statements 67/71
80.43% Branches 37/46
93.75% Functions 15/16
98.36% Lines 60/61

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239                                                                                                                                    19x           19x         19x 19x 19x 19x 19x 19x                       19x 11x 11x 11x                   98x                                                   19x   19x 19x 13x 13x 13x 13x 12x 12x   12x 12x 12x 12x   1x                         19x 28x   19x 28x 19x 28x 19x 28x                     18x 18x 18x 18x 18x 18x 18x 18x                       18x                   18x 18x 4309x 4309x 105x 105x 94x 4309x 4309x 94x 4309x   18x                   18x 18x 4309x   18x    
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
 
/**
 * @module Aggregator/MarkdownRenderer
 * @description Markdown-to-HTML renderer for the aggregated article.
 *
 * Uses `markdown-it` with a focused plugin stack:
 *  - `markdown-it-anchor` — slugged `id`s on every heading
 *  - `markdown-it-footnote` — footnote reference support for artifacts
 *  - `markdown-it-attrs` — `{.class #id}` suffixes for table/fence styling
 *  - `markdown-it-deflist` — definition lists in stakeholder artifacts
 *
 * A custom fence override transforms ```` ```mermaid ```` blocks into
 * `<pre class="mermaid" role="img" aria-label="...">…</pre>` so the
 * vendored client-side `mermaid.esm.min.mjs` (shipped under `js/vendor/`)
 * can progressively enhance them. No network calls, no inline script,
 * CSP `script-src 'self'` preserved.
 */
 
import MarkdownIt from 'markdown-it';
import anchor from 'markdown-it-anchor';
import footnote from 'markdown-it-footnote';
import attrs from 'markdown-it-attrs';
import deflist from 'markdown-it-deflist';
import type Token from 'markdown-it/lib/token.mjs';
 
/** Options controlling {@link renderMarkdown}. */
export interface RenderOptions {
  /**
   * Optional accessible label builder for mermaid figures. Receives the
   * zero-based mermaid block index and the raw mermaid source; returns
   * the `aria-label` used on the wrapping `<figure>`. Defaults to
   * `"Mermaid diagram N"`.
   */
  readonly mermaidLabel?: (index: number, body: string) => string;
}
 
/** Output from {@link renderMarkdown}. */
export interface RenderedMarkdown {
  /** Full HTML body fragment (no `<html>` / `<head>` wrapper). */
  readonly html: string;
  /** Table-of-contents entries harvested from the heading stream. */
  readonly toc: readonly TocEntry[];
  /** Number of mermaid blocks rendered. */
  readonly mermaidCount: number;
}
 
/** One entry in the generated table of contents. */
export interface TocEntry {
  /** Heading level (2–6). */
  readonly level: number;
  /** Slugged id used as the fragment anchor. */
  readonly slug: string;
  /** Heading text (escaped for display). */
  readonly text: string;
}
 
/**
 * Build a preconfigured markdown-it instance. Exposed so callers (e.g.
 * tests) can inspect plugin configuration without re-rendering.
 *
 * @returns Configured MarkdownIt instance with anchor, footnote, attrs,
 *          deflist, mermaid fence override, and table wrapping installed
 */
export function buildMarkdownIt(): MarkdownIt {
  const md = new MarkdownIt({
    html: true, // artifacts already contain hand-authored HTML wrappers
    linkify: false, // avoid surprising auto-linking of plain text URLs
    typographer: false, // keep exact punctuation
    breaks: false,
  });
  md.use(anchor, {
    level: [2, 3, 4, 5, 6],
    permalink: anchor.permalink.headerLink({ safariReaderFix: true }),
    slugify: slugify,
  });
  md.use(footnote);
  md.use(attrs, { allowedAttributes: ['id', 'class'] });
  md.use(deflist);
  installMermaidFence(md);
  installTableWrapper(md);
  return md;
}
 
/**
 * Strip a leading YAML front matter block from a Markdown document. Generated
 * `article.md` files are Jekyll-compatible, but the deterministic HTML
 * renderer must render the body, not the metadata fence.
 *
 * @param markdown - Markdown with optional `---` front matter at byte 0
 * @returns Markdown body with the front matter removed
 */
export function stripMarkdownFrontMatter(markdown: string): string {
  if (!markdown.startsWith('---\n')) return markdown;
  const end = markdown.indexOf('\n---\n', 4);
  Iif (end === -1) return markdown;
  return markdown.slice(end + 5).replace(/^\n+/, '');
}
 
/**
 * Slugify a heading text into a stable URL fragment.
 *
 * @param text - Heading text (may contain unicode punctuation / marks)
 * @returns Slug of up to 80 ASCII-ish characters, with dashes as separators
 */
export function slugify(text: string): string {
  return (
    text
      .toLowerCase()
      .normalize('NFKD')
      // Strip combining diacritical marks (Unicode range U+0300..U+036F)
      .replace(/[\u0300-\u036F]/g, '')
      // Strip general punctuation and supplemental punctuation
      .replace(/[\u2000-\u206F]/g, '')
      .replace(/[\u2E00-\u2E7F]/g, '')
      .replace(/[^\p{L}\p{N}\s-]/gu, '')
      .replace(/\s+/g, '-')
      .replace(/-+/g, '-')
      .replace(/^-|-$/g, '')
      .slice(0, 80)
  );
}
 
/**
 * Override the `fence` renderer so fenced `mermaid` blocks emit a
 * `<pre class="mermaid">` wrapped in an accessible `<figure>`. Everything
 * else falls back to the default renderer.
 *
 * @param md - MarkdownIt instance to patch in-place
 */
function installMermaidFence(md: MarkdownIt): void {
  const defaultFence =
    md.renderer.rules.fence ??
    ((tokens, idx, opts, _env, self) => self.renderToken(tokens, idx, opts));
  let mermaidIndex = 0;
  md.renderer.rules.fence = (tokens, idx, opts, env, self) => {
    const token = tokens[idx];
    Iif (!token) return '';
    const info = (token.info || '').trim().toLowerCase();
    if (info === 'mermaid') {
      const currentIndex = mermaidIndex++;
      const env2 = env as { mermaidLabel?: RenderOptions['mermaidLabel'] };
      const labelFn: RenderOptions['mermaidLabel'] =
        env2.mermaidLabel ?? ((n) => `Mermaid diagram ${n + 1}`);
      const label = md.utils.escapeHtml(labelFn(currentIndex, token.content));
      const body = md.utils.escapeHtml(token.content);
      return `<figure class="mermaid-figure" role="img" aria-label="${label}">\n<pre class="mermaid">${body}</pre>\n</figure>\n`;
    }
    return defaultFence(tokens, idx, opts, env, self);
  };
}
 
/**
 * Wrap every `<table>` in a `<div class="table-scroll">` for responsive
 * horizontal overflow. The wrapper is announced as a region so assistive
 * tech can surface the focus/scroll behaviour.
 *
 * @param md - MarkdownIt instance to patch in-place
 */
function installTableWrapper(md: MarkdownIt): void {
  const defaultOpen =
    md.renderer.rules.table_open ??
    ((tokens, idx, opts, _env, self) => self.renderToken(tokens, idx, opts));
  const defaultClose =
    md.renderer.rules.table_close ??
    ((tokens, idx, opts, _env, self) => self.renderToken(tokens, idx, opts));
  md.renderer.rules.table_open = (tokens, idx, opts, env, self) =>
    `<div class="table-scroll" role="region" tabindex="0">${defaultOpen(tokens, idx, opts, env, self)}`;
  md.renderer.rules.table_close = (tokens, idx, opts, env, self) =>
    `${defaultClose(tokens, idx, opts, env, self)}</div>`;
}
 
/**
 * Render aggregated Markdown into a sanitised HTML body fragment.
 *
 * @param markdown - Aggregated Markdown source produced by the aggregator
 * @param options - Optional render hooks (e.g. custom mermaid aria-label)
 * @returns {@link RenderedMarkdown} with HTML, TOC, and mermaid count
 */
export function renderMarkdown(markdown: string, options: RenderOptions = {}): RenderedMarkdown {
  const md = buildMarkdownIt();
  const env: { mermaidLabel?: RenderOptions['mermaidLabel'] } = {};
  if (options.mermaidLabel) env.mermaidLabel = options.mermaidLabel;
  const tokens = md.parse(stripMarkdownFrontMatter(markdown), env);
  const toc = harvestToc(tokens);
  const html = escapeUppercasePlaceholders(md.renderer.render(tokens, md.options, env));
  const mermaidCount = countMermaidTokens(tokens);
  return { html, toc, mermaidCount };
}
 
/**
 * Escape non-HTML placeholder markers like `<N>` that appear in analysis prose.
 * Lower-case tags are intentionally left untouched because artifacts may embed
 * trusted HTML wrappers such as `<div>` and `<section>`.
 *
 * @param html - Rendered HTML fragment
 * @returns HTML fragment with uppercase placeholder pseudo-tags escaped
 */
function escapeUppercasePlaceholders(html: string): string {
  return html.replace(/<([A-Z][A-Z0-9_-]*)>/g, '&lt;$1&gt;');
}
 
/**
 * Walk the token stream and collect heading entries for the TOC.
 *
 * @param tokens - Token stream produced by MarkdownIt's parser
 * @returns Flat array of {@link TocEntry} items for H2–H6 headings
 */
function harvestToc(tokens: readonly Token[]): TocEntry[] {
  const out: TocEntry[] = [];
  for (let i = 0; i < tokens.length; i++) {
    const token = tokens[i];
    if (!token || token.type !== 'heading_open') continue;
    const level = Number.parseInt(token.tag.slice(1), 10);
    if (!Number.isFinite(level) || level < 2 || level > 6) continue;
    const slug = typeof token.attrGet === 'function' ? token.attrGet('id') : null;
    const inline = tokens[i + 1];
    Iif (!inline || inline.type !== 'inline') continue;
    const text = (inline.content ?? '').trim();
    out.push({ level, slug: slug ?? slugify(text), text });
  }
  return out;
}
 
/**
 * Count fence tokens whose info string starts with `mermaid`.
 *
 * @param tokens - Token stream produced by MarkdownIt's parser
 * @returns Number of mermaid fence tokens in the stream
 */
function countMermaidTokens(tokens: readonly Token[]): number {
  let n = 0;
  for (const token of tokens) {
    if (token.type === 'fence' && (token.info ?? '').trim().toLowerCase() === 'mermaid') n++;
  }
  return n;
}