1
mirror of https://github.com/jakejarvis/hugo-extended.git synced 2026-06-24 10:25:57 -04:00

refactor: full typescript migration (#174)

This commit is contained in:
2026-01-06 21:08:36 -05:00
committed by GitHub
parent 4ce0fbb869
commit 0f9bca8bf5
32 changed files with 4803 additions and 2903 deletions
+579
View File
@@ -0,0 +1,579 @@
import fs from "node:fs/promises";
import path from "node:path";
import { x } from "tinyexec";
import hugo from "../src/hugo";
const OUT_DIR = "src/generated";
const HUGO_TYPES_FILE = "types.ts";
const HUGO_FLAGS_JSON_FILE = "flags.json";
/**
* Normalized flag "kinds" that we map Hugo/Cobra type tokens into.
* These directly correspond to TypeScript types we emit.
*/
type FlagKind = "boolean" | "string" | "number" | "string[]" | "number[]";
/**
* Represents a single CLI flag as parsed from Hugo help output.
*/
type FlagSpec = {
/** Long flag name, including the leading `--` (e.g. `--baseURL`). */
long: string;
/** Optional short flag name, including the leading `-` (e.g. `-b`). */
short?: string;
/**
* Type token printed by Cobra/pflag (e.g. `string`, `int`, `strings`, `file`).
* Omitted for many boolean flags.
*/
typeToken?: string;
/** Derived TS-friendly kind for code generation + argv building. */
kind: FlagKind;
/** Human description (wrapped lines merged, defaults/enums stripped out). */
description: string;
/**
* Enum values inferred from patterns like `(debug|info|warn|error)` in the description.
* When present, we emit a string-literal union instead of a plain `string`.
*/
enum?: string[];
/**
* Default value parsed from a trailing `(default ...)` or `(default is ...)` suffix.
* Stored as raw text, since Hugo prints defaults in multiple formats.
*/
defaultRaw?: string;
};
/**
* Represents a Hugo commands parsed help metadata.
*/
type CommandSpec = {
/**
* Command tokens representing the "path" to the command.
* Examples: `["server"]`, `["mod","get"]`.
*/
pathTokens: string[];
/** Flags listed under the `Flags:` section (command-local). */
flags: FlagSpec[];
/** Flags listed under the `Global Flags:` section (persistent/global). */
globalFlags: FlagSpec[];
/** Subcommand names listed under the `Available Commands:` section. */
subcommands: string[];
};
/**
* Matches a single Hugo flag row in the `Flags:`/`Global Flags:` sections.
* Examples:
* - `-b, --baseURL string ...`
* - ` --cacheDir string ...`
* - `-D, --buildDrafts ...`
*/
const FLAG_LINE =
/^\s*(?:(?<short>-[A-Za-z]),\s*)?(?<long>--[A-Za-z0-9][A-Za-z0-9-]*)\s*(?:(?<type>[A-Za-z][A-Za-z0-9]*)\s+)?(?<desc>.+?)\s*$/;
/**
* Matches a wrapped continuation line for a flag description (indented; not starting with `-`/`--`).
*/
const CONTINUATION_LINE = /^\s{2,}(?<more>[^-\s].+?)\s*$/;
/**
* Matches section headers like `Flags:` / `Global Flags:` / `Available Commands:`.
*/
const SECTION_HEADER = /^(?<name>[A-Z][A-Za-z ]+):\s*$/;
/**
* Canonical type tokens emitted by Cobra/pflag in help output.
* If a captured "type" isn't in this list, it's actually part of the description (common for booleans).
*/
const KNOWN_TYPE_TOKENS = new Set([
"string",
"strings",
"int",
"int64",
"uint",
"uint64",
"float64",
"bool",
"boolean",
"file",
"duration",
"ints",
]);
/**
* Map Cobra/pflag type tokens to a small set of TS-friendly kinds.
*
* @param typeToken - Token printed in help output (e.g. `string`, `int`, `strings`, `file`).
* @returns A normalized {@link FlagKind} used in code generation.
*/
function mapTypeTokenToKind(typeToken?: string): FlagKind {
if (!typeToken) return "boolean";
switch (typeToken.toLowerCase()) {
case "bool":
case "boolean":
return "boolean";
case "string":
case "file":
case "duration":
return "string";
case "strings":
return "string[]";
case "int":
case "int64":
case "uint":
case "uint64":
case "float64":
return "number";
case "ints":
return "number[]";
default:
// Be conservative: Hugo occasionally prints tokens beyond the common set.
return "string";
}
}
/**
* Extract a trailing default from a help description.
*
* Supports:
* - `(default true)`
* - `(default "127.0.0.1")`
* - `(default is hugo.yaml|json|toml)`
* - `(default -1)`
*
* @param desc - Full description string from help output.
* @returns Cleaned description + raw default (if present).
*/
function extractDefault(desc: string): {
cleaned: string;
defaultRaw?: string;
} {
const re = /\s*\(default(?:\s+is)?\s+([^)]+)\)\s*$/i;
const m = re.exec(desc);
if (!m) return { cleaned: desc };
return {
cleaned: desc.slice(0, m.index).trimEnd(),
defaultRaw: m[1].trim(),
};
}
/**
* Extract a simple enum from a help description.
*
* Looks for a parenthesized `a|b|c` list, e.g.:
* - `log level (debug|info|warn|error)`
*
* @param desc - Full description string from help output.
* @returns Cleaned description + enum values (if confidently detected).
*/
function extractEnum(desc: string): { cleaned: string; enum?: string[] } {
const re = /\(([^()]*\|[^()]*)\)/;
const m = re.exec(desc);
if (!m) return { cleaned: desc };
const parts = m[1]
.split("|")
.map((s) => s.trim())
.filter(Boolean);
// Guard against false positives: only accept "simple" enum tokens.
if (parts.length < 2 || parts.some((p) => !/^[A-Za-z0-9._-]+$/.test(p))) {
return { cleaned: desc };
}
const cleaned = (desc.slice(0, m.index) + desc.slice(m.index + m[0].length))
.replace(/\s{2,}/g, " ")
.trim();
return { cleaned, enum: parts };
}
/**
* Parse a contiguous flag section (either `Flags:` or `Global Flags:`) from help output.
*
* @param lines - Entire help output split into lines.
* @param startIdx - Line index immediately after the section header.
* @returns Parsed flags, plus the index where parsing stopped.
*/
function parseFlagsFromSection(
lines: string[],
startIdx: number,
): { flags: FlagSpec[]; endIdx: number } {
const out: FlagSpec[] = [];
let last: FlagSpec | null = null;
for (let i = startIdx; i < lines.length; i++) {
const raw = lines[i].replace(/\t/g, " ").trimEnd();
// Hugo ends with a standard "Use ..." hint; treat that as a hard stop.
if (raw.startsWith('Use "hugo ')) return { flags: out, endIdx: i };
// If we hit another section header (e.g. `Global Flags:` after `Flags:`), stop.
const header = raw.trim().match(SECTION_HEADER);
if (header) return { flags: out, endIdx: i };
const m = raw.match(FLAG_LINE);
if (m?.groups?.long && m.groups.desc) {
const long = m.groups.long;
// Drop `--help` so it doesn't appear in generated option types.
if (long === "--help") {
last = null;
continue;
}
// Cobra's help formatting makes the type column optional. For boolean flags, the first
// word of the description can get mis-captured as a "type". Guard with a whitelist.
let typeToken: string | undefined = m.groups.type;
let desc = m.groups.desc.trim();
if (typeToken && !KNOWN_TYPE_TOKENS.has(typeToken.toLowerCase())) {
desc = `${typeToken} ${desc}`.trim();
typeToken = undefined;
}
// Pull defaults/enums out of the description while preserving raw values.
const def = extractDefault(desc);
desc = def.cleaned;
const en = extractEnum(desc);
desc = en.cleaned;
out.push({
long,
short: m.groups.short,
typeToken,
kind: mapTypeTokenToKind(typeToken),
description: desc,
defaultRaw: def.defaultRaw,
enum: en.enum,
});
last = out[out.length - 1];
continue;
}
// Merge wrapped description lines into the previous flag.
const c = raw.match(CONTINUATION_LINE);
if (c?.groups?.more && last) {
last.description = `${last.description} ${c.groups.more.trim()}`
.replace(/\s{2,}/g, " ")
.trim();
} else {
last = null;
}
}
return { flags: out, endIdx: lines.length };
}
/**
* Parse `Available Commands:` names from a help output.
*
* @param helpText - Full help output.
* @returns List of subcommand names (single token each).
*/
function parseAvailableCommands(helpText: string): string[] {
const lines = helpText.split(/\r?\n/);
const idx = lines.findIndex((l) => l.trim() === "Available Commands:");
if (idx === -1) return [];
const out: string[] = [];
for (let i = idx + 1; i < lines.length; i++) {
const raw = lines[i].trimEnd();
if (raw.trim() === "") continue;
// Stop on the next section header.
if (/^[A-Z][A-Za-z ]+:$/.test(raw.trim())) break;
// Typical format: " server Start the embedded web server"
const mm = raw.match(/^\s{2,}(?<name>[a-z0-9][a-z0-9-]*)\s{2,}.+$/i);
if (mm?.groups?.name) out.push(mm.groups.name);
}
return out;
}
/**
* Parse the relevant parts of a Hugo commands help output:
* - `Flags:`
* - `Global Flags:`
* - `Available Commands:`
*
* @param helpText - Full help output for a command.
* @param pathTokens - Command path tokens (e.g. `["server"]`, `["mod","get"]`, `["root"]`).
* @returns Parsed command metadata.
*/
function parseCommandHelp(helpText: string, pathTokens: string[]): CommandSpec {
const lines = helpText.split(/\r?\n/);
let flags: FlagSpec[] = [];
let globalFlags: FlagSpec[] = [];
const flagsHeaderIdx = lines.findIndex((l) => l.trim() === "Flags:");
if (flagsHeaderIdx !== -1) {
flags = parseFlagsFromSection(lines, flagsHeaderIdx + 1).flags;
}
const globalHeaderIdx = lines.findIndex((l) => l.trim() === "Global Flags:");
if (globalHeaderIdx !== -1) {
globalFlags = parseFlagsFromSection(lines, globalHeaderIdx + 1).flags;
}
const subcommands = parseAvailableCommands(helpText);
return { pathTokens, flags, globalFlags, subcommands };
}
/**
* Strip the leading `--` from a long flag name.
*
* @param long - Long flag name, e.g. `--baseURL`.
* @returns Name without the `--` prefix, e.g. `baseURL`.
*/
function normalizeLong(long: string) {
return long.startsWith("--") ? long.slice(2) : long;
}
/**
* Convert kebab-case to camelCase. If the name is already mixedCase (e.g. `baseURL`),
* it is returned as-is.
*
* @param name - Flag name without the `--` prefix.
* @returns JS/TS-friendly property name.
*/
function camelizeIfKebab(name: string) {
if (!name.includes("-")) return name;
const [first, ...rest] = name.split("-");
return (
first + rest.map((p) => (p ? p[0].toUpperCase() + p.slice(1) : "")).join("")
);
}
/**
* Convert tokens to PascalCase (used to generate interface names).
*
* @param tokens - Command path tokens (e.g. `["mod","get"]`).
* @returns PascalCase string (e.g. `ModGet`).
*/
function pascal(tokens: string[]) {
return tokens.map((t) => (t ? t[0].toUpperCase() + t.slice(1) : "")).join("");
}
/**
* Convert a normalized {@link FlagKind} + optional enum into a TypeScript type string.
*
* @param kind - Normalized kind.
* @param en - Optional enum values inferred from description.
* @returns TypeScript type representation for emitted code.
*/
function kindToTs(kind: FlagKind, en?: string[]) {
if (en?.length) return en.map((v) => JSON.stringify(v)).join(" | ");
switch (kind) {
case "boolean":
return "boolean";
case "string":
return "string";
case "number":
return "number";
case "string[]":
return "string[]";
case "number[]":
return "number[]";
}
}
/**
* Deduplicate flags by their long name. First occurrence wins.
*
* @param flags - Flags to dedupe.
* @returns Deduped list.
*/
function dedupeByLong(flags: FlagSpec[]): FlagSpec[] {
const seen = new Map<string, FlagSpec>();
for (const f of flags) if (!seen.has(f.long)) seen.set(f.long, f);
return [...seen.values()];
}
/**
* Emit TypeScript interfaces and helper types for Hugo commands:
* - `HugoGlobalOptions`
* - `Hugo<CommandPath>Options` for each command
* - `HugoCommand` union and `HugoOptionsFor<>` conditional mapping
*
* @param globalFlags - Persistent/global flags (from `Global Flags:` sections).
* @param commands - Command metadata to emit (command-local flags).
* @returns TypeScript source as a single string.
*/
function emitInterfaces(globalFlags: FlagSpec[], commands: CommandSpec[]) {
const lines: string[] = [];
lines.push(`/* eslint-disable */`);
lines.push(`// AUTO-GENERATED. DO NOT EDIT.`);
lines.push("");
lines.push(`export interface HugoGlobalOptions {`);
for (const f of globalFlags.sort((a, b) => a.long.localeCompare(b.long))) {
const prop = camelizeIfKebab(normalizeLong(f.long));
const tsType = kindToTs(f.kind, f.enum);
const def = f.defaultRaw ? ` (default ${f.defaultRaw})` : "";
lines.push(` /** ${f.description}${def} */`);
lines.push(` ${prop}?: ${tsType};`);
}
lines.push(`}`);
lines.push("");
for (const cmd of commands.sort((a, b) =>
a.pathTokens.join(" ").localeCompare(b.pathTokens.join(" ")),
)) {
const name = `Hugo${pascal(cmd.pathTokens)}Options`;
lines.push(`export interface ${name} extends HugoGlobalOptions {`);
for (const f of cmd.flags.sort((a, b) => a.long.localeCompare(b.long))) {
const prop = camelizeIfKebab(normalizeLong(f.long));
const tsType = kindToTs(f.kind, f.enum);
const def = f.defaultRaw ? ` (default ${f.defaultRaw})` : "";
lines.push(` /** ${f.description}${def} */`);
lines.push(` ${prop}?: ${tsType};`);
}
lines.push(`}`);
lines.push("");
}
const cmdStrings = commands.map((c) => c.pathTokens.join(" "));
lines.push(
`export type HugoCommand = ${cmdStrings.map((s) => JSON.stringify(s)).join(" | ")};`,
);
lines.push("");
lines.push(`export type HugoOptionsFor<C extends HugoCommand> =`);
for (const cmd of commands) {
const s = cmd.pathTokens.join(" ");
const name = `Hugo${pascal(cmd.pathTokens)}Options`;
lines.push(` C extends ${JSON.stringify(s)} ? ${name} :`);
}
lines.push(` never;`);
lines.push("");
return lines.join("\n");
}
/**
* Execute the Hugo binary with the provided args and return the help text.
*
* Hugo prints help to stdout in the cases we rely on.
*
* @param bin - Absolute path to the Hugo executable resolved by this package.
* @param args - CLI args to pass (e.g. `["server","--help"]`).
* @returns Help output (stdout).
*/
async function getHelp(bin: string, args: string[]) {
const out = await x(bin, args, { throwOnError: true });
return out.stdout;
}
/**
* Get help text for a command, using the appropriate method.
* Some commands (like `new`) redirect `--help` to a default subcommand,
* so we use `help <command>` instead to see the parent command structure.
*
* @param bin - Absolute path to the Hugo executable.
* @param tokens - Command path tokens (e.g. `["new"]`).
* @returns Help output showing subcommands if they exist.
*/
async function getCommandHelp(bin: string, tokens: string[]) {
if (tokens.length === 0) {
return getHelp(bin, ["--help"]);
}
// First try using `help <command>` to see if subcommands are listed
const helpArgs = ["help", ...tokens];
const helpOutput = await getHelp(bin, helpArgs);
// If we see "Available Commands:" in the output, use this version
if (helpOutput.includes("Available Commands:")) {
return helpOutput;
}
// Otherwise fall back to the standard `<command> --help`
const stdArgs = [...tokens, "--help"];
return getHelp(bin, stdArgs);
}
/**
* Main entry point: discovers the Hugo command tree, parses flags, and emits:
* - `src/types.ts` (types/interfaces)
* - `src/hugo.spec.json` (runtime spec for argv building)
*/
async function run() {
const bin = await hugo();
// BFS over command tree; `[]` means root.
const queue: string[][] = [[]];
const visited = new Set<string>();
const commandSpecs: CommandSpec[] = [];
const globalFlagsAll: FlagSpec[] = [];
while (queue.length) {
const tokens = queue.shift() ?? [];
const key = tokens.join(" ");
if (visited.has(key)) continue;
visited.add(key);
const helpText = await getCommandHelp(bin, tokens);
// Root is used only for discovery (naming convenience).
const pathTokens = tokens.length ? tokens : ["root"];
const spec = parseCommandHelp(helpText, pathTokens);
commandSpecs.push(spec);
globalFlagsAll.push(...spec.globalFlags);
for (const sub of spec.subcommands) {
queue.push(tokens.concat([sub]));
}
}
// Global options = union of flags found in `Global Flags:` sections.
const globalFlags = dedupeByLong(globalFlagsAll);
const globalLongSet = new Set(globalFlags.map((f) => f.long));
// Drop root and strip global flags from each commands local flags to avoid duplicates.
const cleanedCommands = commandSpecs
.filter((c) => c.pathTokens[0] !== "root")
.map((c) => ({
...c,
flags: c.flags.filter((f) => !globalLongSet.has(f.long)),
}));
const outDir = path.join(process.cwd(), OUT_DIR);
await fs.mkdir(outDir, { recursive: true });
if (HUGO_TYPES_FILE) {
await fs.writeFile(
path.join(outDir, HUGO_TYPES_FILE),
emitInterfaces(globalFlags, cleanedCommands),
"utf8",
);
console.log(`Wrote ${HUGO_TYPES_FILE}`);
}
if (HUGO_FLAGS_JSON_FILE) {
await fs.writeFile(
path.join(outDir, HUGO_FLAGS_JSON_FILE),
JSON.stringify(
{
globalFlags,
commands: cleanedCommands.map((c) => ({
command: c.pathTokens.join(" "),
flags: c.flags,
})),
},
null,
2,
),
"utf8",
);
console.log(`Wrote ${HUGO_FLAGS_JSON_FILE}`);
}
}
run().catch((err) => {
console.error(err);
process.exitCode = 1;
});