kiira
Version:
Command-line interface for Kiira: validate TypeScript and JavaScript code fences in your Markdown docs.
625 lines (610 loc) • 20.1 kB
JavaScript
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}