fix(wiki): sanitize generated mermaid diagrams (#1539)

* fix(wiki): sanitize generated mermaid diagrams

* fix(wiki): address mermaid sanitizer review

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
azizur100389 2026-05-13 11:53:09 +01:00 committed by GitHub
parent ec4624af87
commit e8c8ddec8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 248 additions and 36 deletions

View file

@ -28,6 +28,7 @@ import {
type FileWithExports,
} from './graph-queries.js';
import { generateHTMLViewer } from './html-viewer.js';
import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js';
import {
callLLM,
@ -591,7 +592,7 @@ export class WikiGenerator {
const response = await this.invokeLLM(prompt, MODULE_SYSTEM_PROMPT, this.streamOpts(node.name));
// Write page with front matter
const pageContent = `# ${node.name}\n\n${response.content}`;
const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`);
await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
}
@ -631,7 +632,7 @@ export class WikiGenerator {
const response = await this.invokeLLM(prompt, PARENT_SYSTEM_PROMPT, this.streamOpts(node.name));
const pageContent = `# ${node.name}\n\n${response.content}`;
const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`);
await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
}
@ -681,7 +682,9 @@ export class WikiGenerator {
this.streamOpts('Generating overview', 88),
);
const pageContent = `# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`;
const pageContent = sanitizeMermaidMarkdown(
`# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`,
);
await fs.writeFile(path.join(this.wikiDir, 'overview.md'), pageContent, 'utf-8');
}

View file

@ -7,6 +7,7 @@
import fs from 'fs/promises';
import path from 'path';
import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js';
interface ModuleTreeNode {
name: string;
@ -42,7 +43,7 @@ export async function generateHTMLViewer(wikiDir: string, projectName: string):
const dirEntries = await fs.readdir(wikiDir);
for (const f of dirEntries.filter((f) => f.endsWith('.md'))) {
const content = await fs.readFile(path.join(wikiDir, f), 'utf-8');
pages[f.replace(/\.md$/, '')] = content;
pages[f.replace(/\.md$/, '')] = sanitizeMermaidMarkdown(content);
}
const html = buildHTML(projectName, moduleTree, pages, meta);

View file

@ -0,0 +1,119 @@
const MERMAID_FENCE_RE = /```mermaid\s*\n([\s\S]*?)```/g;
const NODE_LABEL_RE =
/(\[[^\]\n]*(?:\\n)[^\]\n]*\]|\{[^}\n]*(?:\\n)[^}\n]*\}|\([^)\n]*(?:\\n)[^)\n]*\))/g;
const EDGE_LABEL_RE = /\|([^|\n]+)\|/g;
const UNSAFE_EDGE_LABEL_RE = /[()[\]{}<>]/;
const UNSAFE_NODE_ID_RE = /[^A-Za-z0-9_-]/;
const NODE_ID_RE = /^[A-Za-z0-9_.:/()-]+$/;
const LINE_PREFIX_RE = /^(\s*(?:(?:[-A-Za-z0-9_]+)\s*:\s*)?)(.*)$/;
const EDGE_RE =
/(\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?\|[^|\n]*\|(?:[>|ox])?|\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?|\s*<--+>?\s*)/g;
export function sanitizeMermaidMarkdown(markdown: string): string {
return markdown.replace(MERMAID_FENCE_RE, (_match, diagram: string) => {
return '```mermaid\n' + sanitizeMermaidDiagram(diagram) + '```';
});
}
export function sanitizeMermaidDiagram(diagram: string): string {
const aliases = new Map<string, string>();
let nextAlias = 1;
const aliasFor = (id: string): string => {
const existing = aliases.get(id);
if (existing) return existing;
const base = id.replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || 'node';
let alias = base;
while ([...aliases.values()].includes(alias)) {
nextAlias += 1;
alias = `${base}_${nextAlias}`;
}
aliases.set(id, alias);
return alias;
};
return diagram
.split('\n')
.map((line) => sanitizeMermaidLine(line, aliasFor))
.join('\n');
}
function sanitizeMermaidLine(line: string, aliasFor: (id: string) => string): string {
let sanitized = replaceLiteralLineBreaksInLabels(line);
sanitized = quoteUnsafeEdgeLabels(sanitized);
const prefixMatch = sanitized.match(LINE_PREFIX_RE);
if (!prefixMatch) return sanitized;
const prefix = prefixMatch[1];
const body = prefixMatch[2];
if (isDirectiveLine(body)) return sanitized;
const parts = body.split(EDGE_RE);
if (parts.length === 1) return sanitized;
for (let i = 0; i < parts.length; i += 2) {
parts[i] = sanitizeNodeReference(parts[i], aliasFor);
}
return prefix + parts.join('');
}
function replaceLiteralLineBreaksInLabels(line: string): string {
return line.replace(NODE_LABEL_RE, (label) => label.replace(/\\n/g, '<br/>'));
}
function quoteUnsafeEdgeLabels(line: string): string {
return line.replace(EDGE_LABEL_RE, (match, label: string) => {
const trimmed = label.trim();
if (!UNSAFE_EDGE_LABEL_RE.test(trimmed)) return match;
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return match;
}
return `|"${escapeMermaidLabel(trimmed)}"|`;
});
}
function sanitizeNodeReference(segment: string, aliasFor: (id: string) => string): string {
const match = segment.match(/^(\s*)([A-Za-z0-9_.:/()-]+)(.*?)(\s*)$/);
if (!match) return segment;
const [, leading, id, suffix, trailing] = match;
if (!NODE_ID_RE.test(id) || !UNSAFE_NODE_ID_RE.test(id)) return segment;
const hasInlineLabel =
suffix.trim().startsWith('[') || suffix.trim().startsWith('(') || suffix.trim().startsWith('{');
if (hasInlineLabel) return `${leading}${aliasFor(id)}${suffix}${trailing}`;
return `${leading}${aliasFor(id)}["${escapeMermaidLabel(id)}"]${suffix}${trailing}`;
}
function escapeMermaidLabel(label: string): string {
return label.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function isDirectiveLine(line: string): boolean {
const trimmed = line.trim();
return (
trimmed === '' ||
trimmed.startsWith('%%') ||
trimmed.startsWith('graph ') ||
trimmed.startsWith('flowchart ') ||
trimmed.startsWith('sequenceDiagram') ||
trimmed.startsWith('classDiagram') ||
trimmed.startsWith('stateDiagram') ||
trimmed.startsWith('erDiagram') ||
trimmed.startsWith('journey') ||
trimmed.startsWith('gantt') ||
trimmed.startsWith('pie ') ||
trimmed.startsWith('mindmap') ||
trimmed.startsWith('timeline') ||
trimmed.startsWith('subgraph ') ||
trimmed === 'end'
);
}

View file

@ -36,6 +36,7 @@ const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo');
// still works), `afterAll` rms the parent tmpdir.
let MINI_REPO: string;
let tmpParent: string;
let suiteGitnexusHome: string;
// Absolute file:// URL to tsx loader — needed when spawning CLI with cwd
// outside the project tree (bare 'tsx' specifier won't resolve there).
@ -49,6 +50,7 @@ beforeAll(() => {
// Copy the fixture into an isolated tmpdir named `mini-repo` so that the
// `--repo mini-repo` CLI arg (which matches by basename) still works.
tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-'));
suiteGitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-home-'));
MINI_REPO = path.join(tmpParent, 'mini-repo');
fs.cpSync(FIXTURE_SRC, MINI_REPO, { recursive: true });
@ -75,21 +77,30 @@ afterAll(() => {
if (tmpParent) {
fs.rmSync(tmpParent, { recursive: true, force: true });
}
if (suiteGitnexusHome) {
fs.rmSync(suiteGitnexusHome, { recursive: true, force: true });
}
});
function cliEnv(extraEnv: Record<string, string> = {}) {
return {
...process.env,
GITNEXUS_HOME: suiteGitnexusHome,
// Pre-set --max-old-space-size so analyzeCommand's ensureHeap() sees it
// and skips the re-exec. The re-exec drops the tsx loader (--import tsx
// is not in process.argv), causing ERR_UNKNOWN_FILE_EXTENSION on .ts files.
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
...extraEnv,
};
}
function runCli(command: string, cwd: string, timeoutMs = 15000) {
return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, command], {
cwd,
encoding: 'utf8',
timeout: timeoutMs,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
// Pre-set --max-old-space-size so analyzeCommand's ensureHeap() sees it
// and skips the re-exec. The re-exec drops the tsx loader (--import tsx
// is not in process.argv), causing ERR_UNKNOWN_FILE_EXTENSION on .ts files.
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
},
env: cliEnv(),
});
}
@ -103,10 +114,7 @@ function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 15000) {
encoding: 'utf8',
timeout: timeoutMs,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
},
env: cliEnv(),
});
}
@ -126,11 +134,7 @@ function runCliWithEnv(
encoding: 'utf8',
timeout: timeoutMs,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
...extraEnv,
},
env: cliEnv(extraEnv),
});
}
@ -919,10 +923,7 @@ describe('CLI end-to-end', () => {
encoding: 'utf8',
timeout: timeoutMs,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
},
env: cliEnv(),
});
}
@ -1042,10 +1043,7 @@ describe('CLI end-to-end', () => {
encoding: 'utf8',
timeout: 15000,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
},
env: cliEnv(),
},
);
if (result.status === null) return;
@ -1159,10 +1157,7 @@ describe('CLI end-to-end', () => {
{
cwd: MINI_REPO,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
},
env: cliEnv(),
},
);
@ -1212,10 +1207,7 @@ describe('CLI end-to-end', () => {
{
cwd: MINI_REPO,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
},
env: cliEnv(),
},
);

View file

@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import {
sanitizeMermaidDiagram,
sanitizeMermaidMarkdown,
} from '../../src/core/wiki/mermaid-sanitizer.js';
describe('sanitizeMermaidMarkdown', () => {
it('replaces literal newline escapes inside rectangle and diamond labels', () => {
const markdown = [
'```mermaid',
'flowchart TD',
' A[HTTP request\\nwith ID param] --> B{Preceding tei:zone\\nwith @start=#pid?}',
'```',
].join('\n');
const sanitized = sanitizeMermaidMarkdown(markdown);
expect(sanitized).toContain('A[HTTP request<br/>with ID param]');
expect(sanitized).toContain('B{Preceding tei:zone<br/>with @start=#pid?}');
expect(sanitized).not.toContain('\\n');
});
it('quotes unsafe edge labels without changing safe labels', () => {
const diagram = [
'graph LR',
' Script -->|doc()| eXist[(eXist-db XML)]',
' Client -->|HTTP params| Script',
].join('\n');
const sanitized = sanitizeMermaidDiagram(diagram);
expect(sanitized).toContain('Script -->|"doc()"| eXist[(eXist-db XML)]');
expect(sanitized).toContain('Client -->|HTTP params| Script');
});
it('escapes backslashes and quotes in quoted edge labels', () => {
const diagram = ['graph LR', ' Script -->|doc("C:\\\\tmp")| Target'].join('\n');
const sanitized = sanitizeMermaidDiagram(diagram);
expect(sanitized).toContain('Script -->|"doc(\\"C:\\\\\\\\tmp\\")"| Target');
});
it('aliases bare node IDs that contain dots and keeps display labels', () => {
const diagram = [
'graph LR',
' Client -->|xmlurl + xslurl| xslt-conversion.xq',
' xslt-conversion.xq -->|stream-transform| lbpwebjs-main.xsl',
' lbpwebjs-main.xsl -->|fetches| TEI-XML[(TEI XML in eXist)]',
].join('\n');
const sanitized = sanitizeMermaidDiagram(diagram);
expect(sanitized).toContain(
'Client -->|xmlurl + xslurl| xslt-conversion_xq["xslt-conversion.xq"]',
);
expect(sanitized).toContain(
'xslt-conversion_xq["xslt-conversion.xq"] -->|stream-transform| lbpwebjs-main_xsl["lbpwebjs-main.xsl"]',
);
expect(sanitized).toContain(
'lbpwebjs-main_xsl["lbpwebjs-main.xsl"] -->|fetches| TEI-XML[(TEI XML in eXist)]',
);
});
it('aliases unsafe node IDs while preserving existing inline labels', () => {
const diagram = [
'graph LR',
' file.name.ts[(eXist-db XML)] --> target.node["Target node"]',
].join('\n');
const sanitized = sanitizeMermaidDiagram(diagram);
expect(sanitized).toContain('file_name_ts[(eXist-db XML)] --> target_node["Target node"]');
});
it('only rewrites fenced Mermaid blocks in markdown', () => {
const markdown = [
'Regular text with doc() and file.name.ts.',
'',
'```ts',
'const label = "A\\nB";',
'```',
'',
'```mermaid',
'flowchart LR',
' A -->|doc()| file.name.ts',
'```',
].join('\n');
const sanitized = sanitizeMermaidMarkdown(markdown);
expect(sanitized).toContain('Regular text with doc() and file.name.ts.');
expect(sanitized).toContain('const label = "A\\nB";');
expect(sanitized).toContain('A -->|"doc()"| file_name_ts["file.name.ts"]');
});
});