Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/` 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<TargetName, PluginTargetDefinition>`,
one file per target (`src/targets/<name>.ts`). Everything that varies by
target — default components, manifest/marketplace builders, output paths,
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<host>/<source>` 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:
Expand Down Expand Up @@ -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`)
Expand Down
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Map<string, string>> {
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(),
Expand Down
109 changes: 109 additions & 0 deletions src/partials.ts
Original file line number Diff line number Diff line change
@@ -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<Map<string, string>> {
const partials = new Map<string, string>();
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<string, string>): 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<string>();
const resolved = new Set<string>();

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<string, string>,
): 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));
}
4 changes: 3 additions & 1 deletion src/render.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isComponentPath } from "./components.js";
import { resolvePartials } from "./partials.js";
import type { FileValue, ResolvedProject, TargetName } from "./types.js";

/**
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand Down
3 changes: 2 additions & 1 deletion src/targets/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export type ResolvedProject = {
config: PluginpackConfig;
sourceRoot: string;
plugins: Map<string, SourcePlugin>;
partials: Map<string, string>;
source: SourceProvider;
};

Expand Down
Loading