@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,84 @@
1
+ /**
2
+ * Path + naming helpers for the Feeef template source tree.
3
+ *
4
+ * Folder names prefer `instanceId`; when missing we fall back to a stable
5
+ * slug from `title` / `type` plus a short hash so siblings never collide.
6
+ */
7
+
8
+ import path from "node:path";
9
+ import crypto from "node:crypto";
10
+
11
+ /** Top-level component fields that live in `export const meta` / component.json. */
12
+ export const COMPONENT_META_KEYS = [
13
+ "type",
14
+ "instanceId",
15
+ "title",
16
+ "propsSchema",
17
+ "slotsSchema",
18
+ "slotsLayout",
19
+ "props",
20
+ "refId",
21
+ "refVersion",
22
+ ];
23
+
24
+ /**
25
+ * @param {string} value
26
+ * @returns {string}
27
+ */
28
+ export function slugify(value) {
29
+ const s = String(value ?? "")
30
+ .trim()
31
+ .toLowerCase()
32
+ .replace(/[^a-z0-9]+/g, "-")
33
+ .replace(/^-+|-+$/g, "");
34
+ return s || "component";
35
+ }
36
+
37
+ /**
38
+ * Stable short hash for collision-safe folder names.
39
+ * @param {string} input
40
+ */
41
+ export function shortHash(input) {
42
+ return crypto.createHash("sha1").update(input).digest("hex").slice(0, 8);
43
+ }
44
+
45
+ /**
46
+ * @param {Record<string, any>} component
47
+ * @param {number} index
48
+ * @param {Set<string>} usedNames
49
+ */
50
+ export function folderNameForComponent(component, index, usedNames) {
51
+ const base =
52
+ (component.instanceId && slugify(component.instanceId)) ||
53
+ (component.title && slugify(component.title)) ||
54
+ slugify(component.type || "component");
55
+
56
+ let name = base;
57
+ if (usedNames.has(name)) {
58
+ const id = component.instanceId || `${component.type || "c"}-${index}`;
59
+ name = `${base}-${shortHash(String(id))}`;
60
+ }
61
+ // Still colliding (rare) — append index.
62
+ let n = 2;
63
+ while (usedNames.has(name)) {
64
+ name = `${base}-${n++}`;
65
+ }
66
+ usedNames.add(name);
67
+ return name;
68
+ }
69
+
70
+ /**
71
+ * Resolve a path relative to cwd, expanding ~ is not needed for this kit.
72
+ * @param {string} p
73
+ */
74
+ export function resolveFromCwd(p) {
75
+ return path.resolve(process.cwd(), p);
76
+ }
77
+
78
+ /**
79
+ * Pretty relative path for logs.
80
+ * @param {string} abs
81
+ */
82
+ export function rel(abs) {
83
+ return path.relative(process.cwd(), abs) || ".";
84
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Theme-local shared component library (compile-time).
3
+ *
4
+ * Placements reference a shared definition with:
5
+ * { "$ref": "shared.footer", "instanceId": "…", "props": { …overrides } }
6
+ * or shorthand:
7
+ * { "shared": "footer", "instanceId": "…", "props": { … } }
8
+ *
9
+ * Build expands the ref into a full TemplateDataComponent (deep-merge props).
10
+ * This is the authoring-time twin of runtime `type:"reference"` (backend library).
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { rel } from "./paths.mjs";
16
+ import {
17
+ componentIdFromPath,
18
+ listComponentEntries,
19
+ } from "./component-meta.mjs";
20
+
21
+ /**
22
+ * Deep-merge plain objects. Arrays and scalars from `override` replace.
23
+ * @param {any} base
24
+ * @param {any} override
25
+ */
26
+ export function deepMerge(base, override) {
27
+ if (override === undefined) {
28
+ return base === undefined ? undefined : structuredClone(base);
29
+ }
30
+ if (
31
+ override === null ||
32
+ typeof override !== "object" ||
33
+ Array.isArray(override)
34
+ ) {
35
+ return structuredClone(override);
36
+ }
37
+ if (base === null || typeof base !== "object" || Array.isArray(base)) {
38
+ return structuredClone(override);
39
+ }
40
+ /** @type {Record<string, any>} */
41
+ const out = structuredClone(base);
42
+ for (const [key, value] of Object.entries(override)) {
43
+ if (
44
+ value &&
45
+ typeof value === "object" &&
46
+ !Array.isArray(value) &&
47
+ out[key] &&
48
+ typeof out[key] === "object" &&
49
+ !Array.isArray(out[key])
50
+ ) {
51
+ out[key] = deepMerge(out[key], value);
52
+ } else {
53
+ out[key] = structuredClone(value);
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * @param {Record<string, any>} component
61
+ * @returns {string | null}
62
+ */
63
+ export function sharedIdFromComponent(component) {
64
+ if (!component || typeof component !== "object") return null;
65
+ if (typeof component.shared === "string" && component.shared.trim()) {
66
+ return component.shared.trim().replace(/^shared\./, "");
67
+ }
68
+ if (typeof component.$ref === "string" && component.$ref.trim()) {
69
+ const ref = component.$ref.trim();
70
+ if (ref.startsWith("shared.")) return ref.slice("shared.".length);
71
+ // Allow bare "$ref": "footer" only when paired with convention — prefer shared.
72
+ if (!ref.includes(".")) return ref;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Load every component under `<srcDir>/shared/components/` (flat or folder).
79
+ * @param {string} srcDir
80
+ * @param {(dir: string, fileStack: string[], ctx: { sharedCatalog: Map<string, any>, resolving: Set<string> }) => Record<string, any>} readComponentFn
81
+ * @returns {Map<string, Record<string, any>>}
82
+ */
83
+ export function loadSharedCatalog(srcDir, readComponentFn) {
84
+ /** @type {Map<string, Record<string, any>>} */
85
+ const catalog = new Map();
86
+ const sharedRoot = path.join(srcDir, "shared", "components");
87
+ if (!fs.existsSync(sharedRoot)) return catalog;
88
+
89
+ const entries = listComponentEntries(sharedRoot);
90
+ const ids = entries.map((p) => componentIdFromPath(p));
91
+
92
+ const fileStack = [];
93
+ const resolving = new Set();
94
+
95
+ for (let i = 0; i < entries.length; i++) {
96
+ const id = ids[i];
97
+ const entryPath = entries[i];
98
+ // First pass: read without resolving nested shared (catalog incomplete).
99
+ catalog.set(
100
+ id,
101
+ readComponentFn(entryPath, fileStack, {
102
+ sharedCatalog: catalog,
103
+ resolving,
104
+ deferShared: true,
105
+ }),
106
+ );
107
+ }
108
+
109
+ // Second pass: resolve $ref inside shared definitions (composable shared).
110
+ for (const id of ids) {
111
+ const raw = catalog.get(id);
112
+ catalog.set(
113
+ id,
114
+ resolveSharedInTree(raw, catalog, resolving, `shared/${id}`),
115
+ );
116
+ }
117
+
118
+ return catalog;
119
+ }
120
+
121
+ /**
122
+ * Expand a placement that points at a shared id into a full component.
123
+ * @param {Record<string, any>} placement
124
+ * @param {Map<string, Record<string, any>>} catalog
125
+ * @param {Set<string>} resolving
126
+ * @param {string} pathHint
127
+ */
128
+ export function resolveSharedPlacement(
129
+ placement,
130
+ catalog,
131
+ resolving,
132
+ pathHint = "component",
133
+ ) {
134
+ const id = sharedIdFromComponent(placement);
135
+ if (!id) return placement;
136
+
137
+ if (resolving.has(id)) {
138
+ throw new Error(
139
+ `Shared component cycle detected at ${pathHint} → shared.${id}`,
140
+ );
141
+ }
142
+ if (!catalog.has(id)) {
143
+ throw new Error(
144
+ `Unknown shared component "shared.${id}" at ${pathHint}. ` +
145
+ `Add shared/components/${id}/ or fix the $ref.`,
146
+ );
147
+ }
148
+
149
+ resolving.add(id);
150
+ const base = structuredClone(catalog.get(id));
151
+ resolving.delete(id);
152
+
153
+ const {
154
+ $ref: _ref,
155
+ shared: _shared,
156
+ order: _order,
157
+ $schema: _schema,
158
+ ...overrides
159
+ } = placement;
160
+
161
+ /** Fields that always come from the placement when present */
162
+ const instanceKeys = ["instanceId", "title", "refId", "refVersion"];
163
+
164
+ /** @type {Record<string, any>} */
165
+ const merged = { ...base };
166
+
167
+ for (const key of instanceKeys) {
168
+ if (Object.prototype.hasOwnProperty.call(overrides, key)) {
169
+ merged[key] = overrides[key];
170
+ }
171
+ }
172
+
173
+ if (Object.prototype.hasOwnProperty.call(overrides, "props")) {
174
+ merged.props = deepMerge(base.props || {}, overrides.props || {});
175
+ }
176
+
177
+ // Explicit slot / children overrides replace (not deep-merge trees).
178
+ if (Object.prototype.hasOwnProperty.call(overrides, "slots")) {
179
+ merged.slots = overrides.slots;
180
+ }
181
+ if (Object.prototype.hasOwnProperty.call(overrides, "children")) {
182
+ merged.children = overrides.children;
183
+ }
184
+
185
+ // Allow rare full overrides of schemas/code from placement (escape hatch).
186
+ for (const key of [
187
+ "type",
188
+ "code",
189
+ "propsSchema",
190
+ "slotsSchema",
191
+ "slotsLayout",
192
+ ]) {
193
+ if (
194
+ Object.prototype.hasOwnProperty.call(overrides, key) &&
195
+ overrides[key] !== undefined
196
+ ) {
197
+ merged[key] = overrides[key];
198
+ }
199
+ }
200
+
201
+ // Strip authoring-only keys from output.
202
+ delete merged.$ref;
203
+ delete merged.shared;
204
+ delete merged.$schema;
205
+ delete merged.order;
206
+
207
+ return merged;
208
+ }
209
+
210
+ /**
211
+ * Walk a component tree and expand any shared refs.
212
+ * @param {any} node
213
+ * @param {Map<string, Record<string, any>>} catalog
214
+ * @param {Set<string>} resolving
215
+ * @param {string} pathHint
216
+ */
217
+ export function resolveSharedInTree(node, catalog, resolving, pathHint) {
218
+ if (!node || typeof node !== "object") return node;
219
+
220
+ let current = node;
221
+ if (sharedIdFromComponent(current)) {
222
+ current = resolveSharedPlacement(current, catalog, resolving, pathHint);
223
+ }
224
+
225
+ if (Array.isArray(current.children)) {
226
+ current.children = current.children.map((c, i) =>
227
+ resolveSharedInTree(c, catalog, resolving, `${pathHint}/children[${i}]`),
228
+ );
229
+ }
230
+ if (current.slots && typeof current.slots === "object") {
231
+ /** @type {Record<string, any[]>} */
232
+ const next = {};
233
+ for (const [slotId, list] of Object.entries(current.slots)) {
234
+ if (!Array.isArray(list)) {
235
+ next[slotId] = list;
236
+ continue;
237
+ }
238
+ next[slotId] = list.map((c, i) =>
239
+ resolveSharedInTree(
240
+ c,
241
+ catalog,
242
+ resolving,
243
+ `${pathHint}/slots/${slotId}[${i}]`,
244
+ ),
245
+ );
246
+ }
247
+ current.slots = next;
248
+ }
249
+ return current;
250
+ }
251
+
252
+ /**
253
+ * @param {string} srcDir
254
+ * @returns {string[]}
255
+ */
256
+ export function listSharedIds(srcDir) {
257
+ const sharedRoot = path.join(srcDir, "shared", "components");
258
+ if (!fs.existsSync(sharedRoot)) return [];
259
+ return listComponentEntries(sharedRoot).map((p) => componentIdFromPath(p));
260
+ }
261
+
262
+ /**
263
+ * Human-readable path helper for errors.
264
+ * @param {string} abs
265
+ */
266
+ export function sharedRel(abs) {
267
+ return rel(abs);
268
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Theme-owned TemplateSchema: load overlay, merge Lithium built-ins, package.
3
+ *
4
+ * Overlay (preferred): `schema.ts` with `export const schema = … satisfies FeeefThemeSchema`
5
+ * Fallback: `schema.json`
6
+ *
7
+ * Lithium base resolution:
8
+ * FEEEF_LITHIUM_SCHEMA → ../storefront/template/schema.json → vendored data/lithium-schema.json
9
+ */
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import vm from "node:vm";
14
+ import { createRequire } from "node:module";
15
+ import { fileURLToPath } from "node:url";
16
+ import { rel } from "./paths.mjs";
17
+ import { attachLibraryToSchema } from "./library.mjs";
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const here = path.dirname(fileURLToPath(import.meta.url));
21
+
22
+ /**
23
+ * @returns {typeof import('esbuild') | null}
24
+ */
25
+ function loadEsbuild() {
26
+ try {
27
+ return require("esbuild");
28
+ } catch {
29
+ try {
30
+ const cliRoot = path.resolve(here, "../../..");
31
+ return createRequire(path.join(cliRoot, "package.json"))("esbuild");
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+ }
37
+
38
+ /**
39
+ * @param {string} filePath
40
+ * @returns {any}
41
+ */
42
+ function readJson(filePath) {
43
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
44
+ }
45
+
46
+ /**
47
+ * Resolve Lithium built-in schema JSON path.
48
+ * @returns {string | null}
49
+ */
50
+ export function resolveLithiumSchemaPath() {
51
+ const env = process.env.FEEEF_LITHIUM_SCHEMA?.trim();
52
+ if (env) {
53
+ const abs = path.isAbsolute(env) ? env : path.resolve(process.cwd(), env);
54
+ if (fs.existsSync(abs)) return abs;
55
+ }
56
+
57
+ // Monorepo: cli/vendor/template-kit/lib → cli → feeef.all → storefront
58
+ const monorepo = path.resolve(here, "../../../../storefront/template/schema.json");
59
+ if (fs.existsSync(monorepo)) return monorepo;
60
+
61
+ // Alternate: storefront/template-kit/lib → storefront/template/schema.json
62
+ const sfSibling = path.resolve(here, "../../template/schema.json");
63
+ if (fs.existsSync(sfSibling)) return sfSibling;
64
+
65
+ const vendored = path.join(here, "../data/lithium-schema.json");
66
+ if (fs.existsSync(vendored)) return vendored;
67
+
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * @returns {Record<string, any>}
73
+ */
74
+ export function loadLithiumBaseSchema() {
75
+ const p = resolveLithiumSchemaPath();
76
+ if (!p) {
77
+ throw new Error(
78
+ "Lithium schema not found. Set FEEEF_LITHIUM_SCHEMA or ensure vendor/template-kit/data/lithium-schema.json exists.",
79
+ );
80
+ }
81
+ return readJson(p);
82
+ }
83
+
84
+ /**
85
+ * Evaluate `export const schema` from a .ts/.tsx file.
86
+ * @param {string} filePath
87
+ * @returns {Record<string, any>}
88
+ */
89
+ function evaluateSchemaExport(filePath) {
90
+ const source = fs.readFileSync(filePath, "utf8");
91
+ if (!/\bexport\s+const\s+schema\b/.test(source)) {
92
+ throw new Error(
93
+ `${rel(filePath)}: expected \`export const schema\``,
94
+ );
95
+ }
96
+
97
+ const esbuild = loadEsbuild();
98
+ if (!esbuild) {
99
+ throw new Error(
100
+ `Found schema.ts but esbuild is not installed (needed to evaluate ${rel(filePath)})`,
101
+ );
102
+ }
103
+
104
+ const loader = filePath.endsWith(".tsx") ? "tsx" : "ts";
105
+ const transformed = esbuild.transformSync(source, {
106
+ loader,
107
+ format: "cjs",
108
+ target: "es2018",
109
+ logLevel: "silent",
110
+ });
111
+
112
+ const module = { exports: /** @type {Record<string, any>} */ ({}) };
113
+ const sandbox = {
114
+ module,
115
+ exports: module.exports,
116
+ require: () => ({}),
117
+ console,
118
+ };
119
+
120
+ try {
121
+ vm.runInNewContext(transformed.code, sandbox, { filename: filePath });
122
+ } catch (err) {
123
+ throw new Error(
124
+ `Failed to evaluate schema export in ${rel(filePath)}: ${err instanceof Error ? err.message : String(err)}`,
125
+ );
126
+ }
127
+
128
+ const schema =
129
+ module.exports.schema ??
130
+ module.exports.default?.schema ??
131
+ module.exports.default;
132
+
133
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
134
+ throw new Error(
135
+ `${rel(filePath)}: export const schema must be an object`,
136
+ );
137
+ }
138
+ return { ...schema };
139
+ }
140
+
141
+ /**
142
+ * Load theme overlay from schema.ts / schema.tsx / schema.json (or null).
143
+ * @param {string} srcDir
144
+ * @returns {Record<string, any> | null}
145
+ */
146
+ export function loadThemeSchemaOverlay(srcDir) {
147
+ const ts = path.join(srcDir, "schema.ts");
148
+ const tsx = path.join(srcDir, "schema.tsx");
149
+ const json = path.join(srcDir, "schema.json");
150
+
151
+ if (fs.existsSync(ts)) return evaluateSchemaExport(ts);
152
+ if (fs.existsSync(tsx)) return evaluateSchemaExport(tsx);
153
+ if (fs.existsSync(json)) return readJson(json);
154
+ return null;
155
+ }
156
+
157
+ /**
158
+ * Structural checks on an overlay (throws on hard errors).
159
+ * @param {Record<string, any>} overlay
160
+ * @param {string} hint
161
+ */
162
+ export function validateThemeSchemaOverlay(overlay, hint = "schema") {
163
+ if (!overlay || typeof overlay !== "object") {
164
+ throw new Error(`${hint}: overlay must be an object`);
165
+ }
166
+ if (overlay.pages !== undefined) {
167
+ if (!overlay.pages || typeof overlay.pages !== "object" || Array.isArray(overlay.pages)) {
168
+ throw new Error(`${hint}: pages must be an object map`);
169
+ }
170
+ for (const [pageId, page] of Object.entries(overlay.pages)) {
171
+ if (!page || typeof page !== "object") {
172
+ throw new Error(`${hint}: pages.${pageId} must be an object`);
173
+ }
174
+ if (
175
+ page.sections === undefined ||
176
+ typeof page.sections !== "object" ||
177
+ Array.isArray(page.sections)
178
+ ) {
179
+ throw new Error(`${hint}: pages.${pageId}.sections is required`);
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Merge Lithium base + theme overlay.
187
+ *
188
+ * Theme wins for: name, version, schema, propsSchema, pages (when provided).
189
+ * components: { ...base.components, ...overlay.components }
190
+ *
191
+ * @param {Record<string, any>} base
192
+ * @param {Record<string, any> | null} overlay
193
+ * @param {{ name?: string, version?: string }} [manifest]
194
+ * @returns {Record<string, any>}
195
+ */
196
+ export function mergeThemeSchema(base, overlay, manifest = {}) {
197
+ if (overlay) validateThemeSchemaOverlay(overlay, "theme schema");
198
+
199
+ /** @type {Record<string, any>} */
200
+ const out = {
201
+ ...base,
202
+ components: {
203
+ ...(base.components && typeof base.components === "object"
204
+ ? base.components
205
+ : {}),
206
+ ...(overlay?.components && typeof overlay.components === "object"
207
+ ? overlay.components
208
+ : {}),
209
+ },
210
+ };
211
+
212
+ // Identity: overlay → manifest → base
213
+ if (typeof overlay?.name === "string" && overlay.name.trim()) {
214
+ out.name = overlay.name.trim();
215
+ } else if (typeof manifest.name === "string" && manifest.name.trim()) {
216
+ out.name = manifest.name.trim();
217
+ }
218
+
219
+ if (typeof overlay?.version === "string" && overlay.version.trim()) {
220
+ out.version = overlay.version.trim();
221
+ } else if (typeof manifest.version === "string" && manifest.version.trim()) {
222
+ out.version = manifest.version.trim();
223
+ }
224
+
225
+ if (overlay) {
226
+ if (overlay.schema !== undefined) {
227
+ out.schema = overlay.schema;
228
+ }
229
+ if (overlay.propsSchema && typeof overlay.propsSchema === "object") {
230
+ out.propsSchema = overlay.propsSchema;
231
+ }
232
+ if (overlay.pages && typeof overlay.pages === "object") {
233
+ out.pages = overlay.pages;
234
+ }
235
+ // Forward-compat extras (except reserved merge keys)
236
+ for (const [k, v] of Object.entries(overlay)) {
237
+ if (
238
+ [
239
+ "name",
240
+ "version",
241
+ "schema",
242
+ "propsSchema",
243
+ "pages",
244
+ "components",
245
+ "library",
246
+ ].includes(k)
247
+ ) {
248
+ continue;
249
+ }
250
+ if (v !== undefined) out[k] = v;
251
+ }
252
+ }
253
+
254
+ if (!out.name) out.name = "theme";
255
+ if (!out.version) out.version = "0.1.0";
256
+ if (!out.schema) out.schema = "1.0";
257
+
258
+ return out;
259
+ }
260
+
261
+ /**
262
+ * Build packaged schema for dist/schema.json.
263
+ * @param {object} opts
264
+ * @param {string} opts.srcDir
265
+ * @param {{ components?: any[] }} [opts.libraryDoc]
266
+ * @returns {{ schema: Record<string, any>, overlay: Record<string, any> | null, warnings: string[] }}
267
+ */
268
+ export function buildPackagedSchema({ srcDir, libraryDoc }) {
269
+ /** @type {string[]} */
270
+ const warnings = [];
271
+ /** @type {Record<string, any>} */
272
+ let manifest = {};
273
+ const manifestPath = path.join(srcDir, "feeef.template.json");
274
+ if (fs.existsSync(manifestPath)) {
275
+ try {
276
+ manifest = readJson(manifestPath);
277
+ } catch {
278
+ warnings.push("Could not parse feeef.template.json for schema name/version");
279
+ }
280
+ }
281
+
282
+ const base = loadLithiumBaseSchema();
283
+ const overlay = loadThemeSchemaOverlay(srcDir);
284
+ if (!overlay) {
285
+ warnings.push(
286
+ "No schema.ts / schema.json — using Lithium pages layout with manifest name/version",
287
+ );
288
+ }
289
+
290
+ let schema = mergeThemeSchema(base, overlay, {
291
+ name: typeof manifest.name === "string" ? manifest.name : undefined,
292
+ version: typeof manifest.version === "string" ? manifest.version : undefined,
293
+ });
294
+
295
+ if (libraryDoc) {
296
+ schema = attachLibraryToSchema(schema, libraryDoc);
297
+ }
298
+
299
+ return { schema, overlay, warnings };
300
+ }