Created
October 9, 2025 18:19
-
-
Save pHo9UBenaA/16ac14137eb909733fdcb4a4a7235f3d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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