UNPKG

kiira

Version:

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

1,057 lines (1,041 loc) • 36.3 kB
#!/usr/bin/env node import { appendFileSync, constants, existsSync, readFileSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { checkMarkdownFiles, closeNativeEngine, collectExternalPackages, discoverMarkdownFiles, ensureExternalPackages, findConfigFile, loadConfig, loadConfigFile, resolveConfig } from "kiira-core"; import { randomBytes } from "node:crypto"; import { access, chmod, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import { Chalk } from "chalk"; //#region src/args.ts const REPORTERS = [ "pretty", "json", "github" ]; const COMMANDS = new Set(["check", "init"]); const RULE_LEVELS = [ "off", "warn", "error" ]; /** Parse one `--rule` value (`<id>=<off|warn|error>`). Unknown ids are rejected later, by the config. */ function parseRule(value) { const eq = value.indexOf("="); const id = value.slice(0, eq); const level = value.slice(eq + 1); if (eq <= 0 || !RULE_LEVELS.includes(level)) throw new Error(`Invalid --rule "${value}". Expected <id>=<${RULE_LEVELS.join("|")}>.`); return [id, level]; } 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 dryRun = false; let verbose = false; let raw = false; let staticOutput = false; const files = []; const entry = []; const ignore = []; const rules = {}; const base = () => ({ files, config, reporter, fix, dryRun, verbose, raw, entry, ignore, static: staticOutput, rules }); 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 === "--rule" || arg.startsWith("--rule=")) { const [id, level] = parseRule(arg.includes("=") ? arg.slice(7) : argv[++i] ?? ""); rules[id] = level; continue; } if (arg === "--fix") { fix = true; continue; } if (arg === "--dry-run") { dryRun = 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); } if (dryRun && !fix) throw new Error("--dry-run only works with --fix."); return { command: command ?? "check", ...base() }; } //#endregion //#region src/diff.ts const CONTEXT = 3; /** Shortest edit script between two line lists (Myers), after trimming the shared head and tail. */ function diffLines(a, b) { let head = 0; while (head < a.length && head < b.length && a[head] === b[head]) head += 1; let tail = 0; while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail += 1; const x0 = a.slice(head, a.length - tail); const y0 = b.slice(head, b.length - tail); const n = x0.length; const m = y0.length; const offset = n + m + 1; const v = new Int32Array(2 * offset + 1); const trace = []; search: for (let d = 0; d <= n + m; d += 1) { trace.push(v.slice()); for (let k = -d; k <= d; k += 2) { let x = k === -d || k !== d && v[offset + k - 1] < v[offset + k + 1] ? v[offset + k + 1] : v[offset + k - 1] + 1; let y = x - k; while (x < n && y < m && x0[x] === y0[y]) { x += 1; y += 1; } v[offset + k] = x; if (x >= n && y >= m) break search; } } const middle = []; let x = n; let y = m; for (let d = trace.length - 1; d >= 0; d -= 1) { const before = trace[d]; const k = x - y; const prevK = k === -d || k !== d && before[offset + k - 1] < before[offset + k + 1] ? k + 1 : k - 1; const prevX = before[offset + prevK]; const prevY = prevX - prevK; while (x > prevX && y > prevY) { middle.push({ type: " ", line: x0[x - 1] }); x -= 1; y -= 1; } if (d > 0) if (x === prevX) { middle.push({ type: "+", line: y0[y - 1] }); y -= 1; } else { middle.push({ type: "-", line: x0[x - 1] }); x -= 1; } } middle.reverse(); return [ ...a.slice(0, head).map((line) => ({ type: " ", line })), ...middle, ...a.slice(a.length - tail).map((line) => ({ type: " ", line })) ]; } const splitLines = (text) => text.match(/[^\n]*\n|[^\n]+$/g) ?? []; function formatLine(op) { const text = op.line.endsWith("\n") ? op.line.slice(0, -1) : `${op.line}\n\\ No newline at end of file`; return `${op.type}${text}\n`; } const range = (start, count) => `${count === 0 ? start - 1 : start},${count}`; /** A unified diff (`--- a/` / `+++ b/` headers, 3 lines of context) between two texts, or "" when they are equal. */ function unifiedDiff(file, before, after) { if (before === after) return ""; const ops = diffLines(splitLines(before), splitLines(after)); const regions = []; ops.forEach((op, index) => { if (op.type === " ") return; const start = Math.max(0, index - CONTEXT); const end = Math.min(ops.length, index + CONTEXT + 1); const last = regions[regions.length - 1]; if (last && start <= last[1]) last[1] = end; else regions.push([start, end]); }); let out = `--- a/${file}\n+++ b/${file}\n`; for (const [start, end] of regions) { const hunk = ops.slice(start, end); const consumed = ops.slice(0, start); const oldCount = hunk.filter((op) => op.type !== "+").length; const newCount = hunk.filter((op) => op.type !== "-").length; const oldStart = consumed.filter((op) => op.type !== "+").length + 1; const newStart = consumed.filter((op) => op.type !== "-").length + 1; out += `@@ -${range(oldStart, oldCount)} +${range(newStart, newCount)} @@\n${hunk.map(formatLine).join("")}`; } return out; } //#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 textEdit(file, line, from, to, newText) { return { file, range: { start: { line, character: from }, end: { line, character: to } }, newText }; } /** Turn a fence fix into an edit, using the fence line as the check read it. `undefined` when there is nothing to change. */ function fenceFixToEdit(file, fix, source) { const line = source.split("\n")[fix.line]?.replace(/\r$/, ""); if (line === void 0) return; if (fix.kind === "fence-language") { const match = FENCE_LANG.exec(line); if (!match || match[2] === fix.language) return; const start = match[1].length; return textEdit(file, fix.line, start, start + match[2].length, fix.language); } if (line.includes(fix.append)) return; return textEdit(file, fix.line, line.trimEnd().length, line.length, ` ${fix.append}`); } const editKey = (edit) => JSON.stringify([edit.range, edit.newText]); const countUnique = (candidates) => new Set(candidates.map(({ edit }) => editKey(edit))).size; const isForbidden = (relativePath) => relativePath.split(sep).some((part) => part === ".git" || part === "node_modules"); const isOutside = (relativePath) => relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath); /** `file` as a cwd-relative posix path, the way the check keys `sources`. Absolute paths stay as written. */ const normalizePath = (cwd, file) => isAbsolute(file) ? file : relative(cwd, resolve(cwd, file)).split(sep).join("/"); /** Why `file` is refused as written: it must be inside `cwd` and outside `.git` and `node_modules`. */ function unsafePath(cwd, file) { if (isAbsolute(file)) return "the path must be relative to the project"; const lexical = relative(cwd, resolve(cwd, file)); if (lexical === "" || isOutside(lexical)) return "the path is outside the project"; if (isForbidden(lexical)) return "the path is inside .git or node_modules"; } /** * The real path to write for `file`, or why it is refused. Like `unsafePath`, but * after following every symlink on the way (`realpath` resolves each component). */ async function resolveTarget(cwd, file) { let target; let root; try { target = await realpath(resolve(cwd, file)); root = await realpath(cwd); } catch { return { reason: "the file cannot be read" }; } const real = relative(root, target); if (real === "" || isOutside(real)) return { reason: "the path resolves outside the project (symlink)" }; if (isForbidden(real)) return { reason: "the path resolves into .git or node_modules (symlink)" }; return { target }; } function lineStarts(text) { const starts = [0]; for (let i = 0; i < text.length; i += 1) if (text[i] === "\n") starts.push(i + 1); return starts; } /** A position's offset into `text`. A line's characters stop before its `\r\n`. */ function toOffset(text, starts, position) { const start = starts[position.line]; if (start === void 0) return; const next = starts[position.line + 1]; let end = next === void 0 ? text.length : next - 1; if (next !== void 0 && text[end - 1] === "\r") end -= 1; return position.character >= 0 && start + position.character <= end ? start + position.character : void 0; } /** `text` with `candidates` applied, or the labels of the fixes whose edits overlap, or a bad range. */ function applyEdits(text, candidates) { const starts = lineStarts(text); const crlf = text.includes("\r\n"); const seen = /* @__PURE__ */ new Set(); const resolved = []; for (const { edit, label } of candidates) { const key = editKey(edit); if (seen.has(key)) continue; seen.add(key); const start = toOffset(text, starts, edit.range.start); const end = toOffset(text, starts, edit.range.end); if (start === void 0 || end === void 0 || end < start) return { reason: `an edit from ${label} has a range outside the file` }; resolved.push({ start, end, newText: crlf ? edit.newText.replace(/\r?\n/g, "\r\n") : edit.newText, label }); } resolved.sort((a, b) => a.start - b.start || a.end - b.end); const conflicts = /* @__PURE__ */ new Set(); let widest = resolved[0]; for (const edit of resolved.slice(1)) { if (edit.start < widest.end) { conflicts.add(widest.label); conflicts.add(edit.label); } if (edit.end > widest.end) widest = edit; } if (conflicts.size > 0) return { reason: `overlapping edits from ${[...conflicts].join(" and ")}; none applied` }; let out = ""; let cursor = 0; for (const edit of resolved) { out += text.slice(cursor, edit.start) + edit.newText; cursor = edit.end; } return { text: out + text.slice(cursor) }; } /** Why `target` must not be replaced with edits to `before`, or `undefined` when it can be. */ async function staleReason(target, before) { let bytes; try { bytes = await readFile(target); } catch { return "the file cannot be read"; } const text = bytes.toString("utf8"); if (!Buffer.from(text, "utf8").equals(bytes)) return "the file is not valid UTF-8"; if (text !== before) return "the file changed since the check read it"; try { await access(target, constants.W_OK); } catch { return "the file is not writable"; } } /** The checks on `file` before any edit is planned: a safe path, read by the check, and unchanged since. */ async function inspectFile(cwd, file, sources) { const unsafe = unsafePath(cwd, file); if (unsafe) return { reason: unsafe }; const before = sources[file]; if (before === void 0) return { reason: "the check did not read this file" }; const resolved = await resolveTarget(cwd, file); if ("reason" in resolved) return resolved; const reason = await staleReason(resolved.target, before); return reason ? { reason } : { target: resolved.target, before }; } async function writeAtomic(target, before, content) { const { mode } = await stat(target); const temp = join(dirname(target), `.${basename(target)}.${randomBytes(6).toString("hex")}.tmp`); try { await writeFile(temp, content, { flag: "wx", mode }); await chmod(temp, mode & 4095); const reason = await staleReason(target, before); if (reason) throw new Error(reason); await rename(temp, target); } catch (error) { await rm(temp, { force: true }); throw error; } } /** * Apply the text fixes in `diagnostics`: `edits` fixes, and the built-in fence * fixes (rewrite a mistagged language, append `group=`), which become edits first. * * `sources` is what the check read. A file is refused, and left untouched, when * the check did not read it, it changed since, it is not UTF-8 or not writable, * its path is unsafe, or its edits overlap. A fix is all or nothing: when one of * its files is refused, its edits to the other files are dropped too. Other fixes * still apply. Every check runs before the first write. With `dryRun` nothing is written. */ async function applyFixes(cwd, diagnostics, sources, options = {}) { const candidates = []; const refused = /* @__PURE__ */ new Map(); diagnostics.forEach((d, index) => { const fix = d.fix; if (!fix || fix.kind === "config-override") return; const label = `${d.code ?? d.source} at ${d.markdownFile}:${d.markdownRange.start.line + 1}:${d.markdownRange.start.character + 1}`; if (fix.kind === "edits") { for (const edit of fix.edits) candidates.push({ edit: { ...edit, file: normalizePath(cwd, edit.file) }, label, fence: false, fix: index }); return; } const source = sources[d.markdownFile]; if (source === void 0) { refused.set(d.markdownFile, "the check did not read this file"); return; } const edit = fenceFixToEdit(d.markdownFile, fix, source); if (edit) candidates.push({ edit, label, fence: true, fix: index }); }); const files = /* @__PURE__ */ new Map(); for (const file of new Set(candidates.map(({ edit }) => edit.file))) { const inspected = refused.has(file) ? void 0 : await inspectFile(cwd, file, sources); if (inspected && "reason" in inspected) refused.set(file, inspected.reason); else if (inspected) files.set(file, inspected); } const dropped = /* @__PURE__ */ new Set(); let planned = []; for (let replan = true; replan;) { replan = false; for (const c of candidates) if (refused.has(c.edit.file)) dropped.add(c.fix); planned = []; for (const [file, { target, before }] of files) { const mine = candidates.filter((c) => c.edit.file === file && !dropped.has(c.fix)); if (refused.has(file) || mine.length === 0) continue; const applied = applyEdits(before, mine); if ("reason" in applied) { refused.set(file, applied.reason); replan = true; } else if (applied.text !== before) planned.push({ file, before, after: applied.text, target, candidates: mine }); } } const refusals = [...refused].map(([file, reason]) => ({ file, reason })); for (const fix of dropped) { const edits = candidates.filter((c) => c.fix === fix); const cause = edits.find((c) => refused.has(c.edit.file))?.edit.file; for (const file of new Set(edits.map(({ edit }) => edit.file))) if (!refused.has(file)) refusals.push({ file, reason: `the fix from ${edits[0].label} also edits ${cause}, which was skipped` }); } const changes = []; let editsApplied = 0; let fenceEditsApplied = 0; for (const { target, candidates, ...change } of planned) { if (!options.dryRun) try { await writeAtomic(target, change.before, change.after); } catch (error) { refusals.push({ file: change.file, reason: `could not write the file: ${error.message}` }); continue; } changes.push(change); editsApplied += countUnique(candidates); fenceEditsApplied += countUnique(candidates.filter((c) => c.fence)); } return { filesChanged: changes.length, editsApplied, fenceEditsApplied, refusals, changes }; } /** * Apply `config-override` fixes by merging per-glob compiler-option overrides into * a JSON Kiira config (with `dryRun`, only report what would be added). 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, options = {}) { 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 && !options.dryRun) { 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({ schemaVersion: 1, 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"); } const SUMMARY_ERROR_LIMIT = 10; const SUMMARY_MESSAGE_LIMIT = 200; /** A code span whose fence is longer than any backtick run in `text`. */ function codeSpan(text) { const longestRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length)); const fence = "`".repeat(longestRun + 1); const pad = text.startsWith("`") || text.endsWith("`") ? " " : ""; return `${fence}${pad}${text}${pad}${fence}`; } /** The first line of a message, capped and escaped so Markdown and HTML show as plain text. */ function summaryMessage(message) { const line = message.split("\n")[0]; return (line.length > SUMMARY_MESSAGE_LIMIT ? `${line.slice(0, SUMMARY_MESSAGE_LIMIT - 1)}…` : line).replace(/[\\`*_[\]<>|&]/g, "\\$&"); } /** Markdown for `$GITHUB_STEP_SUMMARY`: outcome, counts, and the first errors. */ function formatGithubSummary(result) { const { stats } = result; const errors = result.diagnostics.filter((d) => d.severity === "error"); const lines = [ "### Kiira", "", stats.errors === 0 ? "Passed" : "Failed", "", `- Files: ${stats.markdownFiles}`, `- Snippets checked: ${stats.checked}`, `- Errors: ${stats.errors}`, `- Warnings: ${stats.warnings}` ]; if (errors.length > 0) { lines.push(""); for (const d of errors.slice(0, SUMMARY_ERROR_LIMIT)) lines.push(`- ${codeSpan(`${d.markdownFile}:${d.markdownRange.start.line + 1}`)} ${summaryMessage(d.message)}`); if (errors.length > SUMMARY_ERROR_LIMIT) lines.push(`- and ${errors.length - SUMMARY_ERROR_LIMIT} more`); } return `${lines.join("\n")}\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; }; } const plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`; /** * 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 exclude = [...loaded.exclude ?? [], ...toIgnoreGlobs(cwd, options.ignore ?? [])]; const config = { ...loaded, exclude }; const files = entries.length > 0 ? await discoverMarkdownFiles({ cwd, include: toIncludeGlobs(cwd, entries), exclude: resolveConfig(config, options.rules).exclude }) : void 0; 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 = []; const queue = (message, channel = "log") => { pending.push({ message, channel }); }; let result; try { result = await checkMarkdownFiles({ cwd, config, files, ruleOverrides: options.rules }); if (options.fix) { const configPath = options.config ? isAbsolute(options.config) ? options.config : resolve(cwd, options.config) : findConfigFile(cwd); const dryRun = options.dryRun === true; const fences = await applyFixes(cwd, result.diagnostics, result.sources, { dryRun }); const overrides = await applyConfigOverrides(configPath, result.diagnostics, { dryRun }); for (const { file, reason } of fences.refusals) queue(`Skipped ${file}: ${reason}.`, "error"); if (dryRun) { const channel = options.reporter === "json" ? "error" : "log"; for (const { file, before, after } of fences.changes) queue(unifiedDiff(file, before, after).replace(/\n$/, ""), channel); for (const override of overrides.applied) queue(`Would add config override: ${JSON.stringify(override)}`, channel); queue(`Dry run: would change ${plural(fences.filesChanged, "file")} (${plural(fences.editsApplied, "edit")}) and add ${plural(overrides.applied.length, "config override")}. Nothing was written.`, channel); } else if (fences.editsApplied > 0 || overrides.applied.length > 0) { const parts = []; if (fences.fenceEditsApplied > 0) parts.push(plural(fences.fenceEditsApplied, "fence")); if (fences.editsApplied > fences.fenceEditsApplied) parts.push(plural(fences.editsApplied - fences.fenceEditsApplied, "edit")); if (overrides.applied.length > 0) parts.push(plural(overrides.applied.length, "config override")); queue(`Fixed ${parts.join(" and ")}.\n`); config.overrides = [...config.overrides ?? [], ...overrides.applied]; result = await checkMarkdownFiles({ cwd, config, files, ruleOverrides: options.rules }); } if (overrides.manual.length > 0) { queue("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(", "); queue(` { "include": ["${fix.include}"], ${opts} }`); } } } } finally { spinner.stop(); try { await closeNativeEngine(cwd); } catch (error) { const message = error instanceof Error ? error.message : String(error); options.error(`Warning: could not close the native TypeScript session: ${message}`); } } for (const { message, channel } of pending) options[channel](message); if (result.skipped && options.reporter === "pretty") { options.log("No files matched; nothing to check."); return 0; } const output = formatReport(options.reporter, result, { cwd, getSourceLines: createSourceLineReader(cwd), verbose: options.verbose, raw: options.raw }); if (output.length > 0) options.log(output); const summaryFile = (options.env ?? process.env).GITHUB_STEP_SUMMARY; if (options.reporter === "github" && summaryFile) try { appendFileSync(summaryFile, formatGithubSummary(result)); } catch (error) { options.error(`Kiira could not write the step summary to ${summaryFile}: ${error.message}`); } 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. --rule <id>=<level> Set a rule to off, warn, or error (repeatable). --reporter <name> Output format: pretty (default), json, or github. --fix Apply automatic fixes (e.g. rewrite ts -> tsx for JSX fences). --dry-run With --fix, print a diff and write nothing. --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 --fix --dry-run kiira check --reporter github kiira check --config kiira.config.ts --reporter json kiira check --rule unused-symbols=warn --rule group=off `; //#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, rules: parsed.rules, reporter: parsed.reporter, fix: parsed.fix, dryRun: parsed.dryRun, 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 { };