diff --git a/CLAUDE.md b/CLAUDE.md index e170879..ff7f2ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,12 @@ diff, prune, and validate all derive from it. generated output is never misread as source), and the root-skills plugin. - `src/render.ts` — `collectPluginFiles` (component dirs + static files, with `targets//` override resolution) and `resolveMcpServers`. +- `src/partials.ts` — `loadPartials`/`resolvePartials`: project-level + `{{> name}}` text-reuse, wired into `collectPluginFiles` and + `withRootFiles`. Thin wrapper around the real `mustache` library (view is + always `{}` — no config/env data is ever exposed; this is not a general + templating hook), plus one custom check `mustache` doesn't provide + (circular partial reference detection at load time). - `src/targets/registry.ts` — `targets: Record`, one file per target (`src/targets/.ts`). Everything that varies by target — default components, manifest/marketplace builders, output paths, diff --git a/README.md b/README.md index 8859cd7..740e1b4 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,31 @@ A source plugin that needs files at its emitted root beyond the component and st The map is destination (emitted plugin root relative) -> source (source plugin relative). Files are emitted verbatim at the plugin root, are tracked as managed output, and support target overrides: a `targets//` file wins for that host. A destination that collides with a component or static file is an error, and `pluginpack` does not build bundles — produce build output before running `pluginpack build` so the declared source paths exist. +## Partials + +Skills, agents, commands, and rules often need to repeat the same procedural prose — a consistent auth flow ("try OAuth first, fall back to a token") is a good example. Rather than copy-pasting it into every file, author it once under a `partials/` directory and reference it with a `{{> name}}` tag: + +```txt +partials/auth.md +skills/release-notes/SKILL.md -> contains {{> auth}} +skills/changelog/SKILL.md -> contains {{> auth}} +``` + +Point `source.partials` at that directory in `pluginpack.config.ts`: + +```ts +export default defineConfig({ + source: { partials: "partials" }, + // ... +}); +``` + +Partials are project-level (shared across every source plugin, not scoped to one), and may reference other partials — nested composition resolves in one pass, though a circular reference (A includes B includes A) is a build-time error. A `{{> name}}` tag with no matching partial renders as nothing rather than failing the build (see Known limitation below), and a tag alone on its own line — the common case — leaves no blank line behind when it resolves to nothing. + +Substitution runs on every `.md`/`.mdc`/`.markdown`/`.txt` file pluginpack emits — skills, agents, commands, rules, `additionalFiles`, and a target's `rootFiles` — via the real [`mustache`](https://github.com/janl/mustache.js) library. + +**Known limitation:** because substitution is real Mustache rendering, any _other_ `{{...}}`-looking text in the same file — documentation about Handlebars, Angular, Go templates, Jinja, or Mustache itself, or a curly-brace code sample — is also processed against an empty context and typically disappears. A skill that needs to show literal double-curly-brace syntax has to work around it, e.g. by splitting the braces across adjacent inline-code spans (`{{` + `}}`) rather than writing them as one contiguous run. + ## Other Shapes There are two reasonable alternatives when the single-repo shape is not enough: @@ -404,6 +429,7 @@ To publish a repo-root file (for example a README authored once in the source re | ------------ | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `plugins` | string | no | Directory to discover source plugins from. Defaults to `plugins`. | | `skills` | string | no | Repo-level skills directory; creates a root source plugin from sibling component dirs. | +| `partials` | string | no | Directory of reusable `{{> name}}` text fragments, shared across every source plugin (see **Partials**). | | `rootPlugin` | object | no | Metadata for that root skills plugin. Accepts all **`metadata`** fields plus `id`, `name`, `description`. `id` is the source-plugin name used in each target's `from` array. | **`metadata`** (and `source.rootPlugin`) diff --git a/package-lock.json b/package-lock.json index b38e0e5..7e91577 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "fast-glob": "^3.3.3", "gray-matter": "^4.0.3", "jiti": "^2.7.0", + "mustache": "^4.2.0", "zod": "^4.4.3" }, "bin": { @@ -21,6 +22,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@release-it-plugins/lerna-changelog": "^9.0.0", + "@types/mustache": "^4.2.6", "@types/node": "^26.0.1", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", @@ -3601,6 +3603,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mustache": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.6.tgz", + "integrity": "sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.0.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", @@ -8949,6 +8958,15 @@ "dev": true, "license": "MIT" }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/mute-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-4.0.0.tgz", diff --git a/package.json b/package.json index 763e499..03cdcdb 100644 --- a/package.json +++ b/package.json @@ -70,11 +70,13 @@ "fast-glob": "^3.3.3", "gray-matter": "^4.0.3", "jiti": "^2.7.0", + "mustache": "^4.2.0", "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^10.0.1", "@release-it-plugins/lerna-changelog": "^9.0.0", + "@types/mustache": "^4.2.6", "@types/node": "^26.0.1", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", diff --git a/src/config.ts b/src/config.ts index f0f703e..3f74ebd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,7 @@ import { createJiti } from "jiti"; import { z } from "zod"; import { componentDirs } from "./components.js"; import { exists } from "./fs.js"; +import { loadPartials } from "./partials.js"; import { configSchema, sourcePluginManifestSchema } from "./schema.js"; import { createFilesystemSourceProvider } from "./source.js"; import type { @@ -30,14 +31,35 @@ export async function loadConfig( const sourceRoot = path.resolve(rootDir, config.source?.plugins ?? "plugins"); const plugins = await discoverSourcePlugins(sourceRoot); await addRootSkillsPlugin(rootDir, config, plugins); + const partials = await loadPartialsIfConfigured(rootDir, config); return { ...projectConfig, sourceRoot, plugins, + partials, source: createFilesystemSourceProvider(plugins), }; } +/** + * Loads the project-level `partials/` map if `source.partials` is + * configured, shared across every source plugin (unlike components, which + * are scoped to one plugin's own directory) — see `src/partials.ts`. + */ +async function loadPartialsIfConfigured( + rootDir: string, + config: PluginpackConfig, +): Promise> { + if (!config.source?.partials) { + return new Map(); + } + const partialsDir = path.resolve(rootDir, config.source.partials); + if (!(await exists(partialsDir))) { + throw new Error(`Source partials directory is missing: ${partialsDir}`); + } + return loadPartials(partialsDir); +} + /** Loads and validates the config file itself, without discovering source plugins. */ export async function loadProjectConfig( cwd = process.cwd(), diff --git a/src/partials.ts b/src/partials.ts new file mode 100644 index 0000000..15cdf42 --- /dev/null +++ b/src/partials.ts @@ -0,0 +1,109 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import mustache from "mustache"; +import { toPosix, walkFiles } from "./fs.js"; +import type { FileValue } from "./types.js"; + +const TEXTUAL_EXTENSIONS = [".md", ".mdc", ".markdown", ".txt"]; + +/** A `{{> name}}` reference, used only to build the cycle-detection graph below — never to render. */ +const PARTIAL_REFERENCE = /\{\{>\s*([\w./-]+)\s*\}\}/g; + +/** + * Loads every file under `partialsDir` into a name -> content map, keyed by + * its posix-relative path with the extension stripped (e.g. `auth/oauth`). + * Partials may reference other partials (Mustache resolves nested partials + * natively), but not circularly — checked here, once, before anything ever + * calls into `Mustache.render`. + */ +export async function loadPartials( + partialsDir: string, +): Promise> { + const partials = new Map(); + for (const file of await walkFiles(partialsDir)) { + const relative = toPosix(path.relative(partialsDir, file)); + const key = relative.slice( + 0, + relative.length - path.extname(relative).length, + ); + if (partials.has(key)) { + throw new Error(`Duplicate partial name "${key}" (from "${relative}").`); + } + partials.set(key, await fs.readFile(file, "utf8")); + } + assertNoPartialCycles(partials); + return partials; +} + +/** + * Mustache.js has no protection against a circular partial reference (A + * includes B includes A) — its own docs just say "avoid infinite loops." + * This builds a dependency graph from each partial's own `{{> name}}` + * references (a cheap regex scan used only to find edges, never to render) + * and runs cycle detection over it, so a circular reference fails the build + * clearly instead of hanging `Mustache.render` later. + */ +function assertNoPartialCycles(partials: Map): void { + const references = (content: string): string[] => { + const names: string[] = []; + for (const match of content.matchAll(PARTIAL_REFERENCE)) { + names.push(match[1]); + } + return names; + }; + + const visiting = new Set(); + const resolved = new Set(); + + const visit = (name: string, trail: string[]): void => { + if (resolved.has(name)) { + return; + } + if (visiting.has(name)) { + throw new Error( + `Circular partial reference: ${[...trail, name].join(" -> ")}`, + ); + } + const content = partials.get(name); + if (content === undefined) { + return; + } + visiting.add(name); + for (const referenced of references(content)) { + visit(referenced, [...trail, name]); + } + visiting.delete(name); + resolved.add(name); + }; + + for (const name of partials.keys()) { + visit(name, []); + } +} + +/** + * Substitutes `{{> name}}` partial references in a textual file's content, + * via the real `mustache` library — an empty view (`{}`) is always passed, + * so no config/environment data is ever exposed to interpolation; only + * partials resolve to real content, everything else Mustache-shaped resolves + * to `""` per its own documented missing-key behavior (a known, documented + * limitation, not a bug — see README). + * + * Non-textual paths, and textual files with no `{{` at all, are returned + * completely untouched (the original `FileValue`, byte-identical) — avoids a + * lossy UTF-8 round-trip for files that don't use partials at all. + */ +export function resolvePartials( + relativePath: string, + value: FileValue, + partials: Map, +): FileValue { + if (!TEXTUAL_EXTENSIONS.includes(path.extname(relativePath))) { + return value; + } + const text = typeof value === "string" ? value : value.toString("utf8"); + if (!text.includes("{{")) { + return value; + } + return mustache.render(text, {}, Object.fromEntries(partials)); +} diff --git a/src/render.ts b/src/render.ts index 4d756c9..585c852 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,4 +1,5 @@ import { isComponentPath } from "./components.js"; +import { resolvePartials } from "./partials.js"; import type { FileValue, ResolvedProject, TargetName } from "./types.js"; /** @@ -25,7 +26,8 @@ export async function collectPluginFiles( if (!shouldEmitFile(relativePath, components)) { continue; } - setFile(files, relativePath, value, sourceId); + const resolved = resolvePartials(relativePath, value, project.partials); + setFile(files, relativePath, resolved, sourceId); } } return files; diff --git a/src/schema.ts b/src/schema.ts index 63116ba..492238c 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -41,6 +41,7 @@ const rootPluginSchema = metadataSchema.extend({ const sourceSchema = z.object({ plugins: z.string().optional(), skills: z.string().optional(), + partials: z.string().optional(), rootPlugin: rootPluginSchema.optional(), }); diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 962d63d..03f25cd 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -2,6 +2,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { collectPluginFiles, resolveMcpServers } from "../render.js"; import { isSafeRelativePath, json, toPosix } from "../fs.js"; +import { resolvePartials } from "../partials.js"; import { deepMerge, stripUndefined } from "./shared.js"; import { applyUpdateCheck, pluginAllowsUpdateCheck } from "../update-check.js"; import type { UpdateCheckFormat } from "../update-check.js"; @@ -269,7 +270,7 @@ export async function withRootFiles( `Target "${result.target}" rootFiles source "${source}" could not be read.`, ); } - files.set(destPath, contents); + files.set(destPath, resolvePartials(destPath, contents, project.partials)); } return artifact(result.target, result.outDir, files); } diff --git a/src/types.ts b/src/types.ts index 0ba42aa..4446028 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,6 +56,7 @@ export type ResolvedProject = { config: PluginpackConfig; sourceRoot: string; plugins: Map; + partials: Map; source: SourceProvider; }; diff --git a/tests/core.test.ts b/tests/core.test.ts index 9c9675a..c2f149e 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1998,6 +1998,500 @@ export default defineConfig({ // ...but the sibling keywords survived (shallow spread would have dropped them). expect(marketplace.metadata.keywords).toEqual(["a", "b"]); }); + + it("substitutes a standalone {{> name}} partial into a skill (happy path)", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "partials-plugins", + version: "1.0.0", + metadata: { description: "Partials", author: { name: "X" }, license: "MIT" }, + source: { partials: "partials" }, + targets: { + claude: { + outDir: "dist/claude", + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + partials: { + "auth.md": "Authenticate via OAuth, falling back to a token.\n", + }, + plugins: { + demo: { + skills: { + demo: { + "SKILL.md": `--- +name: demo +description: Demo skill. +--- + +# Demo + +{{> auth}} + +More instructions. +`, + }, + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "claude" }); + + const content = await readFile( + path.join(root, "dist/claude/plugins/demo/skills/demo/SKILL.md"), + "utf8", + ); + expect(content).toContain( + "Authenticate via OAuth, falling back to a token.", + ); + expect(content).not.toContain("{{>"); + expect(content).toBe(`--- +name: demo +description: Demo skill. +--- + +# Demo + +Authenticate via OAuth, falling back to a token. + +More instructions. +`); + }); + + it("renders a missing partial as empty, cleanly (no orphaned blank line)", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "partials-missing-plugins", + version: "1.0.0", + metadata: { description: "Partials", author: { name: "X" }, license: "MIT" }, + targets: { + claude: { + outDir: "dist/claude", + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + plugins: { + demo: { + skills: { + demo: { + "SKILL.md": `--- +name: demo +description: Demo skill. +--- + +Before +{{> nonexistent}} +After + +See: {{> nonexistent}} above. +`, + }, + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "claude" }); + + const content = await readFile( + path.join(root, "dist/claude/plugins/demo/skills/demo/SKILL.md"), + "utf8", + ); + expect(content).toBe(`--- +name: demo +description: Demo skill. +--- + +Before +After + +See: above. +`); + }); + + it("resolves nested partial composition end to end", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "partials-nested-plugins", + version: "1.0.0", + metadata: { description: "Partials", author: { name: "X" }, license: "MIT" }, + source: { partials: "partials" }, + targets: { + claude: { + outDir: "dist/claude", + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + partials: { + "auth.md": "Authenticate first.\n{{> footer}}", + "footer.md": "(see docs for details)", + }, + plugins: { + demo: { + skills: { + demo: { + "SKILL.md": skill("demo", "Demo skill.") + "\n{{> auth}}\n", + }, + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "claude" }); + + const content = await readFile( + path.join(root, "dist/claude/plugins/demo/skills/demo/SKILL.md"), + "utf8", + ); + expect(content).toContain("Authenticate first."); + expect(content).toContain("(see docs for details)"); + expect(content).not.toContain("{{>"); + }); + + it("rejects a circular partial reference at load time", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "partials-cycle-plugins", + version: "1.0.0", + source: { partials: "partials" }, + targets: { + claude: { + outDir: "dist/claude", + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + partials: { + "a.md": "{{> b}}", + "b.md": "{{> a}}", + }, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + // Nothing references "a" or "b" from any skill; the cycle must still be + // caught eagerly at load time, not lazily on first use. + await expect(loadConfig(root)).rejects.toThrow( + /Circular partial reference: (a -> b -> a|b -> a -> b)/, + ); + }); + + it("substitutes multiple partial references in one file, including a repeat", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "partials-multi-plugins", + version: "1.0.0", + source: { partials: "partials" }, + targets: { + claude: { + outDir: "dist/claude", + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + partials: { + "auth.md": "AUTH", + "footer.md": "FOOTER", + }, + plugins: { + demo: { + skills: { + demo: { + "SKILL.md": + skill("demo", "Demo skill.") + + "\n{{> auth}} and again {{> auth}}\n{{> footer}}\n", + }, + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "claude" }); + + const content = await readFile( + path.join(root, "dist/claude/plugins/demo/skills/demo/SKILL.md"), + "utf8", + ); + expect(content).toContain("AUTH and again AUTH"); + expect(content).toContain("FOOTER"); + }); + + it("shares one project-level partials map across independent source plugins", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "partials-cross-plugin", + version: "1.0.0", + source: { partials: "partials" }, + targets: { + claude: { + outDir: "dist/claude", + plugins: { + pluginA: { from: ["a"] }, + pluginB: { from: ["b"] } + } + } + } +}); +`, + partials: { + "auth.md": "Shared auth instructions.", + }, + plugins: { + a: { + skills: { + alpha: { + "SKILL.md": skill("alpha", "Alpha skill.") + "\n{{> auth}}\n", + }, + }, + }, + b: { + skills: { + beta: { + "SKILL.md": skill("beta", "Beta skill.") + "\n{{> auth}}\n", + }, + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "claude" }); + + const alpha = await readFile( + path.join(root, "dist/claude/plugins/pluginA/skills/alpha/SKILL.md"), + "utf8", + ); + const beta = await readFile( + path.join(root, "dist/claude/plugins/pluginB/skills/beta/SKILL.md"), + "utf8", + ); + expect(alpha).toContain("Shared auth instructions."); + expect(beta).toContain("Shared auth instructions."); + }); + + it("leaves output byte-identical when no source.partials is configured (no-op)", async () => { + const project = await fixture(); + const root = project.baseDir; + + const withoutPartials = await build({ cwd: root }); + await rm(path.join(root, "dist"), { recursive: true, force: true }); + const withPartialsFeatureUnused = await build({ cwd: root }); + + const serialize = (artifacts: Awaited>) => + artifacts + .map((a) => ({ + target: a.target, + files: [...a.files].map( + ([p, v]) => [p, Buffer.from(v).toString("base64")] as const, + ), + })) + .sort((x, y) => x.target.localeCompare(y.target)); + expect(serialize(withPartialsFeatureUnused)).toEqual( + serialize(withoutPartials), + ); + }); + + it("never corrupts a textual file with no {{ marker, even with non-UTF8 bytes", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "partials-byte-safety", + version: "1.0.0", + targets: { + claude: { + outDir: "dist/claude", + plugins: { demo: { from: ["demo"], components: ["rules"] } } + } + } +}); +`, + plugins: { + demo: { + rules: { "policy.txt": "placeholder" }, + }, + }, + }); + const root = project.baseDir; + const rawBytes = Buffer.from([0xff, 0xfe, 0x00, 0x41, 0x0a]); + await writeFile(path.join(root, "plugins/demo/rules/policy.txt"), rawBytes); + + await build({ cwd: root, target: "claude" }); + + const written = await readFile( + path.join(root, "dist/claude/plugins/demo/rules/policy.txt"), + ); + expect(written.equals(rawBytes)).toBe(true); + }); + + it("rejects two partial files that normalize to the same name", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "partials-duplicate", + version: "1.0.0", + source: { partials: "partials" }, + targets: { + claude: { + outDir: "dist/claude", + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + partials: { + "auth.md": "one", + "auth.txt": "two", + }, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await expect(loadConfig(root)).rejects.toThrow( + /Duplicate partial name "auth"/, + ); + }); + + it("errors clearly when source.partials is configured but the directory is missing", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "partials-missing-dir", + version: "1.0.0", + source: { partials: "partials" }, + targets: { + claude: { + outDir: "dist/claude", + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await expect(loadConfig(root)).rejects.toThrow( + /Source partials directory is missing/, + ); + }); + + it("substitutes a partial reference inside a target's rootFiles-declared file", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "partials-rootfiles", + version: "1.0.0", + source: { partials: "partials" }, + targets: { + claude: { + outDir: "dist/claude", + plugins: { demo: { from: ["demo"] } }, + rootFiles: { "README.md": "ROOT_README.md" } + } + } +}); +`, + partials: { + "auth.md": "Shared auth instructions.", + }, + "ROOT_README.md": "# Root docs\n\n{{> auth}}\n", + plugins: { + demo: { + skills: { demo: { "SKILL.md": skill("demo", "Demo skill.") } }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "claude" }); + + const readme = await readFile( + path.join(root, "dist/claude/README.md"), + "utf8", + ); + expect(readme).toContain("Shared auth instructions."); + expect(readme).not.toContain("{{>"); + }); + + it("renders unrelated {{...}}-looking text as empty (documented Mustache behavior, not a bug)", async () => { + const project = await fixtureProject({ + "pluginpack.config.ts": `import { defineConfig } from "${path.resolve("src/index.ts")}"; + +export default defineConfig({ + name: "partials-collision-doc", + version: "1.0.0", + targets: { + claude: { + outDir: "dist/claude", + plugins: { demo: { from: ["demo"] } } + } + } +}); +`, + plugins: { + demo: { + skills: { + demo: { + "SKILL.md": + skill("demo", "Demo skill.") + + "\nExample Handlebars tag: {{unrelated}}\n", + }, + }, + }, + }, + }); + const root = project.baseDir; + + await build({ cwd: root, target: "claude" }); + + const content = await readFile( + path.join(root, "dist/claude/plugins/demo/skills/demo/SKILL.md"), + "utf8", + ); + expect(content).toContain("Example Handlebars tag: \n"); + expect(content).not.toContain("{{unrelated}}"); + }); }); async function fixtureProject(files: DirJSON): Promise {