mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
197 lines
8.3 KiB
TypeScript
197 lines
8.3 KiB
TypeScript
/**
|
|
* Static registry build. Scans `registry/modules/*`, imports each module's
|
|
* default export, writes:
|
|
* - <out>/index.json — the main file (per-module
|
|
* metadata, each carrying `path`)
|
|
* - <out>/<category>-<id>.json — per-module full manifests
|
|
*
|
|
* Output is flat (no `modules/` subdir) so it maps 1:1 onto the Blob store and
|
|
* the path-transparent `stanza.tools/registry/<file>.json` rewrites. The output
|
|
* base defaults to `<repoRoot>/dist`. Pass a positional arg to redirect — e.g.
|
|
* the web app's `compile-registry` task points it at `apps/web/.registry`.
|
|
*
|
|
* A standalone build tool (not part of any package): the web's compile-registry
|
|
* task, the Blob upload in CI, and the CLI test harness all invoke it via
|
|
* `jiti scripts/compile-registry.ts [outDir]`. `compileRegistry()` is also
|
|
* exported for any in-process caller.
|
|
*/
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { CATEGORIES, type Logo, type Module, type RegistryIndex } from "@withstanza/schema";
|
|
import { optimize } from "svgo";
|
|
|
|
/**
|
|
* Build the static registry into `<outDir>`. `modulesDir` defaults to the
|
|
* repo's `registry/modules`; tests can point it elsewhere. Returns the final
|
|
* module count.
|
|
*/
|
|
export async function compileRegistry(opts: {
|
|
modulesDir?: string;
|
|
outDir: string;
|
|
}): Promise<{ count: number }> {
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = findRepoRoot(here);
|
|
const modulesDir = opts.modulesDir ?? path.join(repoRoot, "registry", "modules");
|
|
|
|
// Clear stale `*.json` (including a previous `index.json`) so a renamed or
|
|
// removed module's file doesn't linger and ghost-serve. Leaves any other dir
|
|
// contents alone; the out dir is recreated if absent.
|
|
fs.mkdirSync(opts.outDir, { recursive: true });
|
|
for (const f of fs.readdirSync(opts.outDir)) {
|
|
if (f.endsWith(".json")) fs.rmSync(path.join(opts.outDir, f));
|
|
}
|
|
|
|
const dirs = fs
|
|
.readdirSync(modulesDir, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory())
|
|
.map((d) => d.name);
|
|
|
|
const metadata = [];
|
|
for (const dir of dirs) {
|
|
const entry = path.join(modulesDir, dir, "module.ts");
|
|
const imported: { default: Module } = await import(entry);
|
|
const mod = imported.default;
|
|
if (!mod) throw new Error(`Module ${dir} has no default export.`);
|
|
|
|
// Inline each template's file contents. The per-module JSON is the
|
|
// payload the CLI fetches from the CDN, so it has to be self-contained —
|
|
// no follow-up requests to retrieve template files. Local dev still works
|
|
// because the runner falls back to disk when `content` is absent.
|
|
const templatesDir = path.join(modulesDir, dir, "templates");
|
|
const logo = readLogo(path.join(modulesDir, dir), dir);
|
|
const readme = readReadme(path.join(modulesDir, dir));
|
|
// The module's `package.json` version is the single source of truth — it's
|
|
// what `stanza add` pins into `stanza.json` and the key the Blob archive
|
|
// pins each immutable per-module file under. `module.ts`'s own `version`
|
|
// is vestigial (the two had already drifted), so we override it here.
|
|
const version = readPackageVersion(path.join(modulesDir, dir));
|
|
const inlined: Module = {
|
|
...mod,
|
|
version,
|
|
...(logo ? { logo } : {}),
|
|
...(readme ? { readme } : {}),
|
|
adapters: mod.adapters.map((adapter) => ({
|
|
...adapter,
|
|
templates: adapter.templates?.map((tpl) => ({
|
|
...tpl,
|
|
content: fs.readFileSync(path.join(templatesDir, tpl.src), "utf8"),
|
|
})),
|
|
})),
|
|
};
|
|
|
|
// Per-module files live flat at `<category>-<id>.json`; the index records
|
|
// each one's relative `path` so the loader never has to infer a filename.
|
|
// (The physical layout is the build's choice — the contract is the explicit
|
|
// `path`, resolved relative to the index URL.)
|
|
const modulePath = `${mod.category}-${mod.id}.json`;
|
|
fs.writeFileSync(path.join(opts.outDir, modulePath), JSON.stringify(inlined, null, 2));
|
|
|
|
// The index keeps lightweight metadata — no template `content`, no
|
|
// per-adapter payloads — but it DOES carry top-level fields like `logo`
|
|
// so the wizard / web builder can render module cards without fetching
|
|
// the full per-module JSON, plus the `path` the loader resolves.
|
|
metadata.push({
|
|
...inlined,
|
|
adapters: inlined.adapters.map((a) => ({ key: a.key, match: a.match })),
|
|
path: modulePath,
|
|
});
|
|
}
|
|
|
|
const index: RegistryIndex = {
|
|
generatedAt: new Date().toISOString(),
|
|
schemaVersion: 2,
|
|
categories: [...CATEGORIES],
|
|
modules: metadata,
|
|
};
|
|
|
|
fs.writeFileSync(path.join(opts.outDir, "index.json"), JSON.stringify(index, null, 2));
|
|
|
|
return { count: metadata.length };
|
|
}
|
|
|
|
// Direct invocation: `jiti scripts/compile-registry.ts [outDir]`. Skipped when
|
|
// imported (e.g. by test harnesses), so importing has no side effect.
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const repoRoot = findRepoRoot(path.dirname(fileURLToPath(import.meta.url)));
|
|
const outDir = process.argv[2] ?? path.join(repoRoot, "dist");
|
|
const { count } = await compileRegistry({ outDir });
|
|
console.log(`Wrote ${count} modules to ${outDir}`);
|
|
}
|
|
|
|
/**
|
|
* Convention: a module ships a logo by dropping either `logo.svg` (a single
|
|
* theme-agnostic SVG) or `logo-light.svg` + `logo-dark.svg` (a theme pair)
|
|
* in its directory. We read whichever is present and return the inlined
|
|
* markup; if neither exists the module just renders without one.
|
|
*
|
|
* Each SVG is optimized + namespaced through SVGO. The `prefixIds` plugin
|
|
* scopes every `id` (and every `url(#…)` / `href="#…"` / `xlink:href="#…"`
|
|
* reference) by the module slug, so when many logos share the document
|
|
* (slot grids, search results), gradients and clip-paths from one logo
|
|
* never bleed into another via collisions like `id="a"`.
|
|
*/
|
|
function readLogo(moduleDir: string, slug: string): Logo | undefined {
|
|
const light = path.join(moduleDir, "logo-light.svg");
|
|
const dark = path.join(moduleDir, "logo-dark.svg");
|
|
if (fs.existsSync(light) && fs.existsSync(dark)) {
|
|
return {
|
|
light: optimizeLogo(fs.readFileSync(light, "utf8"), `${slug}-light`),
|
|
dark: optimizeLogo(fs.readFileSync(dark, "utf8"), `${slug}-dark`),
|
|
};
|
|
}
|
|
const single = path.join(moduleDir, "logo.svg");
|
|
if (fs.existsSync(single)) return optimizeLogo(fs.readFileSync(single, "utf8"), slug);
|
|
return undefined;
|
|
}
|
|
|
|
function optimizeLogo(svg: string, prefix: string): string {
|
|
return optimize(svg, {
|
|
multipass: true,
|
|
plugins: [
|
|
"preset-default",
|
|
{ name: "prefixIds", params: { prefix, delim: "-" } },
|
|
// Strip every `on*` event handler so we can render via dangerouslySetInnerHTML
|
|
// without smuggling JS through a hostile (third-party) logo.
|
|
{ name: "removeAttrs", params: { attrs: "on[a-zA-Z]+" } },
|
|
],
|
|
}).data;
|
|
}
|
|
|
|
/**
|
|
* Convention: a module ships its README contribution by dropping `readme.md`
|
|
* in its directory. We inline the contents into the module manifest so HTTP-
|
|
* loaded modules stay self-contained. Absent → undefined; `synthesizeReadme`
|
|
* falls back to the module's `description` for those.
|
|
*/
|
|
function readReadme(moduleDir: string): string | undefined {
|
|
const file = path.join(moduleDir, "readme.md");
|
|
if (fs.existsSync(file)) return fs.readFileSync(file, "utf8");
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Each module dir is a private workspace package; its `package.json` version is
|
|
* the source of truth for the module's published version (pinned into
|
|
* `stanza.json` and used as the Blob archive key). Throws if missing so a new
|
|
* module can't ship without a version.
|
|
*/
|
|
function readPackageVersion(moduleDir: string): string {
|
|
const file = path.join(moduleDir, "package.json");
|
|
const pkg: { version?: unknown } = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
if (typeof pkg.version !== "string" || pkg.version.length === 0) {
|
|
throw new Error(`Module ${moduleDir} has no \`version\` in package.json.`);
|
|
}
|
|
return pkg.version;
|
|
}
|
|
|
|
function findRepoRoot(start: string): string {
|
|
let dir = start;
|
|
for (let i = 0; i < 8; i++) {
|
|
if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
|
|
dir = path.dirname(dir);
|
|
}
|
|
throw new Error("Could not locate repo root from " + start);
|
|
}
|