diff --git a/turbo/generators/lib/core-package-utils.test.ts b/turbo/generators/lib/core-package-utils.test.ts index a2eb0b2..f21f53c 100644 --- a/turbo/generators/lib/core-package-utils.test.ts +++ b/turbo/generators/lib/core-package-utils.test.ts @@ -11,6 +11,10 @@ import { join } from "node:path"; import { assertOptionalPackageNotPresent, addToTranspilePackages, + splicePluginRulesAt, + splicePluginImportsAt, + addBoundariesEntry, + emitTemplateTree, } from "./core-package-utils"; describe("assertOptionalPackageNotPresent", () => { @@ -62,3 +66,83 @@ describe("addToTranspilePackages", () => { 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// \nafter\n`, + ); + splicePluginRulesAt(path, "realtime-rules", "INSERTED_BLOCK"); + const result = readFileSync(path, "utf8"); + expect(result).toContain("// \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, + `// \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"); + }); +}); diff --git a/turbo/generators/lib/core-package-utils.ts b/turbo/generators/lib/core-package-utils.ts index bbca5ae..372b10f 100644 --- a/turbo/generators/lib/core-package-utils.ts +++ b/turbo/generators/lib/core-package-utils.ts @@ -1,5 +1,10 @@ -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"; +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 @@ -42,3 +47,104 @@ export function addToTranspilePackages( ); writeFileSync(nextConfigPath, updated); } + +/** + * Inserts a code block immediately after a `// ` 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 = `// `; + 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// recursively. For each .hbs + * file, returns a plop `add` action that emits the file (without .hbs + * extension) at /. 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); + } +} +