@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.
- package/LICENSE +6 -0
- package/README.md +94 -0
- package/dist/bin.js +2752 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2730 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
- package/scaffolds/blank/.cursor/rules/feeef-theme.mdc +34 -0
- package/scaffolds/blank/.cursor/rules/filterator.mdc +13 -0
- package/scaffolds/blank/.cursor/rules/order-form.mdc +17 -0
- package/scaffolds/blank/.cursor/rules/theme-source.mdc +32 -0
- package/scaffolds/blank/.cursor/skills/feeef-filterator/SKILL.md +15 -0
- package/scaffolds/blank/.cursor/skills/feeef-theme/SKILL.md +56 -0
- package/scaffolds/blank/.cursor/skills/feeef-theme/reference.md +73 -0
- package/scaffolds/blank/.vscode/extensions.json +3 -0
- package/scaffolds/blank/.vscode/settings.json +13 -0
- package/scaffolds/blank/AGENTS.md +104 -0
- package/scaffolds/blank/README.md +21 -0
- package/scaffolds/blank/docs/00-INDEX.md +33 -0
- package/scaffolds/blank/docs/03-CUSTOM-COMPONENTS.md +116 -0
- package/scaffolds/blank/docs/04-WORKFLOW.md +129 -0
- package/scaffolds/blank/docs/05-ANTI-PATTERNS.md +80 -0
- package/scaffolds/blank/docs/07-SHARED-COMPONENTS.md +144 -0
- package/scaffolds/blank/docs/08-ORDER-FORM.md +376 -0
- package/scaffolds/blank/docs/09-THEME-PORTING.md +211 -0
- package/scaffolds/blank/docs/10-SCHEMA-LIBRARY.md +127 -0
- package/scaffolds/blank/docs/11-PAGES-CHECKOUT-THANKS-PRODUCT.md +81 -0
- package/scaffolds/blank/docs/12-DARK-LIGHT-THEME.md +164 -0
- package/scaffolds/blank/docs/13-TEMPLATE-I18N.md +320 -0
- package/scaffolds/blank/docs/14-FILTERATOR.md +433 -0
- package/scaffolds/blank/docs/16-AUTHORING-TS-IMPORTS.md +73 -0
- package/scaffolds/blank/feeef.template.json +7 -0
- package/scaffolds/blank/locales/README.md +4 -0
- package/scaffolds/blank/locales/ar.json +10 -0
- package/scaffolds/blank/locales/en.json +10 -0
- package/scaffolds/blank/package-lock.json +1975 -0
- package/scaffolds/blank/package.json +23 -0
- package/scaffolds/blank/pages/home/components/hero.tsx +34 -0
- package/scaffolds/blank/pages/home/page.json +3 -0
- package/scaffolds/blank/props.json +6 -0
- package/scaffolds/blank/schema.ts +49 -0
- package/scaffolds/blank/tsconfig.json +24 -0
- package/scaffolds/blank/types/feeef-live-scope.d.ts +162 -0
- package/scaffolds/blank/types/feeef-template-schema.d.ts +84 -0
- package/vendor/template-kit/README.md +64 -0
- package/vendor/template-kit/bin/dev.mjs +61 -0
- package/vendor/template-kit/bin/feeef-template.mjs +212 -0
- package/vendor/template-kit/data/lithium-schema.json +4733 -0
- package/vendor/template-kit/lib/build.mjs +377 -0
- package/vendor/template-kit/lib/compile-component.mjs +188 -0
- package/vendor/template-kit/lib/component-meta.mjs +481 -0
- package/vendor/template-kit/lib/library.mjs +168 -0
- package/vendor/template-kit/lib/locales.mjs +68 -0
- package/vendor/template-kit/lib/paths.mjs +84 -0
- package/vendor/template-kit/lib/shared.mjs +268 -0
- package/vendor/template-kit/lib/theme-schema.mjs +300 -0
- package/vendor/template-kit/lib/unpack.mjs +324 -0
- package/vendor/template-kit/lib/validate.mjs +207 -0
- package/vendor/template-kit/schemas/component.schema.json +55 -0
- 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
|
+
}
|