core/analyzers/markdown.analyzer.ts

core/analyzers/markdown.analyzer.ts is a file in GovLab Docs. 75 lines of code and 31 definitions.

import type { ConventionHit, Fence } from "#types/markdown.types";
import { NO_FENCE, bodyStart, codeLineMask, splitLines, stepFence } from "#core/parsers/markdown.parser";
import { isProsePath, nextToken, trimEdges } from "#core/selectors/markdown.selector";
import { blankMarkup } from "#core/converters/markdown.converter";

const CODE_LANGS: ReadonlySet<string> = new Set(
    "ts tsx js jsx mjs cjs go rs py java rb php c cpp cs kt swift scala".split(" "),
);
const FENCE_INTENTS: ReadonlySet<string> = new Set(["API", "EXAMPLE", "CODE", "CONFIG"]);
const BARE_FENCE = "```";
const LABEL_SEPARATOR = ":";

const fenceRunLength = function fenceRunLength(trimmed: string): number {
    const char = trimmed.charAt(0);
    let run = 0;
    while (run < trimmed.length && trimmed.charAt(run) === char) {
        run += 1;
    }
    return run;
};

const langLabelHit = function langLabelHit(info: string, lineNo: number): ConventionHit | null {
    const space = info.indexOf(" ");
    const lang = space === -1 ? info : info.slice(0, space);
    if (!CODE_LANGS.has(lang)) {
        return null;
    }
    const rest = space === -1 ? "" : info.slice(space + 1).trim();
    const colon = rest.indexOf(LABEL_SEPARATOR);
    const intent = colon === -1 ? "" : rest.slice(0, colon).trim();
    return FENCE_INTENTS.has(intent) ? null : { code: "unlabeled-code-fence", col: 1, line: lineNo, token: lang };
};

const fenceHit = function fenceHit(trimmed: string, lineNo: number): ConventionHit | null {
    const info = trimmed.slice(fenceRunLength(trimmed)).trim();
    if (info.length === 0) {
        return { code: "untagged-code-fence", col: 1, line: lineNo, token: BARE_FENCE };
    }
    return langLabelHit(info, lineNo);
};

const barePathHits = function barePathHits(prose: string, lineNo: number): ConventionHit[] {
    const hits: ConventionHit[] = [];
    let at = 0;
    while (at < prose.length) {
        const span = nextToken(prose, at);
        const token = trimEdges(prose.slice(span.start, span.next));
        if (isProsePath(token)) {
            hits.push({ code: "bare-path", col: span.start + 1, line: lineNo, token });
        }
        at = span.next;
    }
    return hits;
};

class ConventionScanner {
    public readonly hits: ConventionHit[] = [];
    private fence: Fence = NO_FENCE;

    public feed(line: string, lineNo: number, masked: boolean): void {
        const trimmed = line.trim();
        const wasOpen = this.fence.open;
        this.fence = stepFence(trimmed, this.fence);
        const opened = !wasOpen && this.fence.open ? fenceHit(trimmed, lineNo) : null;
        if (opened !== null) {
            this.hits.push(opened);
        }
        if (!masked) {
            this.hits.push(...barePathHits(blankMarkup(line), lineNo));
        }
    }
}

export const conventionHits = function conventionHits(source: string): ConventionHit[] {
    const lines = splitLines(source);
    const start = bodyStart(lines);
    const mask = codeLineMask(lines, start);
    const scanner = new ConventionScanner();
    for (let at = start; at < lines.length; at += 1) {
        scanner.feed(lines[at] ?? "", at + 1, mask[at] ?? false);
    }
    return scanner.hits;
};