@dowel-ui/registry 0.5.0 → 0.7.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/src/custom.ts ADDED
@@ -0,0 +1,312 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { z } from "zod";
5
+
6
+ import { hashContent } from "./hash";
7
+ import {
8
+ REGISTRY_VERSION,
9
+ registryAccessSchema,
10
+ registryIndexSchema,
11
+ registryItemSchema,
12
+ registryItemTypeSchema,
13
+ type RegistryFile,
14
+ type RegistryFileType,
15
+ type RegistryItem,
16
+ } from "./schema";
17
+
18
+ /**
19
+ * Building a registry of your own components.
20
+ *
21
+ * The CLI has always been able to install from any registry — `--registry`
22
+ * takes a URL or a directory — but producing one meant reimplementing this
23
+ * package. So an organisation that wanted its own components installed the same
24
+ * way had the consumer half and none of the producer half.
25
+ *
26
+ * The authoring shape is declared here rather than imported from the component
27
+ * package, because the registry *is* the contract. A team publishing their own
28
+ * components should not have to depend on somebody else's component library to
29
+ * describe their own.
30
+ */
31
+
32
+ /** Where an item's files are written in the consuming project. */
33
+ export const itemGroupSchema = z.enum(["ui", "blocks", "lib", "hooks"]);
34
+
35
+ export type ItemGroup = z.infer<typeof itemGroupSchema>;
36
+
37
+ const GROUP_FILE_TYPE: Record<ItemGroup, RegistryFileType> = {
38
+ ui: "registry:ui",
39
+ blocks: "registry:block",
40
+ lib: "registry:lib",
41
+ hooks: "registry:hook",
42
+ };
43
+
44
+ export const itemSourceSchema = z.object({
45
+ name: z.string().regex(/^[a-z][a-z0-9-]*$/),
46
+ title: z.string().min(1),
47
+ description: z.string().min(10),
48
+ category: z.string().min(1),
49
+ status: z.enum(["stable", "beta", "experimental"]).default("stable"),
50
+ /** Where the files land. Defaults to `ui`. */
51
+ group: itemGroupSchema.default("ui"),
52
+ /** npm packages the source imports. */
53
+ dependencies: z.array(z.string()).default([]),
54
+ /** Other registry items this one imports, upstream ones included. */
55
+ registryDependencies: z.array(z.string()).default([]),
56
+ /** Files to publish, relative to the item's own directory. */
57
+ files: z.array(z.string().min(1)).min(1),
58
+ a11y: z.string().optional(),
59
+ access: registryAccessSchema,
60
+ /**
61
+ * Overrides where the item's directory is, relative to the registry root.
62
+ * Defaults to `<group>/<name>`, which is the layout this repository uses.
63
+ */
64
+ directory: z.string().optional(),
65
+ });
66
+
67
+ export type ItemSource = z.input<typeof itemSourceSchema>;
68
+
69
+ export const registryConfigSchema = z.object({
70
+ /** Absolute path the item directories are resolved against. */
71
+ root: z.string().min(1),
72
+ items: z.array(itemSourceSchema).min(1),
73
+ /**
74
+ * A registry to layer on top of — a URL, or a directory on disk.
75
+ *
76
+ * The reason a private registry is worth having at all: one URL that serves
77
+ * both the upstream components and yours, so a consumer configures one place
78
+ * and `add` resolves across both.
79
+ */
80
+ extends: z.string().min(1).optional(),
81
+ /** Written into the index, so a consumer can see what produced it. */
82
+ generatedFrom: z.string().min(1).default("custom-registry"),
83
+ });
84
+
85
+ export type RegistryConfig = z.input<typeof registryConfigSchema>;
86
+
87
+ /** Identity helper, for editor autocomplete inside a config file. */
88
+ export function defineRegistryConfig(config: RegistryConfig): RegistryConfig {
89
+ return config;
90
+ }
91
+
92
+ export interface BuildResult {
93
+ items: RegistryItem[];
94
+ /**
95
+ * Names that exist upstream and were replaced by a local item.
96
+ *
97
+ * Reported rather than applied silently. Overriding upstream's Button is a
98
+ * legitimate thing to want and a catastrophic thing to do by accident, and
99
+ * the difference is entirely whether anyone was told.
100
+ */
101
+ overridden: string[];
102
+ /** How many items came from upstream unchanged. */
103
+ inherited: number;
104
+ }
105
+
106
+ /**
107
+ * Every `@/...` import a source file makes, as `[group, rest]`.
108
+ *
109
+ * Published source is authored against the library's own aliases and rewritten
110
+ * at install time to wherever the consuming project keeps things. Both checks
111
+ * below depend on reading those imports.
112
+ */
113
+ function authoredImports(content: string): { group: string; rest: string }[] {
114
+ const found: { group: string; rest: string }[] = [];
115
+ const pattern = /["']@\/(components|lib|hooks|blocks)\/([^"']+)["']/g;
116
+
117
+ for (const match of content.matchAll(pattern)) {
118
+ if (match[1] && match[2]) found.push({ group: match[1], rest: match[2] });
119
+ }
120
+
121
+ return found;
122
+ }
123
+
124
+ /**
125
+ * Catches source written against the *installed* paths instead of the authored
126
+ * ones.
127
+ *
128
+ * `@/components/ui/badge` looks right — it is where the file ends up — and
129
+ * rewrites to `@/components/ui/ui/badge`, because the rewriter maps
130
+ * `@/components/` to wherever the project keeps its components. The result
131
+ * compiles nowhere and the doubled segment is easy to stare past. The authored
132
+ * form is `@/components/badge`.
133
+ */
134
+ function assertAuthoredPaths(name: string, content: string): void {
135
+ const groups = new Set<string>(["ui", "blocks", "lib", "hooks"]);
136
+
137
+ const mistaken = authoredImports(content)
138
+ .filter((entry) => groups.has(entry.rest.split("/")[0] ?? ""))
139
+ .map((entry) => `@/${entry.group}/${entry.rest}`);
140
+
141
+ if (mistaken.length > 0) {
142
+ throw new Error(
143
+ `Item "${name}" imports from an installed path rather than an authored one:\n` +
144
+ ` ${[...new Set(mistaken)].join("\n ")}\n` +
145
+ "Write `@/components/badge`, not `@/components/ui/badge` — the leading group is " +
146
+ "rewritten to wherever the consuming project keeps its components, so naming it " +
147
+ "twice produces a path that resolves nowhere.",
148
+ );
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Catches a component importing something it never declared.
154
+ *
155
+ * The undeclared dependency is not installed alongside it, so the install
156
+ * succeeds and the project fails to build — in someone else's repository, where
157
+ * it is hardest to trace back to here.
158
+ */
159
+ function assertDeclaredDependencies(
160
+ source: z.infer<typeof itemSourceSchema>,
161
+ content: string,
162
+ ): void {
163
+ const declared = new Set([...source.registryDependencies, source.name]);
164
+
165
+ const undeclared = authoredImports(content)
166
+ .filter((entry) => entry.group === "components" || entry.group === "blocks")
167
+ // `@/lib/utils` and `@/lib/styles` are written by `init`, so they are
168
+ // present before any component is and are never declared.
169
+ .map((entry) => entry.rest.split("/")[0] ?? "")
170
+ .filter((imported) => imported.length > 0 && !declared.has(imported));
171
+
172
+ if (undeclared.length > 0) {
173
+ throw new Error(
174
+ `Item "${source.name}" imports ${[...new Set(undeclared)].join(", ")} but does not ` +
175
+ "list them in registryDependencies. They would not be installed alongside it, and " +
176
+ "the failure would surface as a build error in the consuming project.",
177
+ );
178
+ }
179
+ }
180
+
181
+ function toItem(root: string, source: z.infer<typeof itemSourceSchema>): RegistryItem {
182
+ const directory = source.directory ?? join(source.group, source.name);
183
+ const itemDir = join(root, directory);
184
+
185
+ const files: RegistryFile[] = source.files.map((file) => {
186
+ const path = join(itemDir, file);
187
+ if (!existsSync(path)) {
188
+ throw new Error(
189
+ `Item "${source.name}" lists ${file}, but ${path} does not exist. ` +
190
+ "A registry that names a file it cannot read produces a broken install " +
191
+ "in someone else's project, where it is hardest to diagnose.",
192
+ );
193
+ }
194
+
195
+ const content = readFileSync(path, "utf8");
196
+ assertAuthoredPaths(source.name, content);
197
+ assertDeclaredDependencies(source, content);
198
+
199
+ return {
200
+ path: `${source.group}/${file}`,
201
+ type: GROUP_FILE_TYPE[source.group],
202
+ content,
203
+ hash: hashContent(content),
204
+ };
205
+ });
206
+
207
+ return registryItemSchema.parse({
208
+ registryVersion: REGISTRY_VERSION,
209
+ name: source.name,
210
+ type: registryItemTypeSchema.parse(GROUP_FILE_TYPE[source.group]),
211
+ title: source.title,
212
+ description: source.description,
213
+ category: source.category,
214
+ status: source.status,
215
+ dependencies: source.dependencies,
216
+ registryDependencies: source.registryDependencies,
217
+ files,
218
+ a11y: source.a11y,
219
+ access: source.access,
220
+ });
221
+ }
222
+
223
+ async function readUpstream(base: string): Promise<RegistryItem[]> {
224
+ const isHttp = base.startsWith("http://") || base.startsWith("https://");
225
+
226
+ const load = async (file: string): Promise<unknown> => {
227
+ if (!isHttp) {
228
+ const root = base.startsWith("file:") ? fileURLToPath(base) : base;
229
+ const path = join(root, file);
230
+ if (!existsSync(path)) throw new Error(`Upstream registry has no ${file} at ${path}.`);
231
+ return JSON.parse(readFileSync(path, "utf8"));
232
+ }
233
+
234
+ const url = `${base.replace(/\/$/, "")}/${file}`;
235
+ const response = await fetch(url);
236
+ if (!response.ok) {
237
+ throw new Error(`Upstream registry returned ${String(response.status)} for ${url}.`);
238
+ }
239
+ return await response.json();
240
+ };
241
+
242
+ const index = registryIndexSchema.parse(await load("index.json"));
243
+
244
+ const items: RegistryItem[] = [];
245
+ for (const entry of index.items) {
246
+ // Licensed upstream items have no public body to inherit. They stay out of
247
+ // the derived registry rather than appearing in it as something that cannot
248
+ // be fetched, which would fail at install time instead of at build time.
249
+ if (entry.access === "pro") continue;
250
+ items.push(registryItemSchema.parse(await load(`${entry.name}.json`)));
251
+ }
252
+
253
+ return items;
254
+ }
255
+
256
+ /**
257
+ * Every `registryDependencies` name must exist in the finished registry.
258
+ *
259
+ * Checked here, once, rather than discovered by a consumer whose `add` walks
260
+ * into a name nothing serves. This is the single most common way a
261
+ * hand-assembled registry is broken, and it is invisible until someone installs.
262
+ */
263
+ export function assertResolvable(items: RegistryItem[]): void {
264
+ const known = new Set(items.map((item) => item.name));
265
+ const missing: string[] = [];
266
+
267
+ for (const item of items) {
268
+ for (const dependency of item.registryDependencies) {
269
+ if (!known.has(dependency)) missing.push(`${item.name} → ${dependency}`);
270
+ }
271
+ }
272
+
273
+ if (missing.length > 0) {
274
+ throw new Error(
275
+ `These registry dependencies are not in the registry:\n ${missing.join("\n ")}\n` +
276
+ "Add them, or extend a registry that has them.",
277
+ );
278
+ }
279
+ }
280
+
281
+ export async function buildCustomRegistry(config: RegistryConfig): Promise<BuildResult> {
282
+ const parsed = registryConfigSchema.parse(config);
283
+ const local = parsed.items.map((item) => toItem(parsed.root, item));
284
+
285
+ const duplicates = local
286
+ .map((item) => item.name)
287
+ .filter((name, index, all) => all.indexOf(name) !== index);
288
+ if (duplicates.length > 0) {
289
+ throw new Error(`Declared more than once: ${[...new Set(duplicates)].join(", ")}.`);
290
+ }
291
+
292
+ if (!parsed.extends) {
293
+ assertResolvable(local);
294
+ return { items: local, overridden: [], inherited: 0 };
295
+ }
296
+
297
+ const upstream = await readUpstream(parsed.extends);
298
+ const localNames = new Set(local.map((item) => item.name));
299
+ const overridden = upstream
300
+ .filter((item) => localNames.has(item.name))
301
+ .map((item) => item.name);
302
+
303
+ // Local wins. That is the point of extending rather than mirroring: an
304
+ // organisation replaces the components it has opinions about and inherits the
305
+ // rest.
306
+ const inherited = upstream.filter((item) => !localNames.has(item.name));
307
+ const items = [...inherited, ...local].sort((a, b) => a.name.localeCompare(b.name));
308
+
309
+ assertResolvable(items);
310
+
311
+ return { items, overridden, inherited: inherited.length };
312
+ }