UNPKG

kiira

Version:

Command-line interface for Kiira: validate TypeScript and JavaScript code fences in your Markdown docs.

625 lines (610 loc) 20.1 kB
#!/usr/bin/env node import { existsSync, readFileSync, statSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { checkMarkdownFiles, collectExternalPackages, ensureExternalPackages, findConfigFile, loadConfig, loadConfigFile } from "kiira-core"; import { readFile, writeFile } from "node:fs/promises"; import { Chalk } from "chalk"; //#region src/args.ts const REPORTERS = [ "pretty", "json", "github" ]; const COMMANDS = new Set(["check", "init"]); function isReporter(value) { return REPORTERS.includes(value); } /** Parse `process.argv.slice(2)` into a structured command invocation. */ function parseArgs(argv) { let command; let config; let reporter = "pretty"; let fix = false; let verbose = false; let raw = false; let staticOutput = false; const files = []; const entry = []; const ignore = []; const base = () => ({ files, config, reporter, fix, verbose, raw, entry, ignore, static: staticOutput }); for (let i = 0; i < argv.length; i += 1) { const arg = argv[i] ?? ""; if (arg === "--help" || arg === "-h") return { command: "help", ...base() }; if (arg === "--version" || arg === "-v") return { command: "version", ...base() }; if (arg === "--entry" || arg.startsWith("--entry=")) { const value = arg.includes("=") ? arg.slice(8) : argv[++i] ?? ""; if (value) entry.push(value); continue; } if (arg === "--ignore" || arg.startsWith("--ignore=")) { const value = arg.includes("=") ? arg.slice(9) : argv[++i] ?? ""; if (value) ignore.push(value); continue; } if (arg === "--fix") { fix = true; continue; } if (arg === "--verbose") { verbose = true; continue; } if (arg === "--raw") { raw = true; continue; } if (arg === "--static") { staticOutput = true; continue; } if (arg === "--config" || arg.startsWith("--config=")) { config = arg.includes("=") ? arg.slice(9) : argv[++i]; continue; } if (arg === "--reporter" || arg.startsWith("--reporter=")) { const value = arg.includes("=") ? arg.slice(11) : argv[++i] ?? ""; if (!isReporter(value)) throw new Error(`Unknown reporter "${value}". Expected one of: ${REPORTERS.join(", ")}.`); reporter = value; continue; } if (arg.startsWith("-")) throw new Error(`Unknown option "${arg}".`); if (command === void 0 && COMMANDS.has(arg)) command = arg; else files.push(arg); } return { command: command ?? "check", ...base() }; } //#endregion //#region src/entries.ts const GLOB_MAGIC = /[*?{}[\]()!]/; function toPosix(path) { return path.split("\\").join("/"); } function isDirectory(cwd, path) { try { return statSync(isAbsolute(path) ? path : join(cwd, path)).isDirectory(); } catch { return false; } } /** * Turn `--entry` / positional arguments into include globs. A glob is used as-is; * a directory becomes `<dir>/**\/*.{md,mdx}`; a file path is used as-is. */ function toIncludeGlobs(cwd, entries) { return entries.map((entry) => { const value = toPosix(entry); if (GLOB_MAGIC.test(value)) return value; if (isDirectory(cwd, entry)) return `${value.replace(/\/+$/, "")}/**/*.{md,mdx}`; return value; }); } /** * Turn `--ignore` arguments into exclude globs. A glob is used as-is; a file path * (with an extension) is used as-is; anything else is treated as a directory * subtree (`<dir>/**`), so `--ignore docs/api` excludes the whole directory. */ function toIgnoreGlobs(cwd, ignores) { return ignores.map((entry) => { const value = toPosix(entry); if (GLOB_MAGIC.test(value)) return value; if (!isDirectory(cwd, entry) && /\.[a-z0-9]+$/i.test(value)) return value; return `${value.replace(/\/+$/, "")}/**`; }); } //#endregion //#region src/fix.ts const FENCE_LANG = /^(\s*(?:`{3,}|~{3,})\s*)([A-Za-z0-9_-]+)/; function applyLineEdit(line, edit) { let next = line; if (edit.language) next = next.replace(FENCE_LANG, (_match, prefix) => `${prefix}${edit.language}`); if (edit.append && !next.includes(edit.append)) next = `${next.replace(/\s+$/, "")} ${edit.append}`; return next; } /** * Apply fence auto-fixes to the Markdown sources: rewrite a mistagged language * (`ts` -> `tsx`) and/or append metadata (`group=foo`) on the opening fence line. * Returns how many fences were edited across how many files. */ async function applyFixes(cwd, diagnostics) { const byFile = /* @__PURE__ */ new Map(); for (const d of diagnostics) { const fix = d.fix; if (fix?.kind !== "fence-language" && fix?.kind !== "fence-meta") continue; const edits = byFile.get(d.markdownFile) ?? /* @__PURE__ */ new Map(); const edit = edits.get(fix.line) ?? {}; if (fix.kind === "fence-language") edit.language = fix.language; else edit.append = fix.append; edits.set(fix.line, edit); byFile.set(d.markdownFile, edits); } let filesChanged = 0; let fixesApplied = 0; for (const [file, edits] of byFile) { const lines = (await readFile(join(cwd, file), "utf8")).split("\n"); let changed = false; for (const [line, edit] of edits) { const current = lines[line]; if (current === void 0) continue; const replaced = applyLineEdit(current, edit); if (replaced !== current) { lines[line] = replaced; changed = true; fixesApplied += 1; } } if (changed) { await writeFile(join(cwd, file), lines.join("\n"), "utf8"); filesChanged += 1; } } return { filesChanged, fixesApplied }; } /** * Apply `config-override` fixes by merging per-glob compiler-option overrides into * a JSON Kiira config. Non-JSON configs (or none) can't be safely edited, so * those fixes are returned as `manual` for the caller to surface. */ async function applyConfigOverrides(configPath, diagnostics) { const fixes = []; const seen = /* @__PURE__ */ new Set(); for (const d of diagnostics) { if (d.fix?.kind !== "config-override") continue; const key = `${d.fix.include}${JSON.stringify(d.fix.compilerOptions)}`; if (!seen.has(key)) { seen.add(key); fixes.push(d.fix); } } if (fixes.length === 0) return { applied: [], manual: [] }; if (!configPath || !configPath.toLowerCase().endsWith(".json")) return { applied: [], manual: fixes }; const json = JSON.parse(await readFile(configPath, "utf8")); if (json.overrides !== void 0 && !Array.isArray(json.overrides)) throw new Error("Kiira config field \"overrides\" must be an array; cannot apply the fix automatically."); const overrides = Array.isArray(json.overrides) ? json.overrides : []; const applied = []; for (const fix of fixes) { if (overrides.some((o) => { return (Array.isArray(o.include) ? o.include : [o.include]).includes(fix.include); })) continue; const override = { include: [fix.include], ...fix.compilerOptions }; overrides.push(override); applied.push(override); } if (applied.length > 0) { json.overrides = overrides; await writeFile(configPath, `${JSON.stringify(json, null, 2)}\n`, "utf8"); } return { applied, manual: [] }; } //#endregion //#region src/reporters.ts const styled = new Chalk(); const plain = new Chalk({ level: 0 }); function severityColor(c, severity) { if (severity === "error") return c.red; if (severity === "warning") return c.yellow; return c.blue; } /** Render a TS code (e.g. 2305) as `TS2305`; pass other codes through. */ function codeLabel(code) { if (typeof code === "number") return `TS${code}`; return code ?? ""; } function pluralize(count, noun) { return `${count} ${noun}${count === 1 ? "" : "s"}`; } /** Number of diagnostics that `kiira check --fix` can resolve automatically. */ function fixableCount(result) { return result.diagnostics.filter((d) => d.fix).length; } /** Machine-readable report. Positions are 1-based for both line and character. */ function formatJson(result) { const diagnostics = result.diagnostics.map((d) => ({ severity: d.severity, source: d.source, code: d.code, message: d.message, markdownFile: d.markdownFile, markdownRange: { start: { line: d.markdownRange.start.line + 1, character: d.markdownRange.start.character + 1 }, end: { line: d.markdownRange.end.line + 1, character: d.markdownRange.end.character + 1 } }, generated: d.generated ?? false })); return JSON.stringify({ stats: { ...result.stats, fixable: fixableCount(result) }, diagnostics }, null, 2); } function githubSeverity(severity) { if (severity === "error") return "error"; if (severity === "warning") return "warning"; return "notice"; } function escapeGithubData(value) { return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); } /** GitHub Actions workflow command annotations (1-based line/col). */ function formatGithub(result) { return result.diagnostics.map((d) => { const line = d.markdownRange.start.line + 1; const col = d.markdownRange.start.character + 1; const title = codeLabel(d.code); const titlePart = title ? `,title=${title}` : ""; return `::${githubSeverity(d.severity)} file=${d.markdownFile},line=${line},col=${col}${titlePart}::${escapeGithubData(d.message)}`; }).join("\n"); } function renderCodeFrame(lines, diagnostic, c) { const lineIndex = diagnostic.markdownRange.start.line; const source = lines[lineIndex]; if (source === void 0) return ""; const gutter = String(lineIndex + 1); const startCol = diagnostic.markdownRange.start.character; const endCol = diagnostic.markdownRange.end.line === lineIndex ? Math.max(diagnostic.markdownRange.end.character, startCol + 1) : source.length; const caretPad = " ".repeat(gutter.length); const underline = `${" ".repeat(startCol)}${"^".repeat(Math.max(1, endCol - startCol))}`; return [` ${c.dim(`${gutter} |`)} ${source}`, ` ${c.dim(`${caretPad} |`)} ${severityColor(c, diagnostic.severity)(underline)}`].join("\n"); } function location(d, c) { return c.cyan(`${d.markdownFile}:${d.markdownRange.start.line + 1}:${d.markdownRange.start.character + 1}`); } /** One compact line per diagnostic: `file:line:col severity CODE message`. */ function compactLine(d, c) { const severity = severityColor(c, d.severity)(d.severity); const code = codeLabel(d.code); const codePart = code ? `${c.dim(code)} ` : ""; return `${location(d, c)} ${severity} ${codePart}${d.message.split("\n")[0]}`; } /** A verbose block: header, full message, and a code frame. */ function verboseBlock(d, ctx, c) { const code = codeLabel(d.code); const header = `${location(d, c)} ${severityColor(c, d.severity)(d.severity)}${code ? ` ${c.dim(code)}` : ""}`; const frameLines = ctx.getSourceLines?.(d.markdownFile); const frame = frameLines ? renderCodeFrame(frameLines, d, c) : ""; return [ header, d.message, frame ].filter(Boolean).join("\n"); } function formatPretty(result, ctx) { const { stats } = result; const c = ctx.raw ? plain : styled; const sections = []; if (ctx.verbose) sections.push(...result.diagnostics.map((d) => verboseBlock(d, ctx, c))); else { const byFile = /* @__PURE__ */ new Map(); for (const d of result.diagnostics) { const list = byFile.get(d.markdownFile) ?? []; list.push(d); byFile.set(d.markdownFile, list); } for (const [file, diags] of byFile) sections.push([c.underline(file), ...diags.map((d) => ` ${compactLine(d, c)}`)].join("\n")); } const failedSnippets = new Set(result.diagnostics.filter((d) => d.severity === "error").map((d) => d.virtualFile ?? `${d.markdownFile}:${d.markdownRange.start.line}`)).size; const passedSnippets = Math.max(0, stats.checked - failedSnippets); const summary = []; if (stats.errors === 0 && stats.warnings === 0) summary.push(c.green(`✓ Kiira found no errors in ${pluralize(stats.markdownFiles, "file")}.`)); else { const parts = [c.red(pluralize(stats.errors, "error"))]; if (stats.warnings > 0) parts.push(c.yellow(pluralize(stats.warnings, "warning"))); summary.push(`${c.red("✖")} Kiira found ${parts.join(" and ")} in ${pluralize(stats.markdownFiles, "file")}.`); } summary.push(c.dim(`Checked ${pluralize(stats.checked, "snippet")}. Passed ${passedSnippets}. Failed ${failedSnippets}. Ignored ${stats.ignored}.`)); const fixable = fixableCount(result); if (fixable > 0) summary.push(c.cyan(`${pluralize(fixable, "issue")} fixable with \`kiira check --fix\`.`)); const body = sections.join(ctx.verbose ? "\n\n" : "\n"); return (body.length > 0 ? [ body, "", summary.join("\n") ] : [summary.join("\n")]).join("\n"); } function formatReport(reporter, result, ctx) { switch (reporter) { case "json": return formatJson(result); case "github": return formatGithub(result); default: return formatPretty(result, ctx); } } //#endregion //#region src/spinner.ts const FRAMES = [ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" ]; const ESC = String.fromCharCode(27); const HIDE_CURSOR = `${ESC}[?25l`; const SHOW_CURSOR = `${ESC}[?25h`; const CLEAR_LINE = `\r${ESC}[K`; /** * Start a braille spinner on stderr while async work runs. A no-op when disabled * (`--static`) or when the stream is not a TTY (pipes, CI) so output stays clean. */ function startSpinner(text, options) { const stream = options.stream ?? process.stderr; if (!options.enabled || !stream.isTTY) return { stop() {} }; let frame = 0; const render = () => { stream.write(`\r${FRAMES[frame]} ${text}`); }; stream.write(HIDE_CURSOR); render(); const timer = setInterval(() => { frame = (frame + 1) % FRAMES.length; render(); }, 80); return { stop() { clearInterval(timer); stream.write(`${CLEAR_LINE}${SHOW_CURSOR}`); } }; } //#endregion //#region src/commands/check.ts function createSourceLineReader(cwd) { const cache = /* @__PURE__ */ new Map(); return (markdownFile) => { if (cache.has(markdownFile)) return cache.get(markdownFile); let lines; try { lines = readFileSync(join(cwd, markdownFile), "utf8").split(/\r?\n/); } catch { lines = void 0; } cache.set(markdownFile, lines); return lines; }; } /** * Run `kiira check`. Returns the process exit code: 0 when clean, 1 when there * are validation errors. Configuration/runtime failures throw (the caller maps * those to exit code 2). */ async function runCheck(options) { const { cwd } = options; const loaded = options.config ? await loadConfigFile(isAbsolute(options.config) ? options.config : resolve(cwd, options.config)) : await loadConfig(cwd); const entries = [...options.files, ...options.entry ?? []]; const include = entries.length > 0 ? toIncludeGlobs(cwd, entries) : loaded.include; const exclude = [...loaded.exclude ?? [], ...toIgnoreGlobs(cwd, options.ignore ?? [])]; const config = { ...loaded, include, exclude }; const externalPackages = collectExternalPackages(config); if (Object.keys(externalPackages).length > 0) await ensureExternalPackages(cwd, externalPackages, { warn: options.error, log: options.verbose ? options.log : void 0 }); const spinner = startSpinner("Checking Markdown…", { enabled: !options.static }); const pending = []; let result; try { result = await checkMarkdownFiles({ cwd, config }); if (options.fix) { const configPath = options.config ? isAbsolute(options.config) ? options.config : resolve(cwd, options.config) : findConfigFile(cwd); const fences = await applyFixes(cwd, result.diagnostics); const overrides = await applyConfigOverrides(configPath, result.diagnostics); if (fences.fixesApplied > 0 || overrides.applied.length > 0) { const parts = []; if (fences.fixesApplied > 0) parts.push(`${fences.fixesApplied} fence${fences.fixesApplied === 1 ? "" : "s"}`); if (overrides.applied.length > 0) parts.push(`${overrides.applied.length} config override${overrides.applied.length === 1 ? "" : "s"}`); pending.push(`Fixed ${parts.join(" and ")}.\n`); config.overrides = [...config.overrides ?? [], ...overrides.applied]; result = await checkMarkdownFiles({ cwd, config }); } if (overrides.manual.length > 0) { pending.push("Add these overrides to your Kiira config (config is not JSON, so apply manually):"); for (const fix of overrides.manual) { const opts = Object.entries(fix.compilerOptions).map(([k, v]) => `"${k}": "${v}"`).join(", "); pending.push(` { "include": ["${fix.include}"], ${opts} }`); } } } } finally { spinner.stop(); } for (const message of pending) options.log(message); const output = formatReport(options.reporter, result, { cwd, getSourceLines: createSourceLineReader(cwd), verbose: options.verbose, raw: options.raw }); if (output.length > 0) options.log(output); return result.stats.errors > 0 ? 1 : 0; } //#endregion //#region src/commands/init.ts const CONFIG_TEMPLATE = `import { defineConfig } from "kiira-core" export default defineConfig({ \tinclude: ["docs/**/*.{md,mdx}", "README.md"], \ttsconfig: "tsconfig.docs.json", \tdefaultValidate: "type", \tlanguages: ["ts", "tsx", "js", "jsx"], \tfixtures: { \t\tnode: { type: "prepend", content: "export {}" }, \t\treact: { type: "prepend", content: 'import * as React from "react"' }, \t}, }) `; const TSCONFIG_TEMPLATE = `${JSON.stringify({ extends: "./tsconfig.json", compilerOptions: { target: "ES2022", module: "ESNext", moduleResolution: "Bundler", jsx: "react-jsx", strict: true, noEmit: true, allowJs: true, checkJs: true, skipLibCheck: true, types: ["node"] } }, null, 2)}\n`; async function writeIfMissing(path, content, name, log) { if (existsSync(path)) { log(`• ${name} already exists, skipping.`); return; } await writeFile(path, content, "utf8"); log(`✓ Created ${name}.`); } /** Scaffold a Kiira config and a docs tsconfig. Existing files are left untouched. */ async function runInit(options) { await writeIfMissing(join(options.cwd, "kiira.config.ts"), CONFIG_TEMPLATE, "kiira.config.ts", options.log); await writeIfMissing(join(options.cwd, "tsconfig.docs.json"), TSCONFIG_TEMPLATE, "tsconfig.docs.json", options.log); options.log("\nDone. Run `kiira check` to validate your Markdown."); return 0; } //#endregion //#region src/help.ts const VERSION = "0.1.0"; const HELP_TEXT = `kiira — type-check the code in your Markdown Usage: kiira [check] [files...] [options] kiira init Commands: check Validate Markdown code fences (default). init Scaffold kiira.config.ts and tsconfig.docs.json. Options: --entry <path> Directory, file, or glob to check (repeatable). --ignore <path> Directory, file, or glob to exclude (repeatable). --config <path> Path to a Kiira config file. --reporter <name> Output format: pretty (default), json, or github. --fix Rewrite mistagged code fences (e.g. ts -> tsx for JSX). --verbose Show full error messages and code frames. --raw Disable colored output (plain text). --static Disable the loading spinner. -h, --help Show this help. -v, --version Show the version. Examples: kiira check kiira check --entry docs --entry README.md kiira check --entry docs --ignore docs/api kiira check --reporter github kiira check --config kiira.config.ts --reporter json `; //#endregion //#region src/index.ts async function main(argv) { let parsed; try { parsed = parseArgs(argv); } catch (error) { console.error(error.message); return 2; } switch (parsed.command) { case "help": console.log(HELP_TEXT); return 0; case "version": console.log(VERSION); return 0; case "init": return runInit({ cwd: process.cwd(), log: (m) => console.log(m) }); default: return runCheck({ cwd: process.cwd(), files: parsed.files, entry: parsed.entry, ignore: parsed.ignore, config: parsed.config, reporter: parsed.reporter, fix: parsed.fix, verbose: parsed.verbose, raw: parsed.raw, static: parsed.static, log: (m) => console.log(m), error: (m) => console.error(m) }); } } main(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => { console.error(`kiira: ${error.message}`); process.exitCode = 2; }); //#endregion export { }; //# sourceMappingURL=index.mjs.map