@jami-studio/registry-schema 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/src/index.mjs ADDED
@@ -0,0 +1,237 @@
1
+ // @jami-studio/registry-schema
2
+ //
3
+ // Single source of truth for the Studio UI registry contract surface: the
4
+ // registry item, the registryRef handshake, the resolved item graph, and the
5
+ // workspace/theme reference shapes. Schemas are authored as JSON Schema 2020-12
6
+ // and validated with AJV in strict mode so every consumer (the registry
7
+ // generator, the CLI, the Husky pre-commit template validator, the Algolia
8
+ // index sync, and the Jami Harness handshake mirror) checks against the exact
9
+ // same rules.
10
+
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import Ajv2020 from "ajv/dist/2020.js";
15
+ import Ajv from "ajv";
16
+ import addFormats from "ajv-formats";
17
+ import { findDuplicateJsonObjectKeys, formatDuplicateJsonObjectKeys } from "./json-source.mjs";
18
+
19
+ const schemaDir = join(dirname(fileURLToPath(import.meta.url)), "schemas");
20
+
21
+ function loadSchema(file) {
22
+ const source = readFileSync(join(schemaDir, file), "utf8");
23
+ const duplicates = findDuplicateJsonObjectKeys(source);
24
+ if (duplicates.length > 0) {
25
+ throw new Error(`${file} contains duplicate JSON object keys:\n- ${formatDuplicateJsonObjectKeys(duplicates).join("\n- ")}`);
26
+ }
27
+ return JSON.parse(source);
28
+ }
29
+
30
+ export const registryRefSchema = loadSchema("registry-ref.schema.json");
31
+ export const agentManifestSchema = loadSchema("agent-manifest.schema.json");
32
+ export const registryItemSchema = loadSchema("registry-item.schema.json");
33
+ export const itemGraphSchema = loadSchema("item-graph.schema.json");
34
+ export const workspaceRefSchema = loadSchema("workspace-ref.schema.json");
35
+ export const themeRefSchema = loadSchema("theme-ref.schema.json");
36
+ export const workbenchStateSchema = loadSchema("workbench-state.schema.json");
37
+ export const shadcnRegistryItemSchema = loadSchema("shadcn-registry-item.schema.json");
38
+
39
+ export const schemas = Object.freeze({
40
+ registryRef: registryRefSchema,
41
+ agentManifest: agentManifestSchema,
42
+ registryItem: registryItemSchema,
43
+ itemGraph: itemGraphSchema,
44
+ workspaceRef: workspaceRefSchema,
45
+ themeRef: themeRefSchema,
46
+ workbenchState: workbenchStateSchema,
47
+ });
48
+
49
+ const schemaIds = Object.freeze({
50
+ registryRef: registryRefSchema.$id,
51
+ agentManifest: agentManifestSchema.$id,
52
+ registryItem: registryItemSchema.$id,
53
+ itemGraph: itemGraphSchema.$id,
54
+ workspaceRef: workspaceRefSchema.$id,
55
+ themeRef: themeRefSchema.$id,
56
+ workbenchState: workbenchStateSchema.$id,
57
+ });
58
+
59
+ export const agentManifestAllowedComponents = Object.freeze(
60
+ agentManifestSchema.properties.residentRenderer.properties.allowedComponents.const,
61
+ );
62
+
63
+ // A fresh strict 2020-12 AJV instance with every contract schema registered, so
64
+ // `$ref`s between them (a registry item embedding a registryRef, a workspaceRef
65
+ // listing installedItems) resolve without per-call wiring.
66
+ export function createAjv(options = {}) {
67
+ const ajv = new Ajv2020({
68
+ strict: true,
69
+ allErrors: true,
70
+ allowUnionTypes: true,
71
+ ...options,
72
+ });
73
+ addFormats(ajv);
74
+ ajv.addSchema(registryRefSchema);
75
+ ajv.addSchema(agentManifestSchema);
76
+ ajv.addSchema(registryItemSchema);
77
+ ajv.addSchema(itemGraphSchema);
78
+ ajv.addSchema(workspaceRefSchema);
79
+ ajv.addSchema(themeRefSchema);
80
+ ajv.addSchema(workbenchStateSchema);
81
+ return ajv;
82
+ }
83
+
84
+ const ajv = createAjv();
85
+
86
+ const validators = Object.freeze({
87
+ registryRef: ajv.getSchema(schemaIds.registryRef),
88
+ agentManifest: ajv.getSchema(schemaIds.agentManifest),
89
+ registryItem: ajv.getSchema(schemaIds.registryItem),
90
+ itemGraph: ajv.getSchema(schemaIds.itemGraph),
91
+ workspaceRef: ajv.getSchema(schemaIds.workspaceRef),
92
+ themeRef: ajv.getSchema(schemaIds.themeRef),
93
+ workbenchState: ajv.getSchema(schemaIds.workbenchState),
94
+ });
95
+
96
+ export const validateRegistryRef = validators.registryRef;
97
+ export const validateAgentManifest = validators.agentManifest;
98
+ export const validateRegistryItem = validators.registryItem;
99
+ export const validateItemGraph = validators.itemGraph;
100
+ export const validateWorkspaceRef = validators.workspaceRef;
101
+ export const validateThemeRef = validators.themeRef;
102
+ export const validateWorkbenchState = validators.workbenchState;
103
+
104
+ export const contractNames = Object.freeze(Object.keys(validators));
105
+
106
+ export function getValidator(name) {
107
+ const validator = validators[name];
108
+ if (!validator) {
109
+ throw new Error(`unknown registry contract "${name}"; expected one of ${contractNames.join(", ")}`);
110
+ }
111
+ return validator;
112
+ }
113
+
114
+ // Format AJV errors into short, deterministic, human-readable lines.
115
+ export function formatErrors(errors) {
116
+ if (!Array.isArray(errors)) return [];
117
+ return errors.map((error) => {
118
+ const where = error.instancePath && error.instancePath.length > 0 ? error.instancePath : "(root)";
119
+ const extra = error.params?.additionalProperty
120
+ ? ` (${error.params.additionalProperty})`
121
+ : error.params?.allowedValues
122
+ ? ` (allowed: ${error.params.allowedValues.join(", ")})`
123
+ : "";
124
+ return `${where} ${error.message}${extra}`;
125
+ });
126
+ }
127
+
128
+ // Validate `data` against a named contract. Returns a structured result and
129
+ // never throws on invalid data, so callers can aggregate failures.
130
+ export function validate(name, data) {
131
+ const validator = getValidator(name);
132
+ const ok = validator(data) === true;
133
+ return {
134
+ ok,
135
+ errors: ok ? [] : (validator.errors ?? []),
136
+ messages: ok ? [] : formatErrors(validator.errors ?? []),
137
+ };
138
+ }
139
+
140
+ // Validate and throw a descriptive error on failure. Returns `data` on success
141
+ // so it can be used inline.
142
+ export function assertValid(name, data, label = name) {
143
+ const result = validate(name, data);
144
+ if (!result.ok) {
145
+ throw new Error(`${label} failed ${name} validation:\n- ${result.messages.join("\n- ")}`);
146
+ }
147
+ return data;
148
+ }
149
+
150
+ export { findDuplicateJsonObjectKeys, formatDuplicateJsonObjectKeys };
151
+
152
+ export {
153
+ WORKSPACE_PART_KINDS,
154
+ WORKSPACE_PACK_KIND,
155
+ NON_PART_DEPENDENCY_KINDS,
156
+ normalizePartType,
157
+ isPartKind,
158
+ isPackKind,
159
+ comparePartKinds,
160
+ classifyRegistryItem,
161
+ partKindContract,
162
+ assertPartKind,
163
+ partTaxonomyContract,
164
+ inventoryParts,
165
+ } from "./part-taxonomy.mjs";
166
+
167
+ // --- shadcn registry-item.json compatibility ---------------------------------
168
+ //
169
+ // The flat `/r/{name}.json` export is consumed by the standard shadcn CLI, so it
170
+ // must validate against the official shadcn `registry-item.json` schema (a vendored
171
+ // draft-07 copy lives in `src/schemas/shadcn-registry-item.schema.json`). The
172
+ // shadcn type enums are narrower than the Jami registry item types, so the export
173
+ // has to map Jami-only types (`registry:app`, `registry:workspace`) onto the
174
+ // shadcn `registry:item` catch-all before it is shadcn-valid.
175
+
176
+ // `validateSchema: false`: the vendored schema declares the draft-07 `$schema`
177
+ // meta, which AJV's default instance does not carry; we trust the vendored copy
178
+ // and skip meta-schema validation while still evaluating every draft-07 keyword.
179
+ const shadcnAjv = new Ajv({ strict: false, allErrors: true, validateSchema: false });
180
+ addFormats(shadcnAjv);
181
+ export const validateShadcnRegistryItem = shadcnAjv.compile(shadcnRegistryItemSchema);
182
+
183
+ export const shadcnItemTypes = Object.freeze(
184
+ new Set(shadcnRegistryItemSchema.properties.type.enum),
185
+ );
186
+ export const shadcnFileTypes = Object.freeze(
187
+ new Set(shadcnRegistryItemSchema.properties.files.items.properties.type.enum),
188
+ );
189
+
190
+ const SHADCN_CATCH_ALL_TYPE = "registry:item";
191
+
192
+ export function toShadcnItemType(type) {
193
+ return shadcnItemTypes.has(type) ? type : SHADCN_CATCH_ALL_TYPE;
194
+ }
195
+
196
+ export function toShadcnFileType(type) {
197
+ return shadcnFileTypes.has(type) ? type : SHADCN_CATCH_ALL_TYPE;
198
+ }
199
+
200
+ // Coerce a Jami registry output item into a shadcn-standard registry item: map
201
+ // Jami-only item/file types onto the shadcn enums, leaving every other field
202
+ // untouched. The result is what the flat `/r/` export ships.
203
+ export function toShadcnRegistryItem(item) {
204
+ return {
205
+ ...item,
206
+ type: toShadcnItemType(item.type),
207
+ ...(Array.isArray(item.files)
208
+ ? { files: item.files.map((file) => ({ ...file, type: toShadcnFileType(file.type) })) }
209
+ : {}),
210
+ };
211
+ }
212
+
213
+ export function validateShadcn(item) {
214
+ const ok = validateShadcnRegistryItem(item) === true;
215
+ return {
216
+ ok,
217
+ errors: ok ? [] : (validateShadcnRegistryItem.errors ?? []),
218
+ messages: ok ? [] : formatErrors(validateShadcnRegistryItem.errors ?? []),
219
+ };
220
+ }
221
+
222
+ export default {
223
+ schemas,
224
+ contractNames,
225
+ createAjv,
226
+ getValidator,
227
+ validate,
228
+ assertValid,
229
+ formatErrors,
230
+ validateAgentManifest,
231
+ agentManifestAllowedComponents,
232
+ validateShadcn,
233
+ validateShadcnRegistryItem,
234
+ toShadcnRegistryItem,
235
+ toShadcnItemType,
236
+ toShadcnFileType,
237
+ };
@@ -0,0 +1,156 @@
1
+ export function findDuplicateJsonObjectKeys(source) {
2
+ const duplicates = [];
3
+ const stack = [];
4
+ let index = 0;
5
+
6
+ function skipWhitespace() {
7
+ while (/\s/.test(source[index] ?? "")) index += 1;
8
+ }
9
+
10
+ function locationAt(offset) {
11
+ let line = 1;
12
+ let column = 1;
13
+ for (let i = 0; i < offset; i += 1) {
14
+ if (source[i] === "\n") {
15
+ line += 1;
16
+ column = 1;
17
+ } else {
18
+ column += 1;
19
+ }
20
+ }
21
+ return { line, column };
22
+ }
23
+
24
+ function parseString(start) {
25
+ let escaped = false;
26
+ for (let cursor = start + 1; cursor < source.length; cursor += 1) {
27
+ const char = source[cursor];
28
+ if (escaped) {
29
+ escaped = false;
30
+ } else if (char === "\\") {
31
+ escaped = true;
32
+ } else if (char === "\"") {
33
+ return {
34
+ value: JSON.parse(source.slice(start, cursor + 1)),
35
+ end: cursor + 1,
36
+ };
37
+ }
38
+ }
39
+ throw new SyntaxError("unterminated JSON string");
40
+ }
41
+
42
+ function current() {
43
+ return stack[stack.length - 1];
44
+ }
45
+
46
+ function markValueComplete() {
47
+ const parent = current();
48
+ if (!parent) return;
49
+ if (parent.type === "object" && parent.expecting === "value") {
50
+ parent.expecting = "commaOrEnd";
51
+ } else if (parent.type === "array" && parent.expecting === "valueOrEnd") {
52
+ parent.expecting = "commaOrEnd";
53
+ }
54
+ }
55
+
56
+ while (index < source.length) {
57
+ skipWhitespace();
58
+ const char = source[index];
59
+ const context = current();
60
+ if (!char) break;
61
+
62
+ if (char === "{") {
63
+ if (context?.type === "object" && context.expecting !== "value") {
64
+ throw new SyntaxError("unexpected object");
65
+ }
66
+ if (context?.type === "array" && context.expecting !== "valueOrEnd") {
67
+ throw new SyntaxError("unexpected object");
68
+ }
69
+ stack.push({ type: "object", expecting: "keyOrEnd", keys: new Map() });
70
+ index += 1;
71
+ continue;
72
+ }
73
+
74
+ if (char === "[") {
75
+ if (context?.type === "object" && context.expecting !== "value") {
76
+ throw new SyntaxError("unexpected array");
77
+ }
78
+ if (context?.type === "array" && context.expecting !== "valueOrEnd") {
79
+ throw new SyntaxError("unexpected array");
80
+ }
81
+ stack.push({ type: "array", expecting: "valueOrEnd" });
82
+ index += 1;
83
+ continue;
84
+ }
85
+
86
+ if (char === "}") {
87
+ if (!context || context.type !== "object") throw new SyntaxError("unexpected object close");
88
+ stack.pop();
89
+ index += 1;
90
+ markValueComplete();
91
+ continue;
92
+ }
93
+
94
+ if (char === "]") {
95
+ if (!context || context.type !== "array") throw new SyntaxError("unexpected array close");
96
+ stack.pop();
97
+ index += 1;
98
+ markValueComplete();
99
+ continue;
100
+ }
101
+
102
+ if (char === ",") {
103
+ if (!context || context.expecting !== "commaOrEnd") throw new SyntaxError("unexpected comma");
104
+ context.expecting = context.type === "object" ? "keyOrEnd" : "valueOrEnd";
105
+ index += 1;
106
+ continue;
107
+ }
108
+
109
+ if (char === ":") {
110
+ if (!context || context.type !== "object" || context.expecting !== "colon") {
111
+ throw new SyntaxError("unexpected colon");
112
+ }
113
+ context.expecting = "value";
114
+ index += 1;
115
+ continue;
116
+ }
117
+
118
+ if (char === "\"") {
119
+ const stringToken = parseString(index);
120
+ if (context?.type === "object" && context.expecting === "keyOrEnd") {
121
+ const firstSeenAt = context.keys.get(stringToken.value);
122
+ if (firstSeenAt) {
123
+ duplicates.push({
124
+ key: stringToken.value,
125
+ first: firstSeenAt,
126
+ duplicate: locationAt(index),
127
+ });
128
+ } else {
129
+ context.keys.set(stringToken.value, locationAt(index));
130
+ }
131
+ context.expecting = "colon";
132
+ } else {
133
+ markValueComplete();
134
+ }
135
+ index = stringToken.end;
136
+ continue;
137
+ }
138
+
139
+ const primitiveMatch = source.slice(index).match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|^(?:true|false|null)/);
140
+ if (!primitiveMatch) {
141
+ throw new SyntaxError(`unexpected JSON token ${char}`);
142
+ }
143
+ index += primitiveMatch[0].length;
144
+ markValueComplete();
145
+ }
146
+
147
+ if (stack.length > 0) throw new SyntaxError("unterminated JSON value");
148
+ return duplicates;
149
+ }
150
+
151
+ export function formatDuplicateJsonObjectKeys(duplicates) {
152
+ return duplicates.map(
153
+ ({ key, first, duplicate }) =>
154
+ `"${key}" first seen at ${first.line}:${first.column}, duplicated at ${duplicate.line}:${duplicate.column}`,
155
+ );
156
+ }
@@ -0,0 +1,158 @@
1
+ // Workspace-pack part taxonomy.
2
+ //
3
+ // A workspace pack (a `workspace` registry item) composes five reusable part
4
+ // kinds. This module is the single source of truth for that taxonomy: the kinds,
5
+ // their composition order (foundation-up), the role of each kind, and a pure
6
+ // classifier over registry items. The registry generator, the workspace-evidence
7
+ // generator, the CLI, and the workbench all read the taxonomy from here so the
8
+ // "app / page / block / component / primitive" vocabulary never drifts between
9
+ // surfaces.
10
+ //
11
+ // theme and font registry items are token/asset dependencies a pack pulls in, not
12
+ // composition parts; the workspace item itself is the pack container, not a part.
13
+
14
+ // Foundation-up order: a primitive composes from tokens, a component from
15
+ // primitives, a block binds one component to workspace state, a page composes
16
+ // blocks, an app composes pages. Index encodes composition depth.
17
+ export const WORKSPACE_PART_KINDS = Object.freeze(["primitive", "component", "block", "page", "app"]);
18
+
19
+ export const WORKSPACE_PACK_KIND = "workspace";
20
+
21
+ // Registry item types that are pack dependencies but not composition parts.
22
+ export const NON_PART_DEPENDENCY_KINDS = Object.freeze(["theme", "font"]);
23
+
24
+ const PART_KIND_INDEX = Object.freeze(
25
+ Object.fromEntries(WORKSPACE_PART_KINDS.map((kind, index) => [kind, index])),
26
+ );
27
+
28
+ // Durable contract describing each part kind, what it composes from, and the
29
+ // invariants every part of that kind must hold. `composesFrom: null` marks the
30
+ // foundation (primitives compose from design tokens, which are not parts).
31
+ const PART_TAXONOMY = Object.freeze({
32
+ primitive: Object.freeze({
33
+ kind: "primitive",
34
+ order: PART_KIND_INDEX.primitive,
35
+ role: "Radix-first resident shadcn primitive; the smallest installable accessible building block.",
36
+ composesFrom: null,
37
+ foundation: "design tokens",
38
+ }),
39
+ component: Object.freeze({
40
+ kind: "component",
41
+ order: PART_KIND_INDEX.component,
42
+ role: "Resident component composed from primitives with a stable display contract.",
43
+ composesFrom: "primitive",
44
+ foundation: null,
45
+ }),
46
+ block: Object.freeze({
47
+ kind: "block",
48
+ order: PART_KIND_INDEX.block,
49
+ role: "Titled binding of one component to a workspace state surface (ready/loading/error/empty).",
50
+ composesFrom: "component",
51
+ foundation: null,
52
+ }),
53
+ page: Object.freeze({
54
+ kind: "page",
55
+ order: PART_KIND_INDEX.page,
56
+ role: "Route surface composing blocks into a labelled, landmarked page.",
57
+ composesFrom: "block",
58
+ foundation: null,
59
+ }),
60
+ app: Object.freeze({
61
+ kind: "app",
62
+ order: PART_KIND_INDEX.app,
63
+ role: "Full workspace app shell composing pages behind navigation; the installable pack surface.",
64
+ composesFrom: "page",
65
+ foundation: null,
66
+ }),
67
+ });
68
+
69
+ // The generated/shadcn registry output prefixes Jami types as `registry:<type>`
70
+ // and maps `primitive` onto shadcn's `registry:ui`. Normalize both the authored
71
+ // bare type and the generated output type onto the canonical Jami vocabulary so
72
+ // the classifier works on a source item and its generated output alike.
73
+ const SHADCN_TYPE_ALIASES = Object.freeze({ "registry:ui": "primitive" });
74
+
75
+ export function normalizePartType(type) {
76
+ if (typeof type !== "string") return type;
77
+ if (Object.prototype.hasOwnProperty.call(SHADCN_TYPE_ALIASES, type)) return SHADCN_TYPE_ALIASES[type];
78
+ if (type.startsWith("registry:")) return type.slice("registry:".length);
79
+ return type;
80
+ }
81
+
82
+ export function isPartKind(type) {
83
+ return Object.prototype.hasOwnProperty.call(PART_KIND_INDEX, normalizePartType(type));
84
+ }
85
+
86
+ export function isPackKind(type) {
87
+ return normalizePartType(type) === WORKSPACE_PACK_KIND;
88
+ }
89
+
90
+ // Stable foundation-up comparator over part kinds (primitive < ... < app).
91
+ // Unknown kinds sort after all known kinds, then by code unit, so callers get a
92
+ // total, locale-independent order without throwing.
93
+ export function comparePartKinds(a, b) {
94
+ const na = normalizePartType(a);
95
+ const nb = normalizePartType(b);
96
+ const ai = isPartKind(na) ? PART_KIND_INDEX[na] : WORKSPACE_PART_KINDS.length;
97
+ const bi = isPartKind(nb) ? PART_KIND_INDEX[nb] : WORKSPACE_PART_KINDS.length;
98
+ if (ai !== bi) return ai - bi;
99
+ return na < nb ? -1 : na > nb ? 1 : 0;
100
+ }
101
+
102
+ // Pure classifier over a registry item (source or generated output shape). Reads
103
+ // only the `type` field, so it works on both the authored item and the flat
104
+ // shadcn export's pre-mapped Jami type.
105
+ export function classifyRegistryItem(item) {
106
+ const rawType = item?.type ?? null;
107
+ const type = normalizePartType(rawType);
108
+ return {
109
+ type,
110
+ rawType,
111
+ kind: isPartKind(type) ? type : null,
112
+ isPart: isPartKind(type),
113
+ isPack: isPackKind(type),
114
+ isDependency: NON_PART_DEPENDENCY_KINDS.includes(type),
115
+ };
116
+ }
117
+
118
+ export function partKindContract(kind) {
119
+ const contract = PART_TAXONOMY[kind];
120
+ if (!contract) {
121
+ throw new Error(`unknown workspace part kind "${kind}"; expected one of ${WORKSPACE_PART_KINDS.join(", ")}`);
122
+ }
123
+ return contract;
124
+ }
125
+
126
+ export function assertPartKind(type, label = "registry item type") {
127
+ if (!isPartKind(type)) {
128
+ throw new Error(`${label} "${type}" is not a workspace part kind; expected one of ${WORKSPACE_PART_KINDS.join(", ")}`);
129
+ }
130
+ return type;
131
+ }
132
+
133
+ // The whole taxonomy as a frozen, ordered descriptor — what durable docs and the
134
+ // evidence `index.json` embed so the contract is self-describing on disk.
135
+ export function partTaxonomyContract() {
136
+ return Object.freeze({
137
+ schemaVersion: "2026-06-24.workspace-part-taxonomy",
138
+ packKind: WORKSPACE_PACK_KIND,
139
+ partKinds: WORKSPACE_PART_KINDS,
140
+ nonPartDependencyKinds: NON_PART_DEPENDENCY_KINDS,
141
+ parts: WORKSPACE_PART_KINDS.map((kind) => PART_TAXONOMY[kind]),
142
+ });
143
+ }
144
+
145
+ // Group an iterable of registry items into a part inventory keyed by kind, in
146
+ // foundation-up order, with packs and non-part dependencies bucketed separately.
147
+ export function inventoryParts(items) {
148
+ const parts = Object.fromEntries(WORKSPACE_PART_KINDS.map((kind) => [kind, []]));
149
+ const packs = [];
150
+ const dependencies = [];
151
+ for (const item of items ?? []) {
152
+ const classified = classifyRegistryItem(item);
153
+ if (classified.isPart) parts[classified.kind].push(item);
154
+ else if (classified.isPack) packs.push(item);
155
+ else if (classified.isDependency) dependencies.push(item);
156
+ }
157
+ return { parts, packs, dependencies };
158
+ }