@dowel-ui/registry 0.5.0 → 0.8.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/dist/build.d.ts +32 -4
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +2680 -343
- package/dist/build.js.map +1 -1
- package/dist/generate.d.ts +71 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +550 -0
- package/dist/generate.js.map +1 -0
- package/dist/index.d.ts +184 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +651 -2
- package/dist/index.js.map +1 -0
- package/dist/{schema-ByqIpT47.js → schema-BbyNjH6Q.js} +25 -5
- package/dist/schema-BbyNjH6Q.js.map +1 -0
- package/dist/{schema-Cc32v3R-.d.ts → schema-BmYeKe9e.d.ts} +36 -2
- package/dist/schema-BmYeKe9e.d.ts.map +1 -0
- package/package.json +12 -4
- package/src/agent-docs.ts +547 -0
- package/src/build.ts +66 -4
- package/src/custom.ts +312 -0
- package/src/generate.ts +609 -0
- package/src/index.ts +41 -0
- package/src/schema.ts +23 -0
- package/dist/schema-ByqIpT47.js.map +0 -1
- package/dist/schema-Cc32v3R-.d.ts.map +0 -1
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
|
+
}
|