muddle/integrations/shadcn.ts
2026-10-23 08:53:07 -04:00

60 lines
2.6 KiB
TypeScript

import { mkdir, writeFile } from "node:fs/promises";
import { basename, extname, join, resolve } from "node:path";
import type { Integration, IntegrationContext } from "../contracts/index.ts";
import { cssToStcss, reactToSpug } from "../core/backport.ts";
export interface ShadcnIntegrationOptions {
/** Registry component names, for example `button`, `card`, or `dialog`. */
components: string[];
/** Registry origin or `{name}` URL template. Defaults to ShadCN's New York registry. */
registry?: string;
/** Destination relative to the Muddle project root. */
dir?: string;
/** Injectable for offline registries and tests. */
fetch?: (input: string) => Promise<Response>;
}
interface RegistryItem {
files?: { path: string; content: string; type?: string }[];
registryDependencies?: string[];
}
/**
* Pulls declaratively selected ShadCN React components and backports them
* into SPUG and STCSS during application setup.
*/
export function shadcnIntegration(options: ShadcnIntegrationOptions): Integration {
return {
name: "shadcn",
async setup(context: IntegrationContext) {
const fetcher = options.fetch ?? globalThis.fetch;
const destination = resolve(context.config.root, options.dir ?? "modules/app/views/components");
const styles = resolve(context.config.root, "styles/shadcn");
const seen = new Set<string>();
const pull = async (name: string): Promise<void> => {
if (seen.has(name)) return;
seen.add(name);
const base = options.registry ?? "https://ui.shadcn.com/r/styles/new-york/{name}.json";
const url = base.includes("{name}")
? base.replace("{name}", encodeURIComponent(name))
: `${base.replace(/\/$/, "")}/${encodeURIComponent(name)}.json`;
const response = await fetcher(url);
if (!response.ok) throw new Error(`shadcn registry request failed for ${name} with ${response.status}`);
const item = await response.json() as RegistryItem;
for (const dependency of item.registryDependencies ?? []) await pull(dependency.replace(/^@[^/]+\//, ""));
for (const file of item.files ?? []) {
const extension = extname(file.path).toLowerCase();
const stem = basename(file.path).replace(/\.(tsx|jsx|css)$/i, "");
if (extension === ".tsx" || extension === ".jsx") {
await mkdir(destination, { recursive: true });
await writeFile(join(destination, `${stem}.spug`), reactToSpug(file.content, file.path));
} else if (extension === ".css") {
await mkdir(styles, { recursive: true });
await writeFile(join(styles, `${stem}.stcss`), cssToStcss(file.content, file.path));
}
}
};
for (const component of options.components) await pull(component);
},
};
}