Skip to content

Instantly share code, notes, and snippets.

@pHo9UBenaA
Created October 9, 2025 18:19
Show Gist options
  • Select an option

  • Save pHo9UBenaA/16ac14137eb909733fdcb4a4a7235f3d to your computer and use it in GitHub Desktop.

Select an option

Save pHo9UBenaA/16ac14137eb909733fdcb4a4a7235f3d to your computer and use it in GitHub Desktop.
// deno run --allow-read mermaid_lint.ts <path>
// -----------------------------------------------------------------------------
// File spec
// -----------------------------------------------------------------------------
// Behavior:
// - If <path> is a file: lint that single file (supports .mmd/.mermaid and Markdown fences).
// - If <path> is a directory: recursively find markdown files matching
// the glob pattern: "**" + "/" + "*.md", and lint all Mermaid code fences.
// No flags; behavior is inferred from argument type.
//
// Exit codes:
// 0 = all passed
// 1 = syntax errors detected
// 2 = misuse (e.g., no inputs, read/stat errors, no target files in dir)
//
// Design:
// - Functional domain modeling (ADTs) & single-responsibility functions
// - Side effects isolated to boundaries (FS/console)
// - No classes, no hidden global mutable state
// -----------------------------------------------------------------------------
import mermaid from "npm:mermaid@10";
// -----------------------------------------------------------------------------
// Domain Model
// -----------------------------------------------------------------------------
/** Generic fetch-like ADT. */
export type FetchResult<T, E> =
| { ok: true; data: T }
| { ok: false; error: E };
/** File type classification. */
type FileType = "mmd" | "markdown" | "unknown";
/** Diagram block extracted from a source file. */
type DiagramBlock = {
file: string;
label: string;
startLine?: number; // 1-based
diagram: string;
};
/** Lint result ADT. */
type LintResult =
| { ok: true; file: string; label: string; startLine?: number }
| { ok: false; file: string; label: string; startLine?: number; error: string };
// -----------------------------------------------------------------------------
// Magic Literals
// -----------------------------------------------------------------------------
const ROLE_MERMAID_LANG = "mermaid" as const;
const EXT_MMD = new Set(["mmd", "mermaid"]);
const EXT_MD = new Set(["md", "markdown", "mdx"]);
const REGEX_FENCE_MERMAID =
/```(?:mermaid)([^\n]*)\n([\s\S]*?)```/gim;
const EXIT_SUCCESS = 0 as const;
const EXIT_LINT_FAILED = 1 as const;
const EXIT_MISUSE = 2 as const;
const SUMMARY_OK_PREFIX = "OK: " as const;
const SUMMARY_ERR_PREFIX = "ERROR: " as const;
// -----------------------------------------------------------------------------
// Pure utilities
// -----------------------------------------------------------------------------
/** Returns lowercase extension without dot, or empty string. */
const extOf = (p: string): string => {
const i = p.lastIndexOf(".");
return i >= 0 ? p.slice(i + 1).toLowerCase() : "";
};
/** Convert string index (0-based) to line number (1-based). */
const computeLine1Based = (text: string, indexZeroBased: number): number => {
let line = 1;
for (let i = 0; i < indexZeroBased; i++) if (text.charCodeAt(i) === 10) line++;
return line;
};
/** Classify file by extension. */
const detectFileType = (path: string): FileType => {
const ext = extOf(path);
if (EXT_MMD.has(ext)) return "mmd";
if (EXT_MD.has(ext)) return "markdown";
return "unknown";
};
/** Human-friendly block label. */
const blockLabel = (path: string, idx: number): string => {
const base = path.split("/").pop() ?? path;
return `${base}#block${idx}`;
};
// -----------------------------------------------------------------------------
// FS boundary
// -----------------------------------------------------------------------------
/** Safe stat (file/dir detection). */
const safeStat = async (path: string): Promise<FetchResult<Deno.FileInfo, string>> => {
try {
const info = await Deno.stat(path);
return { ok: true, data: info };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { ok: false, error: `[Stat Error] ${path}: ${msg}` };
}
};
/** Safe text read. */
const readTextFile = async (path: string): Promise<FetchResult<string, string>> => {
try {
const text = await Deno.readTextFile(path);
return { ok: true, data: text };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { ok: false, error: `[Read Error] ${path}: ${msg}` };
}
};
/**
* Recursively collect Markdown files under a directory.
* Target pattern is equivalent to: "**" + "/" + "*.md".
* This avoids writing the raw sequence that would terminate a block comment.
*/
const collectMarkdownFiles = async (dir: string): Promise<FetchResult<string[], string>> => {
try {
const acc: string[] = [];
for await (const entry of Deno.readDir(dir)) {
const full = `${dir.replace(/\/+$/, "")}/${entry.name}`;
if (entry.isDirectory) {
const sub = await collectMarkdownFiles(full);
if (!sub.ok) return sub; // early propagate error
acc.push(...sub.data);
} else if (entry.isFile) {
if (EXT_MD.has(extOf(entry.name))) acc.push(full);
}
}
return { ok: true, data: acc };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { ok: false, error: `[ReadDir Error] ${dir}: ${msg}` };
}
};
// -----------------------------------------------------------------------------
// Extraction (pure)
// -----------------------------------------------------------------------------
/** Treat whole content as a single Mermaid diagram. */
const extractFromMmd = (path: string, content: string): DiagramBlock[] => {
const base = path.split("/").pop() ?? path;
return [{ file: path, label: base, diagram: content }];
};
/** Extract ```mermaid fences in Markdown with start-line positions. */
const extractFromMarkdown = (path: string, content: string): DiagramBlock[] => {
const blocks: DiagramBlock[] = [];
let m: RegExpExecArray | null;
let idx = 0;
while ((m = REGEX_FENCE_MERMAID.exec(content)) !== null) {
idx++;
const body = m[2];
const start = m.index;
const startLine = computeLine1Based(content, start);
blocks.push({ file: path, label: blockLabel(path, idx), startLine, diagram: body });
}
return blocks;
};
/** Extract diagram blocks depending on file type. */
const extractDiagrams = (path: string, content: string): DiagramBlock[] => {
const kind = detectFileType(path);
if (kind === "mmd") return extractFromMmd(path, content);
if (kind === "markdown") return extractFromMarkdown(path, content);
// Unknown: treat as single diagram for ad-hoc checks (file mode only)
return extractFromMmd(path, content);
};
// -----------------------------------------------------------------------------
// Validation (domain)
// -----------------------------------------------------------------------------
/** Parse with Mermaid; success => valid syntax. */
const validateDiagram = async (b: DiagramBlock): Promise<LintResult> => {
try {
await mermaid.parse(b.diagram);
return { ok: true, file: b.file, label: b.label, startLine: b.startLine };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { ok: false, file: b.file, label: b.label, startLine: b.startLine, error: msg };
}
};
const validateAll = async (blocks: DiagramBlock[]): Promise<LintResult[]> => {
const out: LintResult[] = [];
for (const b of blocks) out.push(await validateDiagram(b));
return out;
};
// -----------------------------------------------------------------------------
// Reporting (side-effects isolated)
// -----------------------------------------------------------------------------
const renderResult = (r: LintResult): string => {
if (r.ok) {
const loc = r.startLine ? ` (line ${r.startLine})` : "";
return `${SUMMARY_OK_PREFIX}${r.label}${loc}`;
}
const loc = r.startLine ? `:${r.startLine}` : "";
return [`${SUMMARY_ERR_PREFIX}${r.label}${loc}`, ` in ${r.file}`, ` ${r.error}`].join("\n");
};
const summarize = (results: LintResult[]) => {
let ok = 0, ng = 0;
for (const r of results) r.ok ? ok++ : ng++;
return { ok, ng };
};
const reportAndDecide = (results: LintResult[]): number => {
for (const r of results) (r.ok ? console.log : console.error)(renderResult(r));
const { ok, ng } = summarize(results);
if (ng > 0) {
console.error(`\nSummary: ${ok} passed, ${ng} failed`);
return EXIT_LINT_FAILED;
}
console.log(`\nSummary: ${ok} passed, 0 failed`);
return EXIT_SUCCESS;
};
// -----------------------------------------------------------------------------
// Application (orchestration)
// -----------------------------------------------------------------------------
/**
* Orchestrate depending on whether the input path is a file or a directory.
* - File: lint that file (supports .mmd/.mermaid and Markdown fences; unknown treated as single diagram).
* - Directory: recursively collect markdown files and lint Mermaid fences.
*/
const run = async (argPath: string): Promise<number> => {
const stat = await safeStat(argPath);
if (!stat.ok) {
console.error(stat.error);
return EXIT_MISUSE;
}
const allBlocks: DiagramBlock[] = [];
if (stat.data.isDirectory) {
const collected = await collectMarkdownFiles(argPath);
if (!collected.ok) {
console.error(collected.error);
return EXIT_MISUSE;
}
if (collected.data.length === 0) {
console.error(`[No Targets] No Markdown files found under: ${argPath}`);
return EXIT_MISUSE;
}
for (const mdPath of collected.data) {
const content = await readTextFile(mdPath);
if (!content.ok) {
console.error(content.error);
return EXIT_MISUSE; // early return on read error
}
allBlocks.push(...extractFromMarkdown(mdPath, content.data));
}
} else if (stat.data.isFile) {
const content = await readTextFile(argPath);
if (!content.ok) {
console.error(content.error);
return EXIT_MISUSE;
}
allBlocks.push(...extractDiagrams(argPath, content.data));
} else {
console.error(`[Unsupported] Not a regular file or directory: ${argPath}`);
return EXIT_MISUSE;
}
const results = await validateAll(allBlocks);
return reportAndDecide(results);
};
// Entry point
if (import.meta.main) {
if (Deno.args.length !== 1) {
console.error(
[
"Usage:",
" deno run --allow-read mermaid_lint.ts <path>",
"",
"Behavior:",
" - <path> is a file => lint that single file",
" - <path> is a directory => recursively lint all markdown files (glob: \"**\" + \"/\" + \"*.md\")",
"",
"Exit codes:",
` ${EXIT_SUCCESS} = all passed`,
` ${EXIT_LINT_FAILED} = syntax errors found`,
` ${EXIT_MISUSE} = misuse/errors`,
].join("\n"),
);
Deno.exit(EXIT_MISUSE);
}
const code = await run(Deno.args[0]);
Deno.exit(code);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment