All files / src/aggregator markdown-renderer.ts

95.31% Statements 61/64
80.95% Branches 34/42
92.85% Functions 13/14
98.21% Lines 55/56

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                                                                                                                                    12x           12x         12x 12x 12x 12x 12x 12x                   50x                                                   12x   12x 12x 8x 8x 8x 8x 7x 7x   7x 7x 7x 7x   1x                         12x 9x   12x 9x 12x 9x 12x 9x                     11x 11x 11x 11x 11x 11x 11x 11x                   11x 11x 2416x 2416x 52x 52x 46x 2416x 2416x 46x 2416x   11x                   11x 11x 2416x   11x    
// 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;
}
 
/**
 * 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(markdown, env);
  const toc = harvestToc(tokens);
  const html = md.renderer.render(tokens, md.options, env);
  const mermaidCount = countMermaidTokens(tokens);
  return { html, toc, mermaidCount };
}
 
/**
 * 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;
}