Files
suaveplan/scripts/split-audio-fx-plugin/scaffold.ts
2026-05-27 23:47:53 +01:00

209 lines
6.7 KiB
TypeScript

#!/usr/bin/env bun
/**
* Scaffold a new @suaveplan/audio-fx-plugin-<slug> package by:
* 1. Copying every template under ./template/, replacing {PLUGIN_NAME} markers.
* 2. git-mv'ing the source folder from packages/audio/audio-plugins-fx/src/plugins/<src-dir>/ → packages/audio-fx/audio-fx-plugin-<slug>/src/.
* 3. Rewriting relative imports inside the moved sources to point at @suaveplan/audio-fx-plugin-base/* barrels.
* 4. Rewriting @suaveplan/audio-plugins-fx/<slug> doc references to @suaveplan/audio-fx-plugin-<slug>.
*
* Usage:
* bun scripts/split-audio-fx-plugin/scaffold.ts --name compressor
* bun scripts/split-audio-fx-plugin/scaffold.ts --name game-audio --source-dir game
*
* After scaffold:
* bunx turbo lint typecheck test --filter=@suaveplan/audio-fx-plugin-<slug>
* bunx biome check --write src/ # may need formatting fix
*/
import { spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmdirSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const SLUG_RE = /^[a-z][a-z0-9-]*$/;
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const TEMPLATE_DIR = join(SCRIPT_DIR, "template");
const REPO_ROOT = join(SCRIPT_DIR, "..", "..");
const SOURCE_PARENT = join(
REPO_ROOT,
"packages",
"audio",
"audio-plugins-fx",
"src",
"plugins"
);
const DEST_PARENT = join(REPO_ROOT, "packages", "audio");
interface Args {
name: string;
sourceDir: string;
description: string;
}
function parseArgs(): Args {
const argv = process.argv.slice(2);
let name = "";
let sourceDir = "";
let description = "";
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
const next = argv[i + 1];
if (!next) continue;
if (flag === "--name") {
name = next;
i++;
} else if (flag === "--source-dir") {
sourceDir = next;
i++;
} else if (flag === "--description") {
description = next;
i++;
}
}
if (!name || !SLUG_RE.test(name)) {
throw new Error(
`--name must match /^[a-z][a-z0-9-]*$/ (got: ${JSON.stringify(name)})`
);
}
if (!sourceDir) sourceDir = name;
if (!description) {
description = `${name} plugin for the @suaveplan/audio-fx-plugin-* family.`;
}
return { name, sourceDir, description };
}
function gitMv(from: string, to: string): void {
const result = spawnSync("git", ["mv", from, to], {
cwd: REPO_ROOT,
stdio: "inherit",
});
if (result.status !== 0) {
throw new Error(
`git mv ${from} ${to} failed with status ${result.status}`
);
}
}
function walkFiles(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) walkFiles(full, out);
else out.push(full);
}
return out;
}
function substitute(content: string, args: Args): string {
return content
.replaceAll("{PLUGIN_NAME}", args.name)
.replaceAll("{PLUGIN_DESCRIPTION}", args.description);
}
function copyTemplates(destPkg: string, args: Args): void {
for (const entry of readdirSync(TEMPLATE_DIR)) {
const src = join(TEMPLATE_DIR, entry);
const destName = entry.endsWith(".template")
? entry.slice(0, -".template".length)
: entry;
const dest = join(destPkg, destName);
const content = readFileSync(src, "utf8");
writeFileSync(dest, substitute(content, args));
}
}
function moveSource(args: Args, destPkg: string): void {
const srcDir = join(SOURCE_PARENT, args.sourceDir);
if (!existsSync(srcDir)) {
throw new Error(`source dir not found: ${srcDir}`);
}
const destSrc = join(destPkg, "src");
for (const child of readdirSync(srcDir)) {
gitMv(join(srcDir, child), join(destSrc, child));
}
try {
rmdirSync(srcDir);
} catch {
// Non-fatal: if non-empty, leave for manual inspection.
}
}
const RELATIVE_IMPORT_REWRITES: Array<[RegExp, string]> = [
// `base/<anything>.js` → base barrel
[
/from "\.\.\/\.\.\/base\/[a-z0-9./-]+\.js"/g,
'from "@suaveplan/audio-fx-plugin-base/base"',
],
// `chain/<anything>.js` (including nested context/, event-bus/, etc.) → chain barrel
[
/from "\.\.\/\.\.\/chain\/[a-z0-9./-]+\.js"/g,
'from "@suaveplan/audio-fx-plugin-base/chain"',
],
// `registry/<anything>.js` → registry barrel
[
/from "\.\.\/\.\.\/registry\/[a-z0-9./-]+\.js"/g,
'from "@suaveplan/audio-fx-plugin-base/registry"',
],
// root-level error-codes
[
/from "\.\.\/\.\.\/error-codes\.js"/g,
'from "@suaveplan/audio-fx-plugin-base/error-codes"',
],
];
const CROSS_PLUGIN_RE = /from "\.\.\/[a-z][a-z0-9-]*\//;
function rewriteImports(destPkg: string, args: Args): void {
const srcDir = join(destPkg, "src");
for (const file of walkFiles(srcDir)) {
if (!file.endsWith(".ts") && !file.endsWith(".md")) continue;
let content = readFileSync(file, "utf8");
const before = content;
for (const [pattern, replacement] of RELATIVE_IMPORT_REWRITES) {
content = content.replace(pattern, replacement);
}
content = content.replaceAll(
`@suaveplan/audio-plugins-fx/${args.name}`,
`@suaveplan/audio-fx-plugin-${args.name}`
);
const lines = content.split("\n");
for (const line of lines) {
if (
CROSS_PLUGIN_RE.test(line) &&
!line.includes("audio-fx-plugin-base")
) {
throw new Error(
`Cross-plugin import detected in ${file}: ${line.trim()} — abort. ` +
"Fix the moved source manually, then re-run."
);
}
}
if (content !== before) writeFileSync(file, content);
}
}
function main(): void {
const args = parseArgs();
const destPkg = join(DEST_PARENT, `audio-fx-plugin-${args.name}`);
if (existsSync(destPkg)) {
throw new Error(`destination already exists: ${destPkg}`);
}
mkdirSync(join(destPkg, "src"), { recursive: true });
copyTemplates(destPkg, args);
moveSource(args, destPkg);
rewriteImports(destPkg, args);
console.log(
`scaffolded @suaveplan/audio-fx-plugin-${args.name} at ${destPkg}\n` +
`next: bunx turbo lint typecheck test --filter=@suaveplan/audio-fx-plugin-${args.name}`
);
}
main();