feat(generators): splicePluginRulesAt + splicePluginImportsAt + addBoundariesEntry + emitTemplateTree helpers
This commit is contained in:
@@ -11,6 +11,10 @@ import { join } from "node:path";
|
|||||||
import {
|
import {
|
||||||
assertOptionalPackageNotPresent,
|
assertOptionalPackageNotPresent,
|
||||||
addToTranspilePackages,
|
addToTranspilePackages,
|
||||||
|
splicePluginRulesAt,
|
||||||
|
splicePluginImportsAt,
|
||||||
|
addBoundariesEntry,
|
||||||
|
emitTemplateTree,
|
||||||
} from "./core-package-utils";
|
} from "./core-package-utils";
|
||||||
|
|
||||||
describe("assertOptionalPackageNotPresent", () => {
|
describe("assertOptionalPackageNotPresent", () => {
|
||||||
@@ -62,3 +66,83 @@ describe("addToTranspilePackages", () => {
|
|||||||
expect(result.match(/@repo\/core-realtime/g)?.length).toBe(1);
|
expect(result.match(/@repo\/core-realtime/g)?.length).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("splicePluginRulesAt", () => {
|
||||||
|
it("inserts rule block at the named anchor", () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
|
||||||
|
const path = join(tmp, "base.js");
|
||||||
|
writeFileSync(
|
||||||
|
path,
|
||||||
|
`before\n// <gen:realtime-rules>\nafter\n`,
|
||||||
|
);
|
||||||
|
splicePluginRulesAt(path, "realtime-rules", "INSERTED_BLOCK");
|
||||||
|
const result = readFileSync(path, "utf8");
|
||||||
|
expect(result).toContain("// <gen:realtime-rules>\nINSERTED_BLOCK\nafter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent — re-inserting same block at anchor is a no-op", () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
|
||||||
|
const path = join(tmp, "base.js");
|
||||||
|
writeFileSync(
|
||||||
|
path,
|
||||||
|
`// <gen:realtime-rules>\nINSERTED_BLOCK\nrest\n`,
|
||||||
|
);
|
||||||
|
splicePluginRulesAt(path, "realtime-rules", "INSERTED_BLOCK");
|
||||||
|
const result = readFileSync(path, "utf8");
|
||||||
|
expect(result.match(/INSERTED_BLOCK/g)?.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addBoundariesEntry", () => {
|
||||||
|
it("inserts entry before the packages/core-* wildcard", () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
|
||||||
|
const path = join(tmp, "base.js");
|
||||||
|
writeFileSync(
|
||||||
|
path,
|
||||||
|
`"boundaries/elements": [
|
||||||
|
{ type: "core-composition", pattern: "packages/core-cms" },
|
||||||
|
{ type: "core", pattern: "packages/core-*" },
|
||||||
|
{ type: "feature", pattern: "packages/!(core-*)" },
|
||||||
|
],`,
|
||||||
|
);
|
||||||
|
addBoundariesEntry(path, "packages/core-realtime", { mode: "folder" });
|
||||||
|
const result = readFileSync(path, "utf8");
|
||||||
|
// The new entry must appear BEFORE the packages/core-* wildcard
|
||||||
|
const newIdx = result.indexOf(`pattern: "packages/core-realtime"`);
|
||||||
|
const wildcardIdx = result.indexOf(`pattern: "packages/core-*"`);
|
||||||
|
expect(newIdx).toBeGreaterThan(0);
|
||||||
|
expect(newIdx).toBeLessThan(wildcardIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent — re-inserting same entry is a no-op", () => {
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), "core-pkg-"));
|
||||||
|
const path = join(tmp, "base.js");
|
||||||
|
writeFileSync(
|
||||||
|
path,
|
||||||
|
`"boundaries/elements": [
|
||||||
|
{ type: "core", pattern: "packages/core-realtime", mode: "folder" },
|
||||||
|
{ type: "core", pattern: "packages/core-*" },
|
||||||
|
],`,
|
||||||
|
);
|
||||||
|
addBoundariesEntry(path, "packages/core-realtime", { mode: "folder" });
|
||||||
|
const result = readFileSync(path, "utf8");
|
||||||
|
expect(result.match(/packages\/core-realtime/g)?.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("emitTemplateTree", () => {
|
||||||
|
it("produces an `add` plop action per .hbs file in the template directory", () => {
|
||||||
|
// emitTemplateTree reads from turbo/generators/templates/ — the test uses a
|
||||||
|
// temp template directory injected via the `templatesRoot` arg.
|
||||||
|
const tmpTemplates = mkdtempSync(join(tmpdir(), "tpl-"));
|
||||||
|
mkdirSync(join(tmpTemplates, "core-package", "demo", "src"), { recursive: true });
|
||||||
|
writeFileSync(join(tmpTemplates, "core-package", "demo", "package.json.hbs"), "{}");
|
||||||
|
writeFileSync(join(tmpTemplates, "core-package", "demo", "src", "index.ts.hbs"), "export {};");
|
||||||
|
const actions = emitTemplateTree("core-package/demo", "packages/demo", { templatesRoot: tmpTemplates });
|
||||||
|
expect(actions).toHaveLength(2);
|
||||||
|
expect(actions[0]!.type).toBe("add");
|
||||||
|
const paths = actions.map((a) => (a as { path: string }).path);
|
||||||
|
expect(paths).toContain("packages/demo/package.json");
|
||||||
|
expect(paths).toContain("packages/demo/src/index.ts");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join, relative, dirname } from "node:path";
|
||||||
|
import type { PlopTypes } from "@turbo/gen";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Throws if a core package directory already exists. Used as the first action
|
* Throws if a core package directory already exists. Used as the first action
|
||||||
@@ -42,3 +47,104 @@ export function addToTranspilePackages(
|
|||||||
);
|
);
|
||||||
writeFileSync(nextConfigPath, updated);
|
writeFileSync(nextConfigPath, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a code block immediately after a `// <gen:NAME>` anchor in a file.
|
||||||
|
* Idempotent: refuses to insert if the exact block already follows the anchor.
|
||||||
|
*/
|
||||||
|
export function splicePluginRulesAt(
|
||||||
|
filePath: string,
|
||||||
|
anchorName: string,
|
||||||
|
block: string,
|
||||||
|
): void {
|
||||||
|
const source = readFileSync(filePath, "utf8");
|
||||||
|
const anchor = `// <gen:${anchorName}>`;
|
||||||
|
const idx = source.indexOf(anchor);
|
||||||
|
if (idx === -1) {
|
||||||
|
throw new Error(`Anchor ${anchor} not found in ${filePath}`);
|
||||||
|
}
|
||||||
|
const after = source.slice(idx + anchor.length);
|
||||||
|
if (after.trimStart().startsWith(block.trim())) return; // idempotent
|
||||||
|
const updated =
|
||||||
|
source.slice(0, idx + anchor.length) + "\n" + block + after;
|
||||||
|
writeFileSync(filePath, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts an import line immediately after the matching anchor. Idempotent.
|
||||||
|
*/
|
||||||
|
export function splicePluginImportsAt(
|
||||||
|
filePath: string,
|
||||||
|
anchorName: string,
|
||||||
|
importLine: string,
|
||||||
|
): void {
|
||||||
|
splicePluginRulesAt(filePath, anchorName, importLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a `{ type, pattern, ...opts }` entry into the boundaries/elements
|
||||||
|
* array of `core-eslint/base.js`, placed immediately BEFORE the
|
||||||
|
* `packages/core-*` wildcard so its more-specific match wins. Idempotent.
|
||||||
|
*/
|
||||||
|
export function addBoundariesEntry(
|
||||||
|
baseJsPath: string,
|
||||||
|
packagePath: string,
|
||||||
|
opts: { mode?: "folder" } = {},
|
||||||
|
): void {
|
||||||
|
const source = readFileSync(baseJsPath, "utf8");
|
||||||
|
if (source.includes(`pattern: "${packagePath}"`)) return; // idempotent
|
||||||
|
const wildcardLine = source.match(
|
||||||
|
/(\s*\{\s*type:\s*"core",\s*pattern:\s*"packages\/core-\*"[^}]*\},)/,
|
||||||
|
);
|
||||||
|
if (!wildcardLine) {
|
||||||
|
throw new Error(`packages/core-* wildcard not found in ${baseJsPath}`);
|
||||||
|
}
|
||||||
|
const modeFragment = opts.mode ? `, mode: "${opts.mode}"` : "";
|
||||||
|
const newEntry = ` { type: "core", pattern: "${packagePath}"${modeFragment} },\n`;
|
||||||
|
const updated = source.replace(wildcardLine[0], `\n${newEntry}${wildcardLine[1]}`);
|
||||||
|
writeFileSync(baseJsPath, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks turbo/generators/templates/<srcPrefix>/ recursively. For each .hbs
|
||||||
|
* file, returns a plop `add` action that emits the file (without .hbs
|
||||||
|
* extension) at <destPrefix>/<relative-path>. The actions are sorted so
|
||||||
|
* directory creation is deterministic.
|
||||||
|
*/
|
||||||
|
export function emitTemplateTree(
|
||||||
|
srcPrefix: string,
|
||||||
|
destPrefix: string,
|
||||||
|
opts: { templatesRoot?: string } = {},
|
||||||
|
): PlopTypes.AddActionConfig[] {
|
||||||
|
const root =
|
||||||
|
opts.templatesRoot ?? join(__dirname, "..", "templates");
|
||||||
|
const srcRoot = join(root, srcPrefix);
|
||||||
|
const out: PlopTypes.AddActionConfig[] = [];
|
||||||
|
walkHbs(srcRoot, srcRoot, srcPrefix, destPrefix, out);
|
||||||
|
out.sort((a, b) => (a.path ?? "").localeCompare(b.path ?? ""));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkHbs(
|
||||||
|
topRoot: string,
|
||||||
|
dir: string,
|
||||||
|
srcPrefix: string,
|
||||||
|
destPrefix: string,
|
||||||
|
out: PlopTypes.AddActionConfig[],
|
||||||
|
): void {
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
const full = join(dir, name);
|
||||||
|
if (statSync(full).isDirectory()) {
|
||||||
|
walkHbs(topRoot, full, srcPrefix, destPrefix, out);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!name.endsWith(".hbs")) continue;
|
||||||
|
const rel = relative(topRoot, full).replace(/\.hbs$/, "");
|
||||||
|
out.push({
|
||||||
|
type: "add",
|
||||||
|
path: join(destPrefix, rel).replace(/\\/g, "/"),
|
||||||
|
templateFile: join("templates", srcPrefix, relative(topRoot, full)).replace(/\\/g, "/"),
|
||||||
|
} as PlopTypes.AddActionConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user