@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.
@@ -0,0 +1,71 @@
1
+ import type { ComponentManifest } from "@zenginui/engine";
2
+ export declare const REGISTRY_SCHEMA = "zengin-registry/1";
3
+ export type ItemType = "component" | "template" | "lib" | "definitions" | "theme" | "fonts" | "icons";
4
+ /** One typographic role: a Google Fonts family at the weights the components use. */
5
+ export interface FontRole {
6
+ family: string;
7
+ weights: number[];
8
+ /** A serif face gets a serif fallback stack. */
9
+ serif?: boolean;
10
+ }
11
+ /** A pairing: headlines, text and code. */
12
+ export interface FontPairing {
13
+ display: FontRole;
14
+ sans: FontRole;
15
+ mono: FontRole;
16
+ }
17
+ export type FileKind = "component" | "style" | "story" | "lib" | "template" | "definitions" | "theme";
18
+ export interface RegistryFile {
19
+ /** Path relative to the project root the item installs into. */
20
+ path: string;
21
+ kind: FileKind;
22
+ content: string;
23
+ }
24
+ export interface RegistryItem {
25
+ name: string;
26
+ type: ItemType;
27
+ title: string;
28
+ description: string;
29
+ /** npm packages the item needs at runtime. */
30
+ dependencies: Record<string, string>;
31
+ /** npm packages the item needs at build time. */
32
+ devDependencies: Record<string, string>;
33
+ /** Other registry items this one needs, installed first. */
34
+ registryDependencies: string[];
35
+ files: RegistryFile[];
36
+ /** For components: the manifest entry, with `export.from` already pointing at the project alias. */
37
+ manifest?: ComponentManifest;
38
+ /** For themes: Google Fonts families the brand file expects, linked into index.html on apply. `Family:400;700` pins weights. */
39
+ fonts?: string[];
40
+ /** For fonts items: the pairing. */
41
+ pairing?: FontPairing;
42
+ /** For icons items: the react-icons module and the vocabulary name -> export name map. */
43
+ iconSet?: {
44
+ module: string;
45
+ names: Record<string, string>;
46
+ };
47
+ /** For templates: the repository directory the template is derived from, which the site builds as its live preview. */
48
+ source?: string;
49
+ }
50
+ /** The index: every item without its file contents, so a client can list and resolve before fetching. */
51
+ export interface RegistryIndex {
52
+ schema: typeof REGISTRY_SCHEMA;
53
+ name: string;
54
+ /** Version of the system the items were cut from; written into the owned pragma of installed files. */
55
+ version: string;
56
+ generatedAt: string;
57
+ items: Omit<RegistryItem, "files" | "manifest">[];
58
+ }
59
+ export interface Registry extends Omit<RegistryIndex, "items"> {
60
+ items: RegistryItem[];
61
+ }
62
+ /** Where installed files go inside a project. One convention, so `add` needs no configuration. */
63
+ export declare const LAYOUT: {
64
+ readonly componentsDir: "src/components/ui";
65
+ readonly alias: "@/components/ui";
66
+ readonly libDir: "src/lib";
67
+ readonly stylesIndex: "src/styles/index.css";
68
+ readonly storiesDir: "stories";
69
+ readonly definitionsDir: "zengin";
70
+ };
71
+ export declare function isRegistryIndex(x: unknown): x is RegistryIndex;
package/dist/schema.js ADDED
@@ -0,0 +1,13 @@
1
+ export const REGISTRY_SCHEMA = "zengin-registry/1";
2
+ /** Where installed files go inside a project. One convention, so `add` needs no configuration. */
3
+ export const LAYOUT = {
4
+ componentsDir: "src/components/ui",
5
+ alias: "@/components/ui",
6
+ libDir: "src/lib",
7
+ stylesIndex: "src/styles/index.css",
8
+ storiesDir: "stories",
9
+ definitionsDir: "zengin",
10
+ };
11
+ export function isRegistryIndex(x) {
12
+ return typeof x === "object" && x !== null && x.schema === REGISTRY_SCHEMA && Array.isArray(x.items);
13
+ }
@@ -0,0 +1,26 @@
1
+ import type { RegistrySource } from "./load.js";
2
+ export interface ThemeSummary {
3
+ name: string;
4
+ title: string;
5
+ description: string;
6
+ fonts: string[];
7
+ }
8
+ export interface ApplyThemeResult {
9
+ name: string;
10
+ /** Project-relative paths written. */
11
+ files: string[];
12
+ fonts: string[];
13
+ html: boolean;
14
+ }
15
+ /** Every theme the registry offers. */
16
+ export declare function listThemes(source: RegistrySource): Promise<ThemeSummary[]>;
17
+ /**
18
+ * Swaps the project's brand for a theme from the registry: the theme's files replace what is there
19
+ * (a theme is the whole brand, not an addition), and index.html gets the theme's fonts link, replacing
20
+ * the previous theme's. Everything else in the project is untouched, which is the point.
21
+ */
22
+ export declare function applyTheme(opts: {
23
+ projectDir: string;
24
+ name: string;
25
+ source: RegistrySource;
26
+ }): Promise<ApplyThemeResult>;
package/dist/theme.js ADDED
@@ -0,0 +1,35 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fontsHref, patchIndexHtml } from "./html.js";
4
+ /** Every theme the registry offers. */
5
+ export async function listThemes(source) {
6
+ const index = await source.index();
7
+ return index.items.filter((i) => i.type === "theme").map((i) => ({ name: i.name, title: i.title, description: i.description, fonts: i.fonts ?? [] }));
8
+ }
9
+ /**
10
+ * Swaps the project's brand for a theme from the registry: the theme's files replace what is there
11
+ * (a theme is the whole brand, not an addition), and index.html gets the theme's fonts link, replacing
12
+ * the previous theme's. Everything else in the project is untouched, which is the point.
13
+ */
14
+ export async function applyTheme(opts) {
15
+ const dir = resolve(opts.projectDir);
16
+ if (!existsSync(join(dir, "zengin.config.yaml")))
17
+ throw new Error(`${dir} has no zengin.config.yaml. Run zengin theme inside a project made by zengin create, or pass --dir.`);
18
+ const index = await opts.source.index();
19
+ const summary = index.items.find((i) => i.name === opts.name && i.type === "theme");
20
+ if (!summary) {
21
+ const names = index.items.filter((i) => i.type === "theme").map((i) => i.name);
22
+ throw new Error(`No theme "${opts.name}". Themes: ${names.join(", ")}.`);
23
+ }
24
+ const item = await opts.source.item(opts.name);
25
+ const files = [];
26
+ for (const f of item.files) {
27
+ const abs = join(dir, f.path);
28
+ mkdirSync(dirname(abs), { recursive: true });
29
+ writeFileSync(abs, f.content);
30
+ files.push(f.path);
31
+ }
32
+ const fonts = item.fonts ?? [];
33
+ const html = patchIndexHtml(dir, { fonts: fonts.length ? fontsHref(fonts) : null });
34
+ return { name: item.name, files, fonts, html };
35
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * tokens.json (and tokens.dark.json when present) to a stylesheet of custom properties, using the engine's
3
+ * own token loader so the variable names are exactly the ones the rules check for. Themes attach to any
4
+ * element: an explicit data-theme wins; without one, the system preference decides.
5
+ */
6
+ export declare function buildTokensCss(definitionsDir: string): {
7
+ css: string;
8
+ light: number;
9
+ dark: number;
10
+ };
11
+ export declare function writeTokensCss(definitionsDir: string, outFile: string): {
12
+ light: number;
13
+ dark: number;
14
+ };
package/dist/tokens.js ADDED
@@ -0,0 +1,44 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { loadTokens } from "@zenginui/engine";
4
+ /**
5
+ * tokens.json (and tokens.dark.json when present) to a stylesheet of custom properties, using the engine's
6
+ * own token loader so the variable names are exactly the ones the rules check for. Themes attach to any
7
+ * element: an explicit data-theme wins; without one, the system preference decides.
8
+ */
9
+ export function buildTokensCss(definitionsDir) {
10
+ const lightPath = join(definitionsDir, "tokens.json");
11
+ if (!existsSync(lightPath))
12
+ throw new Error(`No tokens.json in ${definitionsDir}.`);
13
+ const light = loadTokens(JSON.parse(readFileSync(lightPath, "utf8")));
14
+ const darkPath = join(definitionsDir, "tokens.dark.json");
15
+ const dark = existsSync(darkPath) ? loadTokens(JSON.parse(readFileSync(darkPath, "utf8"))) : [];
16
+ const lightVars = new Set(light.map((t) => t.cssVar));
17
+ const unknown = dark.filter((t) => !lightVars.has(t.cssVar)).map((t) => t.cssVar);
18
+ if (unknown.length)
19
+ throw new Error(`tokens.dark.json defines tokens missing from tokens.json: ${unknown.join(", ")}`);
20
+ const parts = [
21
+ "/* Generated from zengin/tokens.json and zengin/tokens.dark.json by `zengin tokens`. Do not edit. */",
22
+ "",
23
+ block(':root, [data-theme="light"]', light),
24
+ ];
25
+ if (dark.length) {
26
+ parts.push("", block('[data-theme="dark"]', dark), "", "@media (prefers-color-scheme: dark) {", indent(block(':root:not([data-theme="light"])', dark)), "}");
27
+ }
28
+ return { css: parts.join("\n") + "\n", light: light.length, dark: dark.length };
29
+ }
30
+ export function writeTokensCss(definitionsDir, outFile) {
31
+ const { css, light, dark } = buildTokensCss(definitionsDir);
32
+ mkdirSync(dirname(outFile), { recursive: true });
33
+ writeFileSync(outFile, css);
34
+ return { light, dark };
35
+ }
36
+ function block(selector, tokens) {
37
+ return `${selector} {\n${tokens.map((t) => ` ${t.cssVar}: ${t.value};`).join("\n")}\n}`;
38
+ }
39
+ function indent(s) {
40
+ return s
41
+ .split("\n")
42
+ .map((l) => (l ? ` ${l}` : l))
43
+ .join("\n");
44
+ }
@@ -0,0 +1,60 @@
1
+ import type { RegistrySource } from "./load.js";
2
+ /**
3
+ * What changed upstream since a project copied its components, and whether the project changed them too.
4
+ * Every owned file carries the hash of what was copied. Comparing that hash with the file as it is now says
5
+ * whether the project edited it; comparing it with the registry's current file says whether the system
6
+ * moved. Four answers per file, and only one of them needs a person: both sides changed.
7
+ */
8
+ export type UpgradeState =
9
+ /** The file is what the registry ships. */
10
+ "current"
11
+ /** Upstream changed, the project did not: safe to take. */
12
+ | "upstream"
13
+ /** The project changed it, upstream did not: nothing to do. */
14
+ | "local"
15
+ /** Both changed: a person merges, or --force takes upstream. */
16
+ | "conflict"
17
+ /** No hash in the pragma (copied before hashes) and the contents differ: cannot tell which side moved. */
18
+ | "unknown"
19
+ /** The registry no longer ships this file. */
20
+ | "gone";
21
+ export interface UpgradeEntry {
22
+ /** Project-relative path. */
23
+ path: string;
24
+ /** Registry item the file belongs to. */
25
+ item: string;
26
+ state: UpgradeState;
27
+ /** The version the file was copied from, when the pragma says. */
28
+ from?: string;
29
+ /** Local against upstream, for a conflict or an unknown. */
30
+ diff?: string;
31
+ }
32
+ export interface UpgradePlan {
33
+ /** The version the project pins in zengin.config.yaml. */
34
+ projectVersion: string | undefined;
35
+ /** The registry's version. */
36
+ version: string;
37
+ entries: UpgradeEntry[];
38
+ /** Registry items the project has files for. */
39
+ items: string[];
40
+ }
41
+ export interface ApplyResult {
42
+ written: string[];
43
+ skipped: UpgradeEntry[];
44
+ /** True when zengin.config.yaml's system.version was moved to the registry's. */
45
+ versionBumped: boolean;
46
+ }
47
+ /** Owned files under src/components/ui and the libs under src/lib, mapped to the registry items that ship them. */
48
+ export declare function planUpgrade(opts: {
49
+ projectDir: string;
50
+ source: RegistrySource;
51
+ only?: string[];
52
+ }): Promise<UpgradePlan>;
53
+ /** Takes upstream for every `upstream` entry, and for `conflict`/`unknown` entries only with `force`. Bumps the pinned version when nothing is left behind. */
54
+ export declare function applyUpgrade(plan: UpgradePlan, opts: {
55
+ projectDir: string;
56
+ source: RegistrySource;
57
+ force?: boolean;
58
+ }): Promise<ApplyResult>;
59
+ /** A unified-style diff, local against upstream, three lines of context. Enough to decide; a merge tool does the rest. */
60
+ export declare function diffLines(local: string, upstream: string, context?: number): string;
@@ -0,0 +1,171 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { join, relative, resolve } from "node:path";
3
+ import { readOwnedPragma } from "@zenginui/engine";
4
+ import { kebab } from "./build.js";
5
+ import { contentHash, mergeManifest, stripPragma, withPragma } from "./install.js";
6
+ import { LAYOUT } from "./schema.js";
7
+ /** Owned files under src/components/ui and the libs under src/lib, mapped to the registry items that ship them. */
8
+ export async function planUpgrade(opts) {
9
+ const dir = resolve(opts.projectDir);
10
+ const configPath = join(dir, "zengin.config.yaml");
11
+ if (!existsSync(configPath))
12
+ throw new Error(`${dir} has no zengin.config.yaml. Run zengin upgrade inside a project made by zengin create, or pass --dir.`);
13
+ const projectVersion = /^\s*version:\s*"?([^"\s#]+)"?/m.exec(readFileSync(configPath, "utf8"))?.[1];
14
+ const index = await opts.source.index();
15
+ const only = opts.only?.map((n) => n.replace(/^lib-/, ""));
16
+ // Which items to look at: every component item with a directory in the project, every lib item with a file.
17
+ const wanted = index.items.filter((i) => {
18
+ if (i.type === "component")
19
+ return existsSync(join(dir, LAYOUT.componentsDir, i.name)) && (!only || only.includes(i.name));
20
+ if (i.type === "lib")
21
+ return existsSync(join(dir, LAYOUT.libDir)) && (!only || only.includes(i.name.replace(/^lib-/, "")));
22
+ return false;
23
+ });
24
+ const entries = [];
25
+ const items = [];
26
+ for (const summary of wanted) {
27
+ const item = await opts.source.item(summary.name);
28
+ let touched = false;
29
+ for (const f of item.files) {
30
+ if (f.kind === "story")
31
+ continue; // the project's from the start
32
+ const abs = join(dir, f.path);
33
+ if (!existsSync(abs))
34
+ continue;
35
+ touched = true;
36
+ entries.push(compare(f.path, readFileSync(abs, "utf8"), f.content, item));
37
+ }
38
+ if (touched)
39
+ items.push(item.name);
40
+ }
41
+ // Files with a pragma whose item the registry no longer has.
42
+ for (const p of walk(join(dir, LAYOUT.componentsDir))) {
43
+ const rel = relative(dir, p).replace(/\\/g, "/");
44
+ if (entries.some((e) => e.path === rel))
45
+ continue;
46
+ const pragma = readOwnedPragma(readFileSync(p, "utf8"));
47
+ if (!pragma?.component)
48
+ continue;
49
+ const name = kebab(pragma.component);
50
+ if (only && !only.includes(name))
51
+ continue;
52
+ if (!index.items.some((i) => i.name === name))
53
+ entries.push({ path: rel, item: name, state: "gone", ...(pragma.forkedFrom ? { from: pragma.forkedFrom } : {}) });
54
+ }
55
+ entries.sort((a, b) => a.path.localeCompare(b.path));
56
+ return { projectVersion, version: index.version, entries, items };
57
+ }
58
+ function compare(path, local, upstreamRaw, item) {
59
+ const pragma = readOwnedPragma(local);
60
+ const localBody = stripPragma(local);
61
+ const upstream = stripPragma(upstreamRaw);
62
+ const from = pragma?.forkedFrom;
63
+ const base = { path, item: item.name, state: "current", ...(from ? { from } : {}) };
64
+ if (localBody === upstream)
65
+ return base;
66
+ if (!pragma?.sha)
67
+ return { ...base, state: "unknown", diff: diffLines(localBody, upstream) };
68
+ const localChanged = contentHash(localBody) !== pragma.sha;
69
+ const upstreamChanged = contentHash(upstream) !== pragma.sha;
70
+ if (upstreamChanged && !localChanged)
71
+ return { ...base, state: "upstream" };
72
+ if (localChanged && !upstreamChanged)
73
+ return { ...base, state: "local" };
74
+ return { ...base, state: "conflict", diff: diffLines(localBody, upstream) };
75
+ }
76
+ /** Takes upstream for every `upstream` entry, and for `conflict`/`unknown` entries only with `force`. Bumps the pinned version when nothing is left behind. */
77
+ export async function applyUpgrade(plan, opts) {
78
+ const dir = resolve(opts.projectDir);
79
+ const written = [];
80
+ const skipped = [];
81
+ const byItem = new Map();
82
+ const load = async (name) => byItem.get(name) ?? (byItem.set(name, await opts.source.item(name)), byItem.get(name));
83
+ for (const e of plan.entries) {
84
+ const take = e.state === "upstream" || ((e.state === "conflict" || e.state === "unknown") && opts.force);
85
+ if (!take) {
86
+ if (e.state === "conflict" || e.state === "unknown")
87
+ skipped.push(e);
88
+ continue;
89
+ }
90
+ const item = await load(e.item);
91
+ const f = item.files.find((x) => x.path === e.path);
92
+ const owned = item.manifest && (f.kind === "component" || (f.kind === "style" && f.path.startsWith(LAYOUT.componentsDir)));
93
+ writeFileSync(join(dir, e.path), owned ? withPragma(f.content, item.manifest.name, plan.version) : f.content);
94
+ written.push(e.path);
95
+ if (item.manifest && f.kind === "component")
96
+ mergeManifest(dir, { ...item.manifest, export: item.type === "component" ? { ...item.manifest.export, from: LAYOUT.alias } : item.manifest.export });
97
+ }
98
+ // Files the project edited but upstream did not keep their old pragma; they are current by choice. Only a
99
+ // conflict or an unknown left behind means the project is not on the registry's version yet.
100
+ let versionBumped = false;
101
+ if (skipped.length === 0 && plan.projectVersion !== plan.version) {
102
+ const p = join(dir, "zengin.config.yaml");
103
+ const before = readFileSync(p, "utf8");
104
+ const after = before.replace(/^(\s*version:\s*)"?[^"\s#]+"?/m, `$1"${plan.version}"`);
105
+ if (after !== before) {
106
+ writeFileSync(p, after);
107
+ versionBumped = true;
108
+ }
109
+ }
110
+ return { written, skipped, versionBumped };
111
+ }
112
+ function walk(dir) {
113
+ if (!existsSync(dir))
114
+ return [];
115
+ const out = [];
116
+ for (const name of readdirSync(dir).sort()) {
117
+ const p = join(dir, name);
118
+ if (statSync(p).isDirectory())
119
+ out.push(...walk(p));
120
+ else if (/\.(tsx|ts|css)$/.test(name))
121
+ out.push(p);
122
+ }
123
+ return out;
124
+ }
125
+ /** A unified-style diff, local against upstream, three lines of context. Enough to decide; a merge tool does the rest. */
126
+ export function diffLines(local, upstream, context = 3) {
127
+ const a = local.split("\n");
128
+ const b = upstream.split("\n");
129
+ // Longest common subsequence by dynamic programming; component files are a few hundred lines.
130
+ const n = a.length;
131
+ const m = b.length;
132
+ const table = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
133
+ for (let i = n - 1; i >= 0; i--)
134
+ for (let j = m - 1; j >= 0; j--)
135
+ table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
136
+ const ops = [];
137
+ let i = 0;
138
+ let j = 0;
139
+ while (i < n && j < m) {
140
+ if (a[i] === b[j])
141
+ ops.push({ t: " ", s: a[i] }), i++, j++;
142
+ else if (table[i + 1][j] >= table[i][j + 1])
143
+ ops.push({ t: "-", s: a[i] }), i++;
144
+ else
145
+ ops.push({ t: "+", s: b[j] }), j++;
146
+ }
147
+ while (i < n)
148
+ ops.push({ t: "-", s: a[i++] });
149
+ while (j < m)
150
+ ops.push({ t: "+", s: b[j++] });
151
+ const keep = new Array(ops.length).fill(false);
152
+ ops.forEach((op, k) => {
153
+ if (op.t === " ")
154
+ return;
155
+ for (let c = Math.max(0, k - context); c <= Math.min(ops.length - 1, k + context); c++)
156
+ keep[c] = true;
157
+ });
158
+ const lines = ["--- local", "+++ upstream"];
159
+ let gap = false;
160
+ ops.forEach((op, k) => {
161
+ if (!keep[k]) {
162
+ gap = true;
163
+ return;
164
+ }
165
+ if (gap)
166
+ lines.push("@@");
167
+ gap = false;
168
+ lines.push(`${op.t}${op.s}`);
169
+ });
170
+ return lines.join("\n");
171
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@zenginui/registry",
3
+ "version": "0.1.0",
4
+ "description": "The Zengin registry: components, templates and definitions as installable items, plus the project generator behind `zengin create` and `zengin add`.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "keywords": [
8
+ "design-system",
9
+ "registry",
10
+ "scaffold",
11
+ "generator",
12
+ "zengin"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/Timurtek/zengin.git",
17
+ "directory": "packages/registry"
18
+ },
19
+ "homepage": "https://github.com/Timurtek/zengin/tree/main/packages/registry#readme",
20
+ "bugs": "https://github.com/Timurtek/zengin/issues",
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "sideEffects": false,
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "dependencies": {
40
+ "@zenginui/engine": "0.1.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^22",
44
+ "react-icons": "^5.7.0",
45
+ "typescript": "^5.9.2",
46
+ "vitest": "^5.0.0"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc -p tsconfig.json",
50
+ "typecheck": "tsc -p tsconfig.json --noEmit",
51
+ "test": "vitest run"
52
+ }
53
+ }