core/analyzers/diagram.analyzer.ts

core/analyzers/diagram.analyzer.ts is a file in GovLab Docs. 84 lines of code and 27 definitions.

import { HARDENING_DETAILS, nonAsciiDetail, parenDetail, reservedDetail } from "#configuration/strings/diagram.strings";
import type { MermaidHardeningCode, MermaidHardeningHit } from "#types/diagram.types";
import { mermaidBlocks } from "#core/parsers/diagram.parser";

const RESERVED_IN_LABEL: ReadonlySet<string> = new Set(["[", "]", "{", "}", "<", ">", "&"]);
const PARENS: ReadonlySet<string> = new Set(["(", ")"]);
const TRAILING_BLANKS: ReadonlySet<string> = new Set([" ", "\t", "\r"]);
const DIRECTION_WORD = "direction ";
const BREAK_TAG = "br";
const MAX_ASCII = 126;
const BR_END_OFFSET = 3;
const QUOTE = '"';
const OPEN_ANGLE = "<";
const SEMICOLON = ";";

const trailingHasContent = function trailingHasContent(line: string, from: number): boolean {
    for (let at = from; at < line.length; at += 1) {
        if (!TRAILING_BLANKS.has(line.charAt(at))) {
            return true;
        }
    }
    return false;
};

class MermaidLineScanner {
    public readonly hits: MermaidHardeningHit[] = [];
    private inQuote = false;
    private readonly line: string;
    private readonly lineNo: number;

    public constructor(line: string, lineNo: number) {
        this.line = line;
        this.lineNo = lineNo;
    }

    public scan(): MermaidHardeningHit[] {
        for (let at = 0; at < this.line.length; at += 1) {
            this.scanChar(at);
        }
        if (this.line.trim().toLowerCase().startsWith(DIRECTION_WORD)) {
            this.push("mermaid-subgraph-direction", 0, HARDENING_DETAILS.direction);
        }
        return this.hits;
    }

    private isHtmlBreak(char: string, at: number): boolean {
        return char === OPEN_ANGLE && this.line.slice(at + 1, at + BR_END_OFFSET).toLowerCase() === BREAK_TAG;
    }

    private isStatementSeparator(char: string, at: number): boolean {
        return char === SEMICOLON && !this.inQuote && trailingHasContent(this.line, at + 1);
    }

    private scanChar(at: number): void {
        const char = this.line.charAt(at);
        const code = this.line.codePointAt(at) ?? 0;
        if (code > MAX_ASCII) {
            this.push("mermaid-non-ascii", at, nonAsciiDetail(code));
            return;
        }
        if (char === QUOTE) {
            this.inQuote = !this.inQuote;
            return;
        }
        this.scanPlain(char, at);
    }

    private scanPlain(char: string, at: number): void {
        if (this.isHtmlBreak(char, at)) {
            this.push("mermaid-html-break", at, HARDENING_DETAILS.htmlBreak);
            return;
        }
        if (PARENS.has(char)) {
            this.push("mermaid-paren", at, parenDetail(char));
            return;
        }
        if (this.isStatementSeparator(char, at)) {
            this.push("mermaid-semicolon", at, HARDENING_DETAILS.semicolon);
            return;
        }
        if (this.inQuote && RESERVED_IN_LABEL.has(char)) {
            this.push("mermaid-label-reserved", at, reservedDetail(char));
        }
    }

    private push(code: MermaidHardeningCode, at: number, detail: string): void {
        this.hits.push({ code, col: at + 1, detail, line: this.lineNo });
    }
}

export const mermaidHardening = function mermaidHardening(source: string): MermaidHardeningHit[] {
    return mermaidBlocks(source).flatMap((block) =>
        block.code.split("\n").flatMap((line, offset) => new MermaidLineScanner(line, block.startLine + offset).scan()),
    );
};