@zenginui/registry 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -0
- package/dist/brand.d.ts +61 -0
- package/dist/brand.js +292 -0
- package/dist/build.d.ts +14 -0
- package/dist/build.js +344 -0
- package/dist/color.d.ts +24 -0
- package/dist/color.js +88 -0
- package/dist/create.d.ts +55 -0
- package/dist/create.js +437 -0
- package/dist/fonts.d.ts +42 -0
- package/dist/fonts.js +132 -0
- package/dist/html.d.ts +16 -0
- package/dist/html.js +63 -0
- package/dist/icons.d.ts +43 -0
- package/dist/icons.js +197 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/install.d.ts +34 -0
- package/dist/install.js +106 -0
- package/dist/load.d.ts +11 -0
- package/dist/load.js +70 -0
- package/dist/resolve.d.ts +7 -0
- package/dist/resolve.js +31 -0
- package/dist/schema.d.ts +71 -0
- package/dist/schema.js +13 -0
- package/dist/theme.d.ts +26 -0
- package/dist/theme.js +35 -0
- package/dist/tokens.d.ts +14 -0
- package/dist/tokens.js +44 -0
- package/dist/upgrade.d.ts +60 -0
- package/dist/upgrade.js +171 -0
- package/package.json +53 -0
package/dist/create.js
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { createEngine, loadConfigFile, readProjectFiles, resolveConfig } from "@zenginui/engine";
|
|
4
|
+
import { installItems, STYLES_INDEX_HEAD } from "./install.js";
|
|
5
|
+
import { resolveItems } from "./resolve.js";
|
|
6
|
+
import { LAYOUT } from "./schema.js";
|
|
7
|
+
import { applyTheme } from "./theme.js";
|
|
8
|
+
import { writeTokensCss } from "./tokens.js";
|
|
9
|
+
/** Versions pinned into a generated package.json. One place to bump. */
|
|
10
|
+
export const VERSIONS = {
|
|
11
|
+
react: "^19.3.0",
|
|
12
|
+
"react-dom": "^19.3.0",
|
|
13
|
+
"@types/react": "^19.3.0",
|
|
14
|
+
"@types/react-dom": "^19.3.0",
|
|
15
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
16
|
+
typescript: "^5.9.2",
|
|
17
|
+
vite: "^8.3.0",
|
|
18
|
+
next: "^16.1.0",
|
|
19
|
+
storybook: "^10.6.0",
|
|
20
|
+
"@storybook/react-vite": "^10.6.0",
|
|
21
|
+
"@storybook/addon-docs": "^10.6.0",
|
|
22
|
+
"@storybook/addon-a11y": "^10.6.0",
|
|
23
|
+
zengin: "^0.1.0",
|
|
24
|
+
};
|
|
25
|
+
export async function createProject(opts) {
|
|
26
|
+
const dir = resolve(opts.dir);
|
|
27
|
+
const name = opts.name ?? basename(dir);
|
|
28
|
+
const template = opts.template ?? "blank";
|
|
29
|
+
const storybook = opts.storybook ?? true;
|
|
30
|
+
const framework = opts.framework ?? "vite";
|
|
31
|
+
if (existsSync(dir) && readdirSync(dir).length > 0)
|
|
32
|
+
throw new Error(`${dir} exists and is not empty.`);
|
|
33
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(name))
|
|
34
|
+
throw new Error(`"${name}" is not a valid package name. Use lowercase letters, digits, dots, dashes.`);
|
|
35
|
+
const index = await opts.source.index();
|
|
36
|
+
const version = index.version;
|
|
37
|
+
const templateItem = index.items.find((i) => i.name === template && i.type === "template");
|
|
38
|
+
if (!templateItem) {
|
|
39
|
+
const templates = index.items.filter((i) => i.type === "template").map((i) => i.name);
|
|
40
|
+
throw new Error(`No template "${template}". Templates: ${templates.join(", ")}.`);
|
|
41
|
+
}
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
const write = (rel, content) => {
|
|
44
|
+
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|
|
45
|
+
writeFileSync(join(dir, rel), content);
|
|
46
|
+
};
|
|
47
|
+
// Base files first; the template may overwrite any of them (its own main.tsx, brand.css, index.html).
|
|
48
|
+
if (framework === "vite") {
|
|
49
|
+
write("index.html", INDEX_HTML(name));
|
|
50
|
+
write("src/main.tsx", MAIN_TSX);
|
|
51
|
+
write("vite.config.ts", VITE_CONFIG);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
write("next.config.ts", NEXT_CONFIG);
|
|
55
|
+
write("src/app/page.tsx", NEXT_PAGE);
|
|
56
|
+
}
|
|
57
|
+
write("src/theme/brand.css", BRAND_CSS);
|
|
58
|
+
write(LAYOUT.stylesIndex, STYLES_INDEX_HEAD);
|
|
59
|
+
write("tsconfig.json", framework === "vite" ? TSCONFIG(storybook) : TSCONFIG_NEXT(storybook));
|
|
60
|
+
write(".gitignore", framework === "vite" ? GITIGNORE : GITIGNORE + ".next/\nnext-env.d.ts\n");
|
|
61
|
+
write("zengin.config.yaml", ZENGIN_CONFIG(version));
|
|
62
|
+
write(".mcp.json", MCP_JSON);
|
|
63
|
+
write(".claude/settings.json", CLAUDE_SETTINGS);
|
|
64
|
+
if (storybook) {
|
|
65
|
+
write(".storybook/main.ts", STORYBOOK_MAIN);
|
|
66
|
+
write(".storybook/preview.tsx", STORYBOOK_PREVIEW);
|
|
67
|
+
write(`${LAYOUT.storiesDir}/manifest.ts`, STORIES_MANIFEST);
|
|
68
|
+
}
|
|
69
|
+
const resolved0 = await resolveItems(opts.source, [template]);
|
|
70
|
+
// On Next the template's entry point and page become the root layout and the page: the same imports,
|
|
71
|
+
// the same title and fonts link, the App mounted client-side so the SPA-style templates run unchanged.
|
|
72
|
+
const items = framework === "next" ? resolved0.map((i) => (i.type === "template" ? { ...i, files: i.files.filter((f) => f.path !== "index.html" && f.path !== "src/main.tsx") } : i)) : resolved0;
|
|
73
|
+
const install = installItems({ projectDir: dir, items: storybook ? items : items.map((i) => ({ ...i, files: i.files.filter((f) => f.kind !== "story") })), version, force: true });
|
|
74
|
+
if (framework === "next") {
|
|
75
|
+
const tpl = resolved0.find((i) => i.type === "template");
|
|
76
|
+
const html = tpl?.files.find((f) => f.path === "index.html")?.content ?? INDEX_HTML(name);
|
|
77
|
+
const main = tpl?.files.find((f) => f.path === "src/main.tsx")?.content ?? MAIN_TSX;
|
|
78
|
+
write("src/app/layout.tsx", NEXT_LAYOUT({ name, html, main }));
|
|
79
|
+
}
|
|
80
|
+
write("package.json", packageJson({ name, install, storybook, local: opts.local, framework, mock: items.some((i) => i.type === "template" && i.files.some((f) => f.path === "mock.json")) }));
|
|
81
|
+
write("README.md", README(name, template, install.components, framework));
|
|
82
|
+
if (opts.theme)
|
|
83
|
+
await applyTheme({ projectDir: dir, name: opts.theme, source: opts.source });
|
|
84
|
+
const tokens = writeTokensCss(join(dir, LAYOUT.definitionsDir), join(dir, "src/styles/generated/tokens.css"));
|
|
85
|
+
// The engine on the result. A fresh project must be clean; anything else is a registry defect.
|
|
86
|
+
const { config, dir: projectDir } = loadConfigFile(join(dir, "zengin.config.yaml"));
|
|
87
|
+
const resolved = resolveConfig(config, projectDir);
|
|
88
|
+
const engine = await createEngine(resolved);
|
|
89
|
+
const files = readProjectFiles(projectDir, resolved.scope.include, resolved.scope.exclude);
|
|
90
|
+
const violations = engine.check(files).length;
|
|
91
|
+
return { dir, name, template, framework, version, install, tokens, violations };
|
|
92
|
+
}
|
|
93
|
+
function packageJson(opts) {
|
|
94
|
+
const next = opts.framework === "next";
|
|
95
|
+
// `link:` symlinks the checkout's package and uses its own node_modules, so workspace deps resolve. pnpm honors it; npm needs the release.
|
|
96
|
+
const z = (pkg) => (opts.local ? `link:${resolve(opts.local, "packages", pkg).replace(/\\/g, "/")}` : VERSIONS.zengin);
|
|
97
|
+
const dependencies = sortKeys({ react: VERSIONS.react, "react-dom": VERSIONS["react-dom"], ...(next ? { next: VERSIONS.next } : {}), ...opts.install.dependencies });
|
|
98
|
+
const devDependencies = sortKeys({
|
|
99
|
+
"@types/react": VERSIONS["@types/react"],
|
|
100
|
+
"@types/react-dom": VERSIONS["@types/react-dom"],
|
|
101
|
+
...(!next || opts.storybook ? { "@vitejs/plugin-react": VERSIONS["@vitejs/plugin-react"] } : {}),
|
|
102
|
+
"@zenginui/cli": z("cli"),
|
|
103
|
+
"@zenginui/hook": z("hook"),
|
|
104
|
+
"@zenginui/mcp": z("mcp"),
|
|
105
|
+
typescript: VERSIONS.typescript,
|
|
106
|
+
...(!next || opts.storybook ? { vite: VERSIONS.vite } : {}),
|
|
107
|
+
...(opts.storybook
|
|
108
|
+
? {
|
|
109
|
+
storybook: VERSIONS.storybook,
|
|
110
|
+
"@storybook/react-vite": VERSIONS["@storybook/react-vite"],
|
|
111
|
+
"@storybook/addon-docs": VERSIONS["@storybook/addon-docs"],
|
|
112
|
+
"@storybook/addon-a11y": VERSIONS["@storybook/addon-a11y"],
|
|
113
|
+
}
|
|
114
|
+
: {}),
|
|
115
|
+
...opts.install.devDependencies,
|
|
116
|
+
});
|
|
117
|
+
const scripts = {
|
|
118
|
+
dev: next ? "zengin tokens && next dev" : "zengin tokens && vite",
|
|
119
|
+
build: next ? "zengin tokens && next build" : "zengin tokens && tsc -p tsconfig.json --noEmit && vite build",
|
|
120
|
+
...(next ? { start: "next start" } : { preview: "vite preview" }),
|
|
121
|
+
tokens: "zengin tokens",
|
|
122
|
+
check: "zengin check",
|
|
123
|
+
add: "zengin add",
|
|
124
|
+
...(opts.mock ? { mock: "zengin mock --schema mock.json" } : {}),
|
|
125
|
+
...(opts.storybook ? { storybook: "zengin tokens && storybook dev -p 6006 --no-open", "build-storybook": "zengin tokens && storybook build" } : {}),
|
|
126
|
+
};
|
|
127
|
+
return JSON.stringify({ name: opts.name, private: true, version: "0.1.0", type: "module", scripts, dependencies, devDependencies }, null, 2) + "\n";
|
|
128
|
+
}
|
|
129
|
+
function sortKeys(o) {
|
|
130
|
+
return Object.fromEntries(Object.entries(o).sort(([a], [b]) => a.localeCompare(b)));
|
|
131
|
+
}
|
|
132
|
+
function basename(p) {
|
|
133
|
+
return p.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? p;
|
|
134
|
+
}
|
|
135
|
+
const INDEX_HTML = (name) => `<!doctype html>
|
|
136
|
+
<html lang="en">
|
|
137
|
+
<head>
|
|
138
|
+
<meta charset="UTF-8" />
|
|
139
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
140
|
+
<title>${name}</title>
|
|
141
|
+
</head>
|
|
142
|
+
<body>
|
|
143
|
+
<div id="root"></div>
|
|
144
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
145
|
+
</body>
|
|
146
|
+
</html>
|
|
147
|
+
`;
|
|
148
|
+
const MAIN_TSX = `import "./styles/index.css";
|
|
149
|
+
import "./theme/brand.css";
|
|
150
|
+
import { StrictMode } from "react";
|
|
151
|
+
import { createRoot } from "react-dom/client";
|
|
152
|
+
import { App } from "./App";
|
|
153
|
+
|
|
154
|
+
createRoot(document.getElementById("root")!).render(
|
|
155
|
+
<StrictMode>
|
|
156
|
+
<App />
|
|
157
|
+
</StrictMode>,
|
|
158
|
+
);
|
|
159
|
+
`;
|
|
160
|
+
const BRAND_CSS = `/*
|
|
161
|
+
* Your brand, as token overrides. This is the one file in the project that may hold literals
|
|
162
|
+
* (scope.foundations in zengin.config.yaml). Redefine tokens the system already has and every
|
|
163
|
+
* component wears the result; there is nothing to change in src/components/ui.
|
|
164
|
+
*
|
|
165
|
+
* The full token list is zengin/tokens.json. Run \`zengin tokens\` (dev and build do it for you)
|
|
166
|
+
* to regenerate src/styles/generated/tokens.css after editing the JSON.
|
|
167
|
+
*/
|
|
168
|
+
|
|
169
|
+
:root,
|
|
170
|
+
[data-theme="light"] {
|
|
171
|
+
/* --color-primary: #1B3FE4; */
|
|
172
|
+
/* --font-display: "Archivo", sans-serif; */
|
|
173
|
+
/* --radius-md: 0px; */
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
[data-theme="dark"] {
|
|
177
|
+
/* --color-primary: #7B90FF; */
|
|
178
|
+
}
|
|
179
|
+
`;
|
|
180
|
+
const VITE_CONFIG = `import react from "@vitejs/plugin-react";
|
|
181
|
+
import { defineConfig } from "vite";
|
|
182
|
+
|
|
183
|
+
// "@" is src, matching tsconfig paths. A root-relative alias needs no Node imports, so the config typechecks without @types/node.
|
|
184
|
+
export default defineConfig({
|
|
185
|
+
plugins: [react()],
|
|
186
|
+
resolve: { alias: { "@": "/src" } },
|
|
187
|
+
});
|
|
188
|
+
`;
|
|
189
|
+
const TSCONFIG = (storybook) => JSON.stringify({
|
|
190
|
+
compilerOptions: {
|
|
191
|
+
target: "ES2022",
|
|
192
|
+
module: "ESNext",
|
|
193
|
+
moduleResolution: "Bundler",
|
|
194
|
+
lib: ["ES2022", "DOM", "DOM.Iterable"],
|
|
195
|
+
jsx: "react-jsx",
|
|
196
|
+
strict: true,
|
|
197
|
+
noUncheckedIndexedAccess: true,
|
|
198
|
+
isolatedModules: true,
|
|
199
|
+
skipLibCheck: true,
|
|
200
|
+
resolveJsonModule: true,
|
|
201
|
+
noEmit: true,
|
|
202
|
+
types: ["vite/client"],
|
|
203
|
+
baseUrl: ".",
|
|
204
|
+
paths: { "@/*": ["./src/*"] },
|
|
205
|
+
},
|
|
206
|
+
include: ["src", "vite.config.ts", ...(storybook ? ["stories", ".storybook"] : [])],
|
|
207
|
+
}, null, 2) + "\n";
|
|
208
|
+
const GITIGNORE = `node_modules/
|
|
209
|
+
dist/
|
|
210
|
+
storybook-static/
|
|
211
|
+
# Built from zengin/tokens*.json by \`zengin tokens\`
|
|
212
|
+
src/styles/generated/
|
|
213
|
+
`;
|
|
214
|
+
const ZENGIN_CONFIG = (version) => `# The components in src/components/ui are this project's design system. The definitions the engine
|
|
215
|
+
# enforces against live in zengin/. Every rule is on; relax one here rather than around it.
|
|
216
|
+
system:
|
|
217
|
+
package: "${LAYOUT.alias}"
|
|
218
|
+
version: "${version}" # the Zengin UI version the components were copied from
|
|
219
|
+
definitions: ./${LAYOUT.definitionsDir}
|
|
220
|
+
|
|
221
|
+
scope:
|
|
222
|
+
include: ["src/**/*.{ts,tsx,css}"]
|
|
223
|
+
foundations: ["src/theme/**", "src/styles/**"] # literals live here and nowhere else
|
|
224
|
+
ownership: ["${LAYOUT.componentsDir}/**", "${LAYOUT.libDir}/**"] # yours; the foundation rules still apply inside
|
|
225
|
+
|
|
226
|
+
rules:
|
|
227
|
+
color-literal: { severity: error, allow: semantic }
|
|
228
|
+
spacing-literal: error
|
|
229
|
+
token-reference: error
|
|
230
|
+
unknown-prop: error
|
|
231
|
+
unknown-prop-value: error
|
|
232
|
+
classname-policy: error
|
|
233
|
+
component-substitution: error
|
|
234
|
+
`;
|
|
235
|
+
const MCP_JSON = `{
|
|
236
|
+
"mcpServers": {
|
|
237
|
+
"zengin": {
|
|
238
|
+
"command": "zengin-mcp",
|
|
239
|
+
"env": { "ZENGIN_CONFIG": "zengin.config.yaml" }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
`;
|
|
244
|
+
const CLAUDE_SETTINGS = `{
|
|
245
|
+
"hooks": {
|
|
246
|
+
"PostToolUse": [
|
|
247
|
+
{
|
|
248
|
+
"matcher": "Write|Edit|MultiEdit",
|
|
249
|
+
"hooks": [{ "type": "command", "command": "zengin-hook", "timeout": 30, "statusMessage": "Checking against the design system..." }]
|
|
250
|
+
}
|
|
251
|
+
]
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
`;
|
|
255
|
+
const STORYBOOK_MAIN = `import type { StorybookConfig } from "@storybook/react-vite";
|
|
256
|
+
|
|
257
|
+
const config: StorybookConfig = {
|
|
258
|
+
framework: "@storybook/react-vite",
|
|
259
|
+
stories: ["../stories/**/*.stories.tsx"],
|
|
260
|
+
addons: ["@storybook/addon-docs", "@storybook/addon-a11y"],
|
|
261
|
+
core: { disableTelemetry: true },
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export default config;
|
|
265
|
+
`;
|
|
266
|
+
const STORYBOOK_PREVIEW = `import type { Decorator, Preview } from "@storybook/react-vite";
|
|
267
|
+
import "../src/styles/index.css";
|
|
268
|
+
import "../src/theme/brand.css";
|
|
269
|
+
|
|
270
|
+
/** Every story renders inside a themed surface. The toolbar switches data-theme, as a consumer would. */
|
|
271
|
+
const withTheme: Decorator = (Story, context) => {
|
|
272
|
+
const theme = (context.globals["theme"] as string) ?? "light";
|
|
273
|
+
return (
|
|
274
|
+
<div data-theme={theme} style={{ minHeight: "100%", padding: "var(--spacing-6)", background: "var(--color-surface)", color: "var(--color-text)", fontFamily: "var(--font-sans)" }}>
|
|
275
|
+
<Story />
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const preview: Preview = {
|
|
281
|
+
globalTypes: {
|
|
282
|
+
theme: {
|
|
283
|
+
description: "Theme",
|
|
284
|
+
toolbar: { title: "Theme", icon: "mirror", items: [{ value: "light", title: "Light" }, { value: "dark", title: "Dark" }], dynamicTitle: true },
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
initialGlobals: { theme: "light" },
|
|
288
|
+
decorators: [withTheme],
|
|
289
|
+
parameters: { backgrounds: { disable: true }, controls: { expanded: true }, a11y: { test: "error" } },
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export default preview;
|
|
293
|
+
`;
|
|
294
|
+
const STORIES_MANIFEST = `import manifests from "../${LAYOUT.definitionsDir}/components.json";
|
|
295
|
+
|
|
296
|
+
/** The manifest the engine enforces against. Stories read it, so controls and variant matrices cannot drift from it. */
|
|
297
|
+
interface Prop {
|
|
298
|
+
type: string;
|
|
299
|
+
values?: string[];
|
|
300
|
+
default?: unknown;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
interface Manifest {
|
|
304
|
+
name: string;
|
|
305
|
+
props?: Record<string, Prop>;
|
|
306
|
+
className?: { allow?: string[] };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const byName = new Map((manifests as unknown as Manifest[]).map((m) => [m.name, m]));
|
|
310
|
+
|
|
311
|
+
export function manifest(name: string): Manifest {
|
|
312
|
+
const m = byName.get(name);
|
|
313
|
+
if (!m) throw new Error(\`No manifest for \${name}\`);
|
|
314
|
+
return m;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Enum values for a prop, in manifest order. */
|
|
318
|
+
export function values(component: string, prop: string): string[] {
|
|
319
|
+
const p = manifest(component).props?.[prop];
|
|
320
|
+
if (!p?.values) throw new Error(\`\${component}.\${prop} is not an enum prop\`);
|
|
321
|
+
return p.values;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Storybook argTypes for every enum and boolean prop the manifest declares. */
|
|
325
|
+
export function argTypesFor(component: string): Record<string, unknown> {
|
|
326
|
+
const out: Record<string, unknown> = {};
|
|
327
|
+
for (const [name, p] of Object.entries(manifest(component).props ?? {})) {
|
|
328
|
+
if (p.type === "enum") out[name] = { control: "select", options: p.values, table: { defaultValue: p.default === undefined ? undefined : { summary: String(p.default) } } };
|
|
329
|
+
else if (p.type === "boolean") out[name] = { control: "boolean" };
|
|
330
|
+
else if (p.type === "number") out[name] = { control: "number" };
|
|
331
|
+
else if (p.type === "string") out[name] = { control: "text" };
|
|
332
|
+
else out[name] = { control: false };
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** What className may set, for the docs description. */
|
|
338
|
+
export function classNameAllow(component: string): string {
|
|
339
|
+
const allow = manifest(component).className?.allow ?? [];
|
|
340
|
+
return allow.length ? \`className may set: \${allow.join(", ")}.\` : "className is not a styling API on this component.";
|
|
341
|
+
}
|
|
342
|
+
`;
|
|
343
|
+
const README = (name, template, components, framework = "vite") => `# ${name}
|
|
344
|
+
|
|
345
|
+
Created with \`zengin create\` from the **${template}** template${framework === "next" ? ", on Next.js (App Router, \`src/app\`)" : ""}. The components in \`src/components/ui\` are yours: edit them, the engine keeps everything else on the system they define.
|
|
346
|
+
|
|
347
|
+
\`\`\`bash
|
|
348
|
+
npm install
|
|
349
|
+
npm run dev # http://localhost:${framework === "next" ? "3000" : "5173"}
|
|
350
|
+
npm run storybook # http://localhost:6006
|
|
351
|
+
npm run check # zengin check: every file in src against zengin/
|
|
352
|
+
npm run add -- select switch # more components from the registry
|
|
353
|
+
\`\`\`
|
|
354
|
+
|
|
355
|
+
## Where things are
|
|
356
|
+
|
|
357
|
+
| Path | What |
|
|
358
|
+
| --- | --- |
|
|
359
|
+
| \`src/components/ui/\` | ${components.join(", ")}. Each file carries a \`zengin-owned\` pragma with the version it was copied from, so a rollup can tell how far it has drifted. |
|
|
360
|
+
| \`zengin/\` | \`tokens.json\`, \`tokens.dark.json\`, \`components.json\`: the definitions the engine enforces against. |
|
|
361
|
+
| \`src/theme/brand.css\` | Your brand as token overrides. The one place literals are allowed. |
|
|
362
|
+
| \`src/styles/\` | The base stylesheet and the component imports. \`generated/tokens.css\` is built by \`zengin tokens\`. |
|
|
363
|
+
| \`zengin.config.yaml\` | The policy. Every rule at error. |
|
|
364
|
+
| \`.mcp.json\`, \`.claude/settings.json\` | The MCP server and the edit hook, so agents working here are checked as they write. |
|
|
365
|
+
`;
|
|
366
|
+
const NEXT_CONFIG = `import type { NextConfig } from "next";
|
|
367
|
+
|
|
368
|
+
// "@" is src through tsconfig paths; Next reads them. Nothing else to configure.
|
|
369
|
+
const config: NextConfig = { reactStrictMode: true };
|
|
370
|
+
|
|
371
|
+
export default config;
|
|
372
|
+
`;
|
|
373
|
+
/** The template's App, mounted on the client. The templates read window and document in hooks, which is what an SPA does; ssr: false keeps that honest. */
|
|
374
|
+
const NEXT_PAGE = `"use client";
|
|
375
|
+
import dynamic from "next/dynamic";
|
|
376
|
+
|
|
377
|
+
const App = dynamic(() => import("@/App").then((m) => m.App), { ssr: false });
|
|
378
|
+
|
|
379
|
+
export default function Page() {
|
|
380
|
+
return <App />;
|
|
381
|
+
}
|
|
382
|
+
`;
|
|
383
|
+
/**
|
|
384
|
+
* The root layout from the template's index.html and main.tsx: the same stylesheet imports (the tokens,
|
|
385
|
+
* the brand, the template's own css), the title as metadata, and a <head> with the fonts link so that
|
|
386
|
+
* `zengin theme`, `zengin fonts` and `zengin brand` patch it the way they patch index.html.
|
|
387
|
+
*/
|
|
388
|
+
const NEXT_LAYOUT = (opts) => {
|
|
389
|
+
const title = /<title>([^<]*)<\/title>/.exec(opts.html)?.[1]?.trim() || opts.name;
|
|
390
|
+
// A stylesheet link in a React 19 layout needs `precedence`, or Next's prerender fails on the hoisting; the patchers add it too.
|
|
391
|
+
const links = [...opts.html.matchAll(/<link[^>]*rel="(?:stylesheet|preconnect)"[^>]*>/g)].map((m) => m[0]
|
|
392
|
+
.replace(/\s*\/?>$/, " />")
|
|
393
|
+
.replace(/\scrossorigin(?=\s|\/|>)/, " crossOrigin=\"anonymous\"")
|
|
394
|
+
.replace(/(rel="stylesheet"[^>]*?)\s\/>$/, '$1 precedence="default" />'));
|
|
395
|
+
const imports = [...opts.main.matchAll(/^import "(\.\/[^"]+\.css)";$/gm)].map((m) => m[1].replace(/^\.\//, "@/"));
|
|
396
|
+
const css = (imports.length ? imports : ["@/styles/index.css", "@/theme/brand.css"]).map((p) => `import "${p}";`).join("\n");
|
|
397
|
+
return `${css}
|
|
398
|
+
import type { Metadata } from "next";
|
|
399
|
+
import type { ReactNode } from "react";
|
|
400
|
+
|
|
401
|
+
export const metadata: Metadata = { title: ${JSON.stringify(title)} };
|
|
402
|
+
|
|
403
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
404
|
+
return (
|
|
405
|
+
<html lang="en" suppressHydrationWarning>
|
|
406
|
+
<head>
|
|
407
|
+
${links.map((l) => ` ${l}`).join("\n")}
|
|
408
|
+
</head>
|
|
409
|
+
<body>{children}</body>
|
|
410
|
+
</html>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
`;
|
|
414
|
+
};
|
|
415
|
+
const TSCONFIG_NEXT = (storybook) => JSON.stringify({
|
|
416
|
+
compilerOptions: {
|
|
417
|
+
target: "ES2022",
|
|
418
|
+
module: "ESNext",
|
|
419
|
+
moduleResolution: "Bundler",
|
|
420
|
+
lib: ["ES2022", "DOM", "DOM.Iterable"],
|
|
421
|
+
jsx: "react-jsx",
|
|
422
|
+
strict: true,
|
|
423
|
+
noUncheckedIndexedAccess: true,
|
|
424
|
+
isolatedModules: true,
|
|
425
|
+
skipLibCheck: true,
|
|
426
|
+
resolveJsonModule: true,
|
|
427
|
+
esModuleInterop: true,
|
|
428
|
+
allowJs: true,
|
|
429
|
+
incremental: true,
|
|
430
|
+
noEmit: true,
|
|
431
|
+
plugins: [{ name: "next" }],
|
|
432
|
+
baseUrl: ".",
|
|
433
|
+
paths: { "@/*": ["./src/*"] },
|
|
434
|
+
},
|
|
435
|
+
include: ["next-env.d.ts", "src", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", ...(storybook ? ["stories", ".storybook"] : [])],
|
|
436
|
+
exclude: ["node_modules"],
|
|
437
|
+
}, null, 2) + "\n";
|
package/dist/fonts.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { RegistrySource } from "./load.js";
|
|
2
|
+
import type { FontPairing } from "./schema.js";
|
|
3
|
+
/**
|
|
4
|
+
* A pairing is three roles, display, text and code, each a Google Fonts family at the weights the
|
|
5
|
+
* components use. Applying one touches exactly what a theme touches for fonts: the three font tokens
|
|
6
|
+
* in src/theme/brand.css and the one fonts link in index.html. The palette, radii and shadows stay.
|
|
7
|
+
*/
|
|
8
|
+
export interface FontsSummary {
|
|
9
|
+
name: string;
|
|
10
|
+
title: string;
|
|
11
|
+
description: string;
|
|
12
|
+
pairing: FontPairing;
|
|
13
|
+
}
|
|
14
|
+
export interface ApplyFontsResult {
|
|
15
|
+
name: string;
|
|
16
|
+
pairing: FontPairing;
|
|
17
|
+
/** Project-relative paths written. */
|
|
18
|
+
files: string[];
|
|
19
|
+
html: boolean;
|
|
20
|
+
/** With selfHost: the font files downloaded into public/fonts. */
|
|
21
|
+
downloaded: string[];
|
|
22
|
+
}
|
|
23
|
+
export declare const FALLBACK_SANS = "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif";
|
|
24
|
+
export declare const FALLBACK_SERIF = "ui-serif, Georgia, \"Times New Roman\", serif";
|
|
25
|
+
export declare const FALLBACK_MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
|
26
|
+
export declare function listFonts(source: RegistrySource): Promise<FontsSummary[]>;
|
|
27
|
+
/** `Family:400;700` for the fonts link: each role at its own weights. */
|
|
28
|
+
export declare function pairingFamilies(p: FontPairing): string[];
|
|
29
|
+
/** The three token declarations a pairing sets, with the fallback stack each role deserves. */
|
|
30
|
+
export declare function pairingCss(p: FontPairing): Record<"--font-sans" | "--font-display" | "--font-mono", string>;
|
|
31
|
+
/**
|
|
32
|
+
* Rewrites the font tokens in a brand file. Each declaration is replaced where it is (every block that
|
|
33
|
+
* sets it, so light and dark agree) or added to the first block when the file never set it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function applyPairingToCss(css: string, p: FontPairing): string;
|
|
36
|
+
export declare function applyFonts(opts: {
|
|
37
|
+
projectDir: string;
|
|
38
|
+
name: string;
|
|
39
|
+
source: RegistrySource;
|
|
40
|
+
selfHost?: boolean;
|
|
41
|
+
fetcher?: typeof fetch;
|
|
42
|
+
}): Promise<ApplyFontsResult>;
|
package/dist/fonts.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fontsHref, patchIndexHtml } from "./html.js";
|
|
4
|
+
/** Registry items are `fonts-<name>`, so a pairing and a theme can share a name; people say the bare name. */
|
|
5
|
+
const bare = (name) => name.replace(/^fonts-/, "");
|
|
6
|
+
export const FALLBACK_SANS = "ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif";
|
|
7
|
+
export const FALLBACK_SERIF = "ui-serif, Georgia, \"Times New Roman\", serif";
|
|
8
|
+
export const FALLBACK_MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
|
|
9
|
+
export async function listFonts(source) {
|
|
10
|
+
const index = await source.index();
|
|
11
|
+
return index.items.filter((i) => i.type === "fonts" && i.pairing).map((i) => ({ name: bare(i.name), title: i.title, description: i.description, pairing: i.pairing }));
|
|
12
|
+
}
|
|
13
|
+
/** `Family:400;700` for the fonts link: each role at its own weights. */
|
|
14
|
+
export function pairingFamilies(p) {
|
|
15
|
+
const roles = [p.display, p.sans, p.mono];
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const r of roles) {
|
|
18
|
+
const spec = `${r.family}:${r.weights.join(";")}`;
|
|
19
|
+
if (!out.includes(spec))
|
|
20
|
+
out.push(spec);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
/** The three token declarations a pairing sets, with the fallback stack each role deserves. */
|
|
25
|
+
export function pairingCss(p) {
|
|
26
|
+
const stack = (family, serif, then) => `"${family}", ${then ? `"${then}", ` : ""}${serif ? FALLBACK_SERIF : FALLBACK_SANS}`;
|
|
27
|
+
return {
|
|
28
|
+
"--font-sans": stack(p.sans.family, p.sans.serif),
|
|
29
|
+
"--font-display": p.display.family === p.sans.family ? stack(p.display.family, p.display.serif) : stack(p.display.family, p.display.serif, p.sans.family),
|
|
30
|
+
"--font-mono": `"${p.mono.family}", ${FALLBACK_MONO}`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Rewrites the font tokens in a brand file. Each declaration is replaced where it is (every block that
|
|
35
|
+
* sets it, so light and dark agree) or added to the first block when the file never set it.
|
|
36
|
+
*/
|
|
37
|
+
export function applyPairingToCss(css, p) {
|
|
38
|
+
let out = css;
|
|
39
|
+
const missing = [];
|
|
40
|
+
for (const [prop, value] of Object.entries(pairingCss(p))) {
|
|
41
|
+
const re = new RegExp(`(^[ \\t]*)${prop}\\s*:[^;]*;`, "gm");
|
|
42
|
+
if (re.test(out))
|
|
43
|
+
out = out.replace(re, `$1${prop}: ${value};`);
|
|
44
|
+
else
|
|
45
|
+
missing.push(` ${prop}: ${value};`);
|
|
46
|
+
}
|
|
47
|
+
if (missing.length) {
|
|
48
|
+
const open = /(:root[^{]*\{[ \t]*\r?\n)/.exec(out);
|
|
49
|
+
out = open ? out.replace(open[0], `${open[0]}${missing.join("\n")}\n`) : `:root {\n${missing.join("\n")}\n}\n\n${out}`;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
export async function applyFonts(opts) {
|
|
54
|
+
const dir = resolve(opts.projectDir);
|
|
55
|
+
if (!existsSync(join(dir, "zengin.config.yaml")))
|
|
56
|
+
throw new Error(`${dir} has no zengin.config.yaml. Run zengin fonts inside a project made by zengin create, or pass --dir.`);
|
|
57
|
+
const index = await opts.source.index();
|
|
58
|
+
const summary = index.items.find((i) => i.name === `fonts-${bare(opts.name)}` && i.type === "fonts");
|
|
59
|
+
if (!summary) {
|
|
60
|
+
const names = index.items.filter((i) => i.type === "fonts").map((i) => bare(i.name));
|
|
61
|
+
throw new Error(`No pairing "${opts.name}". Pairings: ${names.join(", ")}.`);
|
|
62
|
+
}
|
|
63
|
+
const item = await opts.source.item(summary.name);
|
|
64
|
+
const pairing = item.pairing;
|
|
65
|
+
if (!pairing)
|
|
66
|
+
throw new Error(`The registry item "${opts.name}" carries no pairing.`);
|
|
67
|
+
const files = [];
|
|
68
|
+
const brand = join(dir, "src", "theme", "brand.css");
|
|
69
|
+
const before = existsSync(brand) ? readFileSync(brand, "utf8") : `/* Fonts, set by zengin fonts. */\n\n:root {\n}\n`;
|
|
70
|
+
mkdirSync(dirname(brand), { recursive: true });
|
|
71
|
+
writeFileSync(brand, applyPairingToCss(before, pairing));
|
|
72
|
+
files.push("src/theme/brand.css");
|
|
73
|
+
const families = pairingFamilies(pairing);
|
|
74
|
+
let downloaded = [];
|
|
75
|
+
let html;
|
|
76
|
+
if (opts.selfHost) {
|
|
77
|
+
const hosted = await selfHost(dir, families, opts.fetcher ?? fetch);
|
|
78
|
+
downloaded = hosted.downloaded;
|
|
79
|
+
files.push(hosted.cssFile);
|
|
80
|
+
if (hosted.mainPatched)
|
|
81
|
+
files.push(hosted.mainPatched);
|
|
82
|
+
html = patchIndexHtml(dir, { fonts: null });
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
html = patchIndexHtml(dir, { fonts: fontsHref(families) });
|
|
86
|
+
}
|
|
87
|
+
if (html)
|
|
88
|
+
files.push("index.html");
|
|
89
|
+
return { name: bare(item.name), pairing, files, html, downloaded };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Downloads the woff2 files Google serves for the families into public/fonts and writes the @font-face
|
|
93
|
+
* rules to src/theme/fonts.css, imported from main.tsx, so nothing loads from fonts.googleapis.com at
|
|
94
|
+
* runtime. Google's CSS is fetched with a modern browser's user agent, which is what makes it answer
|
|
95
|
+
* with woff2 and unicode ranges.
|
|
96
|
+
*/
|
|
97
|
+
async function selfHost(dir, families, fetcher) {
|
|
98
|
+
const href = fontsHref(families);
|
|
99
|
+
const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36";
|
|
100
|
+
const res = await fetcher(href, { headers: { "user-agent": ua } });
|
|
101
|
+
if (!res.ok)
|
|
102
|
+
throw new Error(`Google Fonts answered ${res.status} for ${href}`);
|
|
103
|
+
let css = await res.text();
|
|
104
|
+
const fontsDir = join(dir, "public", "fonts");
|
|
105
|
+
mkdirSync(fontsDir, { recursive: true });
|
|
106
|
+
const downloaded = [];
|
|
107
|
+
const urls = [...new Set([...css.matchAll(/url\((https:\/\/fonts\.gstatic\.com\/[^)]+)\)/g)].map((m) => m[1]))];
|
|
108
|
+
for (const url of urls) {
|
|
109
|
+
const name = url.split("/").slice(-2).join("-").replace(/[^A-Za-z0-9._-]/g, "");
|
|
110
|
+
const file = join(fontsDir, name);
|
|
111
|
+
const r = await fetcher(url);
|
|
112
|
+
if (!r.ok)
|
|
113
|
+
throw new Error(`Google Fonts answered ${r.status} for ${url}`);
|
|
114
|
+
writeFileSync(file, Buffer.from(await r.arrayBuffer()));
|
|
115
|
+
downloaded.push(`public/fonts/${name}`);
|
|
116
|
+
css = css.split(url).join(`/fonts/${name}`);
|
|
117
|
+
}
|
|
118
|
+
const cssFile = "src/theme/fonts.css";
|
|
119
|
+
writeFileSync(join(dir, cssFile), `/* Self-hosted by zengin fonts --self-host: ${families.join(", ")}. Files in public/fonts. */\n\n${css.trim()}\n`);
|
|
120
|
+
let mainPatched = null;
|
|
121
|
+
const main = join(dir, "src", "main.tsx");
|
|
122
|
+
if (existsSync(main)) {
|
|
123
|
+
const src = readFileSync(main, "utf8");
|
|
124
|
+
if (!src.includes('"./theme/fonts.css"')) {
|
|
125
|
+
const anchor = 'import "./theme/brand.css";';
|
|
126
|
+
const next = src.includes(anchor) ? src.replace(anchor, `${anchor}\nimport "./theme/fonts.css";`) : `import "./theme/fonts.css";\n${src}`;
|
|
127
|
+
writeFileSync(main, next);
|
|
128
|
+
mainPatched = "src/main.tsx";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { cssFile, downloaded, mainPatched };
|
|
132
|
+
}
|
package/dist/html.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface HtmlPatch {
|
|
2
|
+
title?: string;
|
|
3
|
+
themeColor?: string;
|
|
4
|
+
/** href for the icon link. */
|
|
5
|
+
icon?: string;
|
|
6
|
+
/** Google Fonts stylesheet href; null removes the one a theme or brand added before. */
|
|
7
|
+
fonts?: string | null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Edits index.html in place: the title, the theme-color meta, the icon, and one fonts link the CLI owns
|
|
11
|
+
* (marked with data-zengin so a later theme replaces it, never duplicates it). Returns false when there
|
|
12
|
+
* is no index.html to patch.
|
|
13
|
+
*/
|
|
14
|
+
export declare function patchIndexHtml(projectDir: string, patch: HtmlPatch): boolean;
|
|
15
|
+
/** The Google Fonts css2 href for the families, at the four weights components use unless `Family:400;700` pins them. */
|
|
16
|
+
export declare function fontsHref(families: string[]): string;
|