@feeef.dev/cli 0.2.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.
Files changed (61) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +94 -0
  3. package/dist/bin.js +2752 -0
  4. package/dist/bin.js.map +1 -0
  5. package/dist/index.d.ts +6 -0
  6. package/dist/index.js +2730 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +67 -0
  9. package/scaffolds/blank/.cursor/rules/feeef-theme.mdc +34 -0
  10. package/scaffolds/blank/.cursor/rules/filterator.mdc +13 -0
  11. package/scaffolds/blank/.cursor/rules/order-form.mdc +17 -0
  12. package/scaffolds/blank/.cursor/rules/theme-source.mdc +32 -0
  13. package/scaffolds/blank/.cursor/skills/feeef-filterator/SKILL.md +15 -0
  14. package/scaffolds/blank/.cursor/skills/feeef-theme/SKILL.md +56 -0
  15. package/scaffolds/blank/.cursor/skills/feeef-theme/reference.md +73 -0
  16. package/scaffolds/blank/.vscode/extensions.json +3 -0
  17. package/scaffolds/blank/.vscode/settings.json +13 -0
  18. package/scaffolds/blank/AGENTS.md +104 -0
  19. package/scaffolds/blank/README.md +21 -0
  20. package/scaffolds/blank/docs/00-INDEX.md +33 -0
  21. package/scaffolds/blank/docs/03-CUSTOM-COMPONENTS.md +116 -0
  22. package/scaffolds/blank/docs/04-WORKFLOW.md +129 -0
  23. package/scaffolds/blank/docs/05-ANTI-PATTERNS.md +80 -0
  24. package/scaffolds/blank/docs/07-SHARED-COMPONENTS.md +144 -0
  25. package/scaffolds/blank/docs/08-ORDER-FORM.md +376 -0
  26. package/scaffolds/blank/docs/09-THEME-PORTING.md +211 -0
  27. package/scaffolds/blank/docs/10-SCHEMA-LIBRARY.md +127 -0
  28. package/scaffolds/blank/docs/11-PAGES-CHECKOUT-THANKS-PRODUCT.md +81 -0
  29. package/scaffolds/blank/docs/12-DARK-LIGHT-THEME.md +164 -0
  30. package/scaffolds/blank/docs/13-TEMPLATE-I18N.md +320 -0
  31. package/scaffolds/blank/docs/14-FILTERATOR.md +433 -0
  32. package/scaffolds/blank/docs/16-AUTHORING-TS-IMPORTS.md +73 -0
  33. package/scaffolds/blank/feeef.template.json +7 -0
  34. package/scaffolds/blank/locales/README.md +4 -0
  35. package/scaffolds/blank/locales/ar.json +10 -0
  36. package/scaffolds/blank/locales/en.json +10 -0
  37. package/scaffolds/blank/package-lock.json +1975 -0
  38. package/scaffolds/blank/package.json +23 -0
  39. package/scaffolds/blank/pages/home/components/hero.tsx +34 -0
  40. package/scaffolds/blank/pages/home/page.json +3 -0
  41. package/scaffolds/blank/props.json +6 -0
  42. package/scaffolds/blank/schema.ts +49 -0
  43. package/scaffolds/blank/tsconfig.json +24 -0
  44. package/scaffolds/blank/types/feeef-live-scope.d.ts +162 -0
  45. package/scaffolds/blank/types/feeef-template-schema.d.ts +84 -0
  46. package/vendor/template-kit/README.md +64 -0
  47. package/vendor/template-kit/bin/dev.mjs +61 -0
  48. package/vendor/template-kit/bin/feeef-template.mjs +212 -0
  49. package/vendor/template-kit/data/lithium-schema.json +4733 -0
  50. package/vendor/template-kit/lib/build.mjs +377 -0
  51. package/vendor/template-kit/lib/compile-component.mjs +188 -0
  52. package/vendor/template-kit/lib/component-meta.mjs +481 -0
  53. package/vendor/template-kit/lib/library.mjs +168 -0
  54. package/vendor/template-kit/lib/locales.mjs +68 -0
  55. package/vendor/template-kit/lib/paths.mjs +84 -0
  56. package/vendor/template-kit/lib/shared.mjs +268 -0
  57. package/vendor/template-kit/lib/theme-schema.mjs +300 -0
  58. package/vendor/template-kit/lib/unpack.mjs +324 -0
  59. package/vendor/template-kit/lib/validate.mjs +207 -0
  60. package/vendor/template-kit/schemas/component.schema.json +55 -0
  61. package/vendor/template-kit/schemas/template.schema.json +18 -0
@@ -0,0 +1,481 @@
1
+ /**
2
+ * Load component metadata from `export const meta` in a component entry,
3
+ * with fallback to legacy `component.json`.
4
+ *
5
+ * Entries may be:
6
+ * - Flat file: `components/hero.tsx` (or `.jsx` / `.json`)
7
+ * - Folder: `components/hero/component.tsx` (+ optional nested slots/children)
8
+ */
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import vm from "node:vm";
13
+ import { createRequire } from "node:module";
14
+ import { fileURLToPath } from "node:url";
15
+ import { COMPONENT_META_KEYS, rel } from "./paths.mjs";
16
+
17
+ const require = createRequire(import.meta.url);
18
+
19
+ /** Flat / folder entry extensions (basename, not `slots.empty.json`). */
20
+ const FLAT_CODE_EXT = new Set([".tsx", ".jsx"]);
21
+ const FLAT_META_EXT = new Set([".tsx", ".jsx", ".json"]);
22
+
23
+ /**
24
+ * @returns {typeof import('esbuild') | null}
25
+ */
26
+ function loadEsbuild() {
27
+ try {
28
+ return require("esbuild");
29
+ } catch {
30
+ try {
31
+ const here = path.dirname(fileURLToPath(import.meta.url));
32
+ const cliRoot = path.resolve(here, "../../..");
33
+ return createRequire(path.join(cliRoot, "package.json"))("esbuild");
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Component id from an entry path (`hero.tsx` → `hero`, `hero/` → `hero`).
42
+ * @param {string} entryPath
43
+ */
44
+ export function componentIdFromPath(entryPath) {
45
+ const base = path.basename(entryPath);
46
+ const ext = path.extname(base).toLowerCase();
47
+ if (FLAT_META_EXT.has(ext)) return base.slice(0, -ext.length);
48
+ return base;
49
+ }
50
+
51
+ /**
52
+ * Whether `root` is a flat component file (not a directory).
53
+ * @param {string} root
54
+ */
55
+ export function isFlatComponentFile(root) {
56
+ try {
57
+ if (!fs.statSync(root).isFile()) return false;
58
+ } catch {
59
+ return false;
60
+ }
61
+ const ext = path.extname(root).toLowerCase();
62
+ return FLAT_META_EXT.has(ext) && path.basename(root) !== "slots.empty.json";
63
+ }
64
+
65
+ /**
66
+ * Resolve the TSX/JSX source file for a component root (dir or flat file).
67
+ * @param {string} root Absolute path to component folder or flat `.tsx`/`.jsx`/`.json`
68
+ * @returns {string | null}
69
+ */
70
+ export function resolveComponentEntry(root) {
71
+ if (isFlatComponentFile(root)) {
72
+ const ext = path.extname(root).toLowerCase();
73
+ if (FLAT_CODE_EXT.has(ext)) return root;
74
+ return null;
75
+ }
76
+ const tsx = path.join(root, "component.tsx");
77
+ const jsx = path.join(root, "component.jsx");
78
+ if (fs.existsSync(tsx)) return tsx;
79
+ if (fs.existsSync(jsx)) return jsx;
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * List component entries under a `components/` (or `children/` / `slots/<id>/`) dir.
85
+ * Supports flat `name.tsx|jsx|json` and legacy `name/` folders.
86
+ * Prefer folder when both `name/` and `name.tsx` exist (warn via thrown Error).
87
+ *
88
+ * @param {string} componentsDir
89
+ * @returns {string[]} Absolute entry paths, sorted by meta.order then id
90
+ */
91
+ export function listComponentEntries(componentsDir) {
92
+ if (!fs.existsSync(componentsDir)) return [];
93
+
94
+ /** @type {Map<string, { id: string; path: string; kind: 'dir' | 'file' }>} */
95
+ const byId = new Map();
96
+
97
+ for (const ent of fs.readdirSync(componentsDir, { withFileTypes: true })) {
98
+ if (ent.name.startsWith(".")) continue;
99
+
100
+ if (ent.isDirectory()) {
101
+ const id = ent.name;
102
+ const abs = path.join(componentsDir, id);
103
+ const prev = byId.get(id);
104
+ if (prev && prev.kind === "file") {
105
+ throw new Error(
106
+ `Component id "${id}" has both ${rel(prev.path)} and folder ${rel(abs)} — keep one`,
107
+ );
108
+ }
109
+ byId.set(id, { id, path: abs, kind: "dir" });
110
+ continue;
111
+ }
112
+
113
+ if (!ent.isFile()) continue;
114
+ if (ent.name === "slots.empty.json") continue;
115
+ const ext = path.extname(ent.name).toLowerCase();
116
+ if (!FLAT_META_EXT.has(ext)) continue;
117
+
118
+ const id = ent.name.slice(0, -ext.length);
119
+ const abs = path.join(componentsDir, ent.name);
120
+ const prev = byId.get(id);
121
+ if (prev) {
122
+ if (prev.kind === "dir") {
123
+ throw new Error(
124
+ `Component id "${id}" has both folder ${rel(prev.path)} and ${rel(abs)} — keep one`,
125
+ );
126
+ }
127
+ // Prefer .tsx > .jsx > .json when duplicates
128
+ const rank = (p) =>
129
+ p.endsWith(".tsx") ? 0 : p.endsWith(".jsx") ? 1 : 2;
130
+ if (rank(abs) < rank(prev.path)) {
131
+ byId.set(id, { id, path: abs, kind: "file" });
132
+ }
133
+ continue;
134
+ }
135
+ byId.set(id, { id, path: abs, kind: "file" });
136
+ }
137
+
138
+ /** @type {{ id: string; order: number; path: string }[]} */
139
+ const entries = [...byId.values()].map(({ id, path: abs }) => {
140
+ let order = Number.MAX_SAFE_INTEGER;
141
+ try {
142
+ const { meta } = loadComponentMeta(abs);
143
+ if (typeof meta.order === "number") order = meta.order;
144
+ } catch {
145
+ // Missing meta — keep at end; readComponentRaw will throw later.
146
+ }
147
+ return { id, order, path: abs };
148
+ });
149
+
150
+ entries.sort((a, b) => {
151
+ if (a.order !== b.order) return a.order - b.order;
152
+ return a.id.localeCompare(b.id);
153
+ });
154
+ return entries.map((e) => e.path);
155
+ }
156
+
157
+ /**
158
+ * Strip `export const meta` / `componentMeta` so live compile only keeps App.
159
+ * @param {string} source
160
+ */
161
+ export function stripMetaExport(source) {
162
+ let out = source;
163
+ out = removeExportedConst(out, "meta");
164
+ out = removeExportedConst(out, "componentMeta");
165
+ out = out.replace(
166
+ /export\s*\{[^}]*\bmeta\b[^}]*\}\s*;?/g,
167
+ "/* stripped meta re-export */",
168
+ );
169
+ // Authoring-only schema binding (used by meta + FeeefLivePropsOf) — not for react-live.
170
+ out = removeTopLevelConst(out, "propsSchema");
171
+ return out;
172
+ }
173
+
174
+ /**
175
+ * Remove a top-level `const name = …;` (object/array/literal), including `as const`.
176
+ * @param {string} source
177
+ * @param {string} name
178
+ */
179
+ function removeTopLevelConst(source, name) {
180
+ const re = new RegExp(String.raw`(?:^|\n)const\s+${name}\s*=\s*`);
181
+ const m = re.exec(source);
182
+ if (!m || m.index == null) return source;
183
+ const start = m[0].startsWith("\n") ? m.index + 1 : m.index;
184
+ let i = m.index + m[0].length;
185
+ i = skipValue(source, i);
186
+ i = skipMetaTrailer(source, i);
187
+ return source.slice(0, start) + source.slice(i);
188
+ }
189
+
190
+ /**
191
+ * Remove `export const name = …;` (object/array/literal) via brace scan.
192
+ * @param {string} source
193
+ * @param {string} name
194
+ */
195
+ function removeExportedConst(source, name) {
196
+ const re = new RegExp(
197
+ String.raw`export\s+const\s+${name}(?:\s+satisfies\s+[^=]+)?\s*=\s*`,
198
+ );
199
+ const m = re.exec(source);
200
+ if (!m || m.index == null) return source;
201
+ const start = m.index;
202
+ let i = m.index + m[0].length;
203
+ // Skip value
204
+ i = skipValue(source, i);
205
+ // trailing `as const` / `satisfies T` / `;` / whitespace
206
+ i = skipMetaTrailer(source, i);
207
+ return source.slice(0, start) + source.slice(i);
208
+ }
209
+
210
+ /**
211
+ * Skip `as const`, `satisfies Type`, semicolons after a meta value.
212
+ * @param {string} source
213
+ * @param {number} i
214
+ */
215
+ function skipMetaTrailer(source, i) {
216
+ for (;;) {
217
+ while (i < source.length && /\s/.test(source[i])) i++;
218
+ if (source.slice(i, i + 8) === "as const") {
219
+ i += 8;
220
+ continue;
221
+ }
222
+ if (source.slice(i, i + 9) === "satisfies") {
223
+ i += 9;
224
+ while (i < source.length && /\s/.test(source[i])) i++;
225
+ // skip type expression until `;` or newline before next top-level
226
+ i = skipTypeExpression(source, i);
227
+ continue;
228
+ }
229
+ if (source[i] === ";") {
230
+ i++;
231
+ if (source[i] === "\n") i++;
232
+ break;
233
+ }
234
+ if (source[i] === "\n") {
235
+ i++;
236
+ break;
237
+ }
238
+ break;
239
+ }
240
+ return i;
241
+ }
242
+
243
+ /**
244
+ * Skip a TypeScript type expression (identifiers, `.`, `<>`, `[]`, `|`, `&`, `typeof`).
245
+ * @param {string} source
246
+ * @param {number} i
247
+ */
248
+ function skipTypeExpression(source, i) {
249
+ let depthAngle = 0;
250
+ let depthParen = 0;
251
+ let depthBracket = 0;
252
+ while (i < source.length) {
253
+ const c = source[i];
254
+ if (c === "<") depthAngle++;
255
+ else if (c === ">") depthAngle = Math.max(0, depthAngle - 1);
256
+ else if (c === "(") depthParen++;
257
+ else if (c === ")") depthParen = Math.max(0, depthParen - 1);
258
+ else if (c === "[") depthBracket++;
259
+ else if (c === "]") depthBracket = Math.max(0, depthBracket - 1);
260
+ else if (
261
+ (c === ";" || c === "\n") &&
262
+ depthAngle === 0 &&
263
+ depthParen === 0 &&
264
+ depthBracket === 0
265
+ ) {
266
+ break;
267
+ }
268
+ i++;
269
+ }
270
+ return i;
271
+ }
272
+
273
+ /**
274
+ * @param {string} source
275
+ * @param {number} i
276
+ */
277
+ function skipValue(source, i) {
278
+ const ch = source[i];
279
+ if (ch === "{" || ch === "[") {
280
+ const open = ch;
281
+ const close = ch === "{" ? "}" : "]";
282
+ let depth = 0;
283
+ for (; i < source.length; i++) {
284
+ const c = source[i];
285
+ if (c === "'" || c === '"' || c === "`") {
286
+ const q = c;
287
+ i++;
288
+ while (i < source.length) {
289
+ if (source[i] === "\\") {
290
+ i += 2;
291
+ continue;
292
+ }
293
+ if (source[i] === q) break;
294
+ i++;
295
+ }
296
+ continue;
297
+ }
298
+ if (c === open) depth++;
299
+ else if (c === close) {
300
+ depth--;
301
+ if (depth === 0) return i + 1;
302
+ }
303
+ }
304
+ return source.length;
305
+ }
306
+ // identifier / number / string literal until `;` or newline+`as`
307
+ while (i < source.length && source[i] !== ";" && source[i] !== "\n") i++;
308
+ return i;
309
+ }
310
+
311
+ /**
312
+ * Evaluate `export const meta` from component source without running App UI.
313
+ * @param {string} source
314
+ * @param {string} filePath
315
+ * @returns {Record<string, any> | null}
316
+ */
317
+ export function extractMetaFromSource(source, filePath) {
318
+ if (
319
+ !/\bexport\s+const\s+(meta|componentMeta)\b/.test(source) &&
320
+ !/\bexport\s*\{[^}]*\bmeta\b/.test(source)
321
+ ) {
322
+ return null;
323
+ }
324
+
325
+ const esbuild = loadEsbuild();
326
+ if (!esbuild) {
327
+ throw new Error(
328
+ `Found export const meta in ${rel(filePath)} but esbuild is not installed`,
329
+ );
330
+ }
331
+
332
+ const neutralized = stubAppFunction(source);
333
+ const loader = filePath.endsWith(".tsx") ? "tsx" : "jsx";
334
+ const transformed = esbuild.transformSync(neutralized, {
335
+ loader,
336
+ format: "cjs",
337
+ target: "es2018",
338
+ jsx: "transform",
339
+ logLevel: "silent",
340
+ });
341
+
342
+ const module = { exports: /** @type {Record<string, any>} */ ({}) };
343
+ const sandbox = {
344
+ module,
345
+ exports: module.exports,
346
+ require: () => ({}),
347
+ React: {
348
+ createElement: () => null,
349
+ Fragment: "Fragment",
350
+ useState: (v) => [v, () => {}],
351
+ useEffect: () => {},
352
+ useRef: (v) => ({ current: v }),
353
+ useMemo: (fn) => fn(),
354
+ useCallback: (fn) => fn,
355
+ },
356
+ console,
357
+ };
358
+
359
+ try {
360
+ vm.runInNewContext(
361
+ `${transformed.code}\n; module.exports = Object.assign({}, module.exports, exports);`,
362
+ sandbox,
363
+ { filename: filePath, timeout: 2000 },
364
+ );
365
+ } catch (err) {
366
+ throw new Error(
367
+ `Failed to evaluate meta export in ${rel(filePath)}: ${
368
+ err instanceof Error ? err.message : String(err)
369
+ }`,
370
+ );
371
+ }
372
+
373
+ const meta =
374
+ sandbox.module.exports?.meta ??
375
+ sandbox.module.exports?.componentMeta ??
376
+ null;
377
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
378
+ return null;
379
+ }
380
+ return { ...meta };
381
+ }
382
+
383
+ /**
384
+ * Replace `function App(...) { ... }` with a no-op stub (brace-balanced).
385
+ * @param {string} source
386
+ */
387
+ function stubAppFunction(source) {
388
+ const re = /\bfunction\s+App\s*\([^)]*\)\s*\{/;
389
+ const m = re.exec(source);
390
+ if (!m || m.index == null) return source;
391
+ const start = m.index;
392
+ const bodyStart = m.index + m[0].length - 1; // at '{'
393
+ let depth = 0;
394
+ let i = bodyStart;
395
+ for (; i < source.length; i++) {
396
+ const ch = source[i];
397
+ if (ch === "{") depth++;
398
+ else if (ch === "}") {
399
+ depth--;
400
+ if (depth === 0) {
401
+ i++;
402
+ break;
403
+ }
404
+ } else if (ch === "'" || ch === '"' || ch === "`") {
405
+ // skip strings
406
+ const quote = ch;
407
+ i++;
408
+ while (i < source.length) {
409
+ if (source[i] === "\\") {
410
+ i += 2;
411
+ continue;
412
+ }
413
+ if (source[i] === quote) break;
414
+ i++;
415
+ }
416
+ }
417
+ }
418
+ return (
419
+ source.slice(0, start) +
420
+ "function App(){ return null; }\n" +
421
+ source.slice(i)
422
+ );
423
+ }
424
+
425
+ /**
426
+ * Load meta for a component entry (folder or flat file).
427
+ * Preference: `export const meta` in .tsx/.jsx → sibling/flat `.json` / `component.json`.
428
+ * @param {string} root Absolute path to component folder or flat file
429
+ * @returns {{ meta: Record<string, any>, source: 'tsx' | 'json', entry: string | null, jsonPath: string | null }}
430
+ */
431
+ export function loadComponentMeta(root) {
432
+ if (isFlatComponentFile(root)) {
433
+ const ext = path.extname(root).toLowerCase();
434
+ if (ext === ".json") {
435
+ const meta = JSON.parse(fs.readFileSync(root, "utf8"));
436
+ return { meta, source: "json", entry: null, jsonPath: root };
437
+ }
438
+ const source = fs.readFileSync(root, "utf8");
439
+ const fromTs = extractMetaFromSource(source, root);
440
+ if (fromTs) {
441
+ return { meta: fromTs, source: "tsx", entry: root, jsonPath: null };
442
+ }
443
+ throw new Error(
444
+ `Missing export const meta in ${rel(root)} — add meta or use a .json leaf`,
445
+ );
446
+ }
447
+
448
+ const entry = resolveComponentEntry(root);
449
+ if (entry) {
450
+ const source = fs.readFileSync(entry, "utf8");
451
+ const fromTs = extractMetaFromSource(source, entry);
452
+ if (fromTs) {
453
+ return { meta: fromTs, source: "tsx", entry, jsonPath: null };
454
+ }
455
+ }
456
+
457
+ const jsonPath = path.join(root, "component.json");
458
+ if (fs.existsSync(jsonPath)) {
459
+ const meta = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
460
+ return { meta, source: "json", entry, jsonPath };
461
+ }
462
+
463
+ throw new Error(
464
+ `Missing component meta in ${rel(root)} — add export const meta in component.tsx / <name>.tsx or component.json`,
465
+ );
466
+ }
467
+
468
+ /**
469
+ * Serialize meta as a TS `export const meta = …` preamble for unpack.
470
+ * @param {Record<string, any>} meta
471
+ */
472
+ export function formatMetaExport(meta) {
473
+ const clean = { ...meta };
474
+ delete clean.$schema;
475
+ delete clean.code;
476
+ // Keep order + COMPONENT_META_KEYS + any extra catalog fields already on meta
477
+ const json = JSON.stringify(clean, null, 2);
478
+ return `export const meta = ${json} as const;\n\n`;
479
+ }
480
+
481
+ export { COMPONENT_META_KEYS };
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Theme component library (`schema.library.json`).
3
+ *
4
+ * Author under:
5
+ * <theme>/library/components/<id>.tsx # preferred leaf
6
+ * <theme>/library/components/<id>/ # folder when slots/children needed
7
+ * component.json / component.tsx
8
+ * slots/… children/…
9
+ *
10
+ * Entries may never appear in data.json — they exist so the merchant editor
11
+ * can drag-drop them into pages. Build emits `dist/schema.library.json` and
12
+ * the same list is embedded on the template schema as `library: [...]`.
13
+ */
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import {
18
+ componentIdFromPath,
19
+ listComponentEntries,
20
+ } from "./component-meta.mjs";
21
+
22
+ /**
23
+ * Catalog metadata keys (not part of TemplateDataComponent runtime).
24
+ */
25
+ const CATALOG_KEYS = new Set([
26
+ "libraryId",
27
+ "id",
28
+ "title",
29
+ "subtitle",
30
+ "category",
31
+ "tags",
32
+ "imageUrl",
33
+ "order",
34
+ "$schema",
35
+ "$ref",
36
+ "shared",
37
+ ]);
38
+
39
+ /**
40
+ * Build schema.library.json document from disk.
41
+ *
42
+ * @param {object} opts
43
+ * @param {string} opts.srcDir
44
+ * @param {(dir: string, fileStack: string[]) => Record<string, any>} opts.readComponentRaw
45
+ * @param {(node: any, catalog: Map<string, any>, resolving: Set<string>, hint: string) => any} [opts.resolveSharedInTree]
46
+ * @param {Map<string, any>} [opts.sharedCatalog]
47
+ */
48
+ export function buildSchemaLibrary({
49
+ srcDir,
50
+ readComponentRaw,
51
+ resolveSharedInTree,
52
+ sharedCatalog,
53
+ }) {
54
+ const libraryRoot = path.join(srcDir, "library", "components");
55
+ /** @type {any[]} */
56
+ const components = [];
57
+
58
+ if (!fs.existsSync(libraryRoot)) {
59
+ return {
60
+ version: "1.0",
61
+ components: [],
62
+ };
63
+ }
64
+
65
+ const fileStack = [];
66
+ const resolving = new Set();
67
+ const catalog = sharedCatalog || new Map();
68
+
69
+ for (const entryPath of listComponentEntries(libraryRoot)) {
70
+ const folderName = componentIdFromPath(entryPath);
71
+ let raw = readComponentRaw(entryPath, fileStack);
72
+
73
+ if (resolveSharedInTree && catalog.size > 0) {
74
+ raw = resolveSharedInTree(
75
+ raw,
76
+ catalog,
77
+ resolving,
78
+ `library/${folderName}`,
79
+ );
80
+ }
81
+
82
+ const id =
83
+ (typeof raw.libraryId === "string" && raw.libraryId.trim()) ||
84
+ (typeof raw.id === "string" && raw.id.trim()) ||
85
+ folderName;
86
+
87
+ const title =
88
+ (typeof raw.title === "string" && raw.title.trim()) ||
89
+ id;
90
+
91
+ /** @type {Record<string, any>} */
92
+ const entry = {
93
+ id,
94
+ title,
95
+ subtitle: raw.subtitle ?? null,
96
+ category: raw.category ?? "library",
97
+ tags: Array.isArray(raw.tags) ? raw.tags : [],
98
+ imageUrl: raw.imageUrl ?? null,
99
+ // Insert type: usually "custom"; may be a built-in registry type.
100
+ type: typeof raw.type === "string" && raw.type ? raw.type : "custom",
101
+ code: typeof raw.code === "string" ? raw.code : null,
102
+ propsSchema:
103
+ raw.propsSchema && typeof raw.propsSchema === "object"
104
+ ? raw.propsSchema
105
+ : null,
106
+ propsDefault:
107
+ raw.props && typeof raw.props === "object" ? raw.props : {},
108
+ slotsSchema:
109
+ raw.slotsSchema && typeof raw.slotsSchema === "object"
110
+ ? raw.slotsSchema
111
+ : null,
112
+ slotsDefault:
113
+ raw.slots && typeof raw.slots === "object" ? raw.slots : null,
114
+ slotsLayout:
115
+ raw.slotsLayout && typeof raw.slotsLayout === "object"
116
+ ? raw.slotsLayout
117
+ : null,
118
+ children: Array.isArray(raw.children) ? raw.children : null,
119
+ };
120
+
121
+ // Preserve unknown forward-compat fields (except catalog/runtime noise).
122
+ for (const [k, v] of Object.entries(raw)) {
123
+ if (CATALOG_KEYS.has(k)) continue;
124
+ if (
125
+ [
126
+ "type",
127
+ "code",
128
+ "props",
129
+ "propsSchema",
130
+ "slots",
131
+ "slotsSchema",
132
+ "slotsLayout",
133
+ "children",
134
+ "instanceId",
135
+ "refId",
136
+ "refVersion",
137
+ ].includes(k)
138
+ ) {
139
+ continue;
140
+ }
141
+ if (v !== undefined && !(k in entry)) entry[k] = v;
142
+ }
143
+
144
+ components.push(entry);
145
+ }
146
+
147
+ // Stable order: optional numeric order on disk via folder order already sorted;
148
+ // secondary sort by title.
149
+ components.sort((a, b) => String(a.title).localeCompare(String(b.title)));
150
+
151
+ return {
152
+ version: "1.0",
153
+ components,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Embed library list onto a schema document (non-destructive).
159
+ * @param {Record<string, any>} schema
160
+ * @param {{ components?: any[] }} libraryDoc
161
+ */
162
+ export function attachLibraryToSchema(schema, libraryDoc) {
163
+ const next = { ...schema };
164
+ next.library = Array.isArray(libraryDoc?.components)
165
+ ? libraryDoc.components
166
+ : [];
167
+ return next;
168
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Theme locale files (`locales/*.json`) → TemplateData.i18n + dist/locales/.
3
+ */
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ /**
9
+ * @param {string} srcDir
10
+ * @param {{ defaultLocale?: string }} [opts]
11
+ * @returns {{
12
+ * defaultLocale: string,
13
+ * locales: string[],
14
+ * messages: Record<string, Record<string, any>>,
15
+ * } | null}
16
+ */
17
+ export function buildThemeI18n(srcDir, opts = {}) {
18
+ const localesDir = path.join(srcDir, "locales");
19
+ if (!fs.existsSync(localesDir)) return null;
20
+
21
+ /** @type {Record<string, Record<string, any>>} */
22
+ const messages = {};
23
+ const locales = fs
24
+ .readdirSync(localesDir)
25
+ .filter((name) => name.endsWith(".json") && !name.startsWith("."))
26
+ .map((name) => name.replace(/\.json$/i, ""))
27
+ .sort();
28
+
29
+ for (const locale of locales) {
30
+ const filePath = path.join(localesDir, `${locale}.json`);
31
+ try {
32
+ messages[locale] = JSON.parse(fs.readFileSync(filePath, "utf8"));
33
+ } catch (err) {
34
+ throw new Error(
35
+ `Invalid locale file ${path.relative(process.cwd(), filePath)}: ${err}`,
36
+ );
37
+ }
38
+ }
39
+
40
+ if (locales.length === 0) return null;
41
+
42
+ const defaultLocale =
43
+ (typeof opts.defaultLocale === "string" && opts.defaultLocale) ||
44
+ (locales.includes("en") ? "en" : locales[0]);
45
+
46
+ return {
47
+ defaultLocale,
48
+ locales,
49
+ messages,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Write per-locale JSON next to data.json.
55
+ * @param {string} distDir
56
+ * @param {{ messages: Record<string, Record<string, any>> }} i18n
57
+ */
58
+ export function writeLocaleFiles(distDir, i18n) {
59
+ const outDir = path.join(distDir, "locales");
60
+ fs.mkdirSync(outDir, { recursive: true });
61
+ for (const [locale, tree] of Object.entries(i18n.messages || {})) {
62
+ fs.writeFileSync(
63
+ path.join(outDir, `${locale}.json`),
64
+ `${JSON.stringify(tree, null, 2)}\n`,
65
+ "utf8",
66
+ );
67
+ }
68
+ }