@feeef.dev/cli 0.2.0 → 0.2.2

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.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Theme smoke: evaluate every custom `App` from built TemplateData (react-live style).
3
+ *
4
+ * Does not need the storefront — uses React SSR like cli/tests/compile-e2e.test.ts.
5
+ */
6
+
7
+ import { createRequire } from "node:module";
8
+ import { pathToFileURL } from "node:url";
9
+ import path from "node:path";
10
+ import fs from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ /**
16
+ * Resolve react / react-dom from the nearest package that has it
17
+ * (CLI when vendored, storefront when running kit from monorepo).
18
+ */
19
+ function createCliRequire() {
20
+ const candidates = [
21
+ path.resolve(__dirname, "../../.."), // cli/vendor/template-kit/lib → cli
22
+ path.resolve(__dirname, "../.."), // storefront/template-kit/lib → storefront
23
+ path.resolve(__dirname, ".."),
24
+ ];
25
+ for (const root of candidates) {
26
+ const pkgJson = path.join(root, "package.json");
27
+ if (!fs.existsSync(pkgJson)) continue;
28
+ try {
29
+ const req = createRequire(pkgJson);
30
+ req.resolve("react");
31
+ req.resolve("react-dom/server");
32
+ req.resolve("esbuild");
33
+ return req;
34
+ } catch {
35
+ /* try next */
36
+ }
37
+ }
38
+ return createRequire(import.meta.url);
39
+ }
40
+
41
+ const require = createCliRequire();
42
+
43
+ /**
44
+ * Recursively collect custom components with compiled `code`.
45
+ * @param {any} node
46
+ * @param {string} loc
47
+ * @param {Array<{ loc: string, instanceId?: string, title?: string, sourcePath?: string, type: string, code: string, props: Record<string, unknown> }>} out
48
+ */
49
+ export function walkCustomComponents(node, loc, out) {
50
+ if (!node || typeof node !== "object") return;
51
+
52
+ if (Array.isArray(node)) {
53
+ node.forEach((child, i) => walkCustomComponents(child, `${loc}[${i}]`, out));
54
+ return;
55
+ }
56
+
57
+ const type = node.type;
58
+ const code = typeof node.code === "string" ? node.code.trim() : "";
59
+ if (type === "custom" && code) {
60
+ out.push({
61
+ loc,
62
+ instanceId: node.instanceId ?? undefined,
63
+ title: node.title ?? undefined,
64
+ sourcePath: node.sourcePath ?? undefined,
65
+ type,
66
+ code,
67
+ props:
68
+ node.props && typeof node.props === "object" && !Array.isArray(node.props)
69
+ ? node.props
70
+ : {},
71
+ });
72
+ }
73
+
74
+ if (Array.isArray(node.children)) {
75
+ walkCustomComponents(node.children, `${loc}/children`, out);
76
+ }
77
+ if (node.slots && typeof node.slots === "object") {
78
+ for (const [slotId, list] of Object.entries(node.slots)) {
79
+ walkCustomComponents(list, `${loc}/slots/${slotId}`, out);
80
+ }
81
+ }
82
+ if (node.pages && typeof node.pages === "object") {
83
+ for (const [pageId, page] of Object.entries(node.pages)) {
84
+ walkCustomComponents(page, `pages.${pageId}`, out);
85
+ }
86
+ }
87
+ if (node.sections && typeof node.sections === "object") {
88
+ for (const [sectionId, section] of Object.entries(node.sections)) {
89
+ walkCustomComponents(section, `${loc}/sections.${sectionId}`, out);
90
+ }
91
+ }
92
+ if (Array.isArray(node.components)) {
93
+ walkCustomComponents(node.components, `${loc}/components`, out);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Minimal react-live-like stubs for SSR smoke (no Next router / store).
99
+ * @param {Record<string, unknown>} props
100
+ */
101
+ function buildSmokeScope(props) {
102
+ const React = require("react");
103
+ const noop = () => null;
104
+ const emptyHooks = () => null;
105
+ return {
106
+ React,
107
+ props,
108
+ useState: React.useState,
109
+ useEffect: React.useEffect,
110
+ useLayoutEffect: React.useLayoutEffect,
111
+ useRef: React.useRef,
112
+ useMemo: React.useMemo,
113
+ useCallback: React.useCallback,
114
+ useCurrentProduct: emptyHooks,
115
+ useFeeefCart: emptyHooks,
116
+ useFeeef: emptyHooks,
117
+ useStore: emptyHooks,
118
+ useTheme: () => ({
119
+ theme: "light",
120
+ setTheme: noop,
121
+ resolvedTheme: "light",
122
+ systemTheme: "light",
123
+ }),
124
+ useTemplate: () => ({ template: null }),
125
+ useFeeefT: () => (key) => key,
126
+ useFeeefLocale: () => "en",
127
+ getFeeefLocale: () => "en",
128
+ getFeeefDir: () => "ltr",
129
+ t: (key) => key,
130
+ useRouter: () => ({ push: noop, replace: noop, back: noop, prefetch: noop }),
131
+ usePathname: () => "/",
132
+ useSearchParams: () => new URLSearchParams(),
133
+ Link: ({ children, href, ...rest }) =>
134
+ React.createElement("a", { href: href ?? "#", ...rest }, children),
135
+ RouterNav: ({ children, ...rest }) =>
136
+ React.createElement("div", { role: "link", ...rest }, children),
137
+ SlotsLayout: ({ children }) => React.createElement(React.Fragment, null, children),
138
+ SlotContextBridge: ({ children }) =>
139
+ React.createElement(React.Fragment, null, children),
140
+ useSlotContext: () => ({}),
141
+ dartColorToCssColor: () => undefined,
142
+ convertDartColorToCssNumber: () => 0,
143
+ cssColorToHslString: () => undefined,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Transpile preserved JSX (build uses jsx:preserve for react-live) → createElement.
149
+ * Mirrors what react-live/Sucrase does in the browser.
150
+ * @param {string} code
151
+ */
152
+ function transpileJsxForSmoke(code) {
153
+ let esbuild;
154
+ try {
155
+ esbuild = require("esbuild");
156
+ } catch {
157
+ throw new Error(
158
+ "esbuild is required for feeef template smoke (install @feeef.dev/cli deps)",
159
+ );
160
+ }
161
+ const result = esbuild.transformSync(code, {
162
+ loader: "tsx",
163
+ jsx: "transform",
164
+ jsxFactory: "React.createElement",
165
+ jsxFragment: "React.Fragment",
166
+ format: "cjs",
167
+ target: "es2018",
168
+ });
169
+ return result.code;
170
+ }
171
+
172
+ /**
173
+ * Evaluate one custom App with React SSR.
174
+ * @param {{ code: string, props?: Record<string, unknown>, sourcePath?: string, loc?: string, instanceId?: string }} item
175
+ * @returns {{ ok: true, html: string } | { ok: false, error: Error }}
176
+ */
177
+ export function evalCustomApp(item) {
178
+ const React = require("react");
179
+ const { renderToStaticMarkup } = require("react-dom/server");
180
+ const props = item.props ?? {};
181
+ const scope = buildSmokeScope(props);
182
+ const keys = Object.keys(scope);
183
+ const values = keys.map((k) => scope[k]);
184
+
185
+ try {
186
+ const js = transpileJsxForSmoke(item.code);
187
+ const factory = new Function(
188
+ ...keys,
189
+ `${js}\n; if (typeof App !== "function") throw new Error("Missing function App()"); return App;`,
190
+ );
191
+ const App = factory(...values);
192
+ const html = renderToStaticMarkup(React.createElement(App, null));
193
+ return { ok: true, html };
194
+ } catch (e) {
195
+ const err = e instanceof Error ? e : new Error(String(e));
196
+ const label =
197
+ item.sourcePath ||
198
+ item.instanceId ||
199
+ item.loc ||
200
+ "unknown-component";
201
+ err.message = `[smoke] ${label}: ${err.message}`;
202
+ return { ok: false, error: err };
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Build (optional) + smoke-render every custom component.
208
+ *
209
+ * @param {object} opts
210
+ * @param {string} opts.srcDir
211
+ * @param {string} [opts.dataPath]
212
+ * @param {boolean} [opts.build=true]
213
+ * @returns {Promise<{ passed: number, failed: Array<{ item: any, error: Error }>, total: number, items: any[] }>}
214
+ */
215
+ export async function smokeTheme({ srcDir, dataPath, build = true }) {
216
+ const resolvedSrc = path.resolve(srcDir);
217
+ const outPath = dataPath
218
+ ? path.resolve(dataPath)
219
+ : path.join(resolvedSrc, "dist", "data.json");
220
+
221
+ if (build) {
222
+ const { buildTemplate } = await import(
223
+ pathToFileURL(path.join(__dirname, "build.mjs")).href
224
+ );
225
+ buildTemplate({ srcDir: resolvedSrc, outPath, write: true });
226
+ }
227
+
228
+ if (!fs.existsSync(outPath)) {
229
+ throw new Error(`Missing ${outPath}`);
230
+ }
231
+
232
+ const data = JSON.parse(fs.readFileSync(outPath, "utf8"));
233
+ const items = [];
234
+ walkCustomComponents(data, "data", items);
235
+
236
+ const libraryPath = path.join(path.dirname(outPath), "schema.library.json");
237
+ if (fs.existsSync(libraryPath)) {
238
+ try {
239
+ const lib = JSON.parse(fs.readFileSync(libraryPath, "utf8"));
240
+ const list = Array.isArray(lib.components) ? lib.components : [];
241
+ list.forEach((c, i) =>
242
+ walkCustomComponents(c, `library[${i}]`, items),
243
+ );
244
+ } catch {
245
+ /* ignore bad library */
246
+ }
247
+ }
248
+
249
+ const failed = [];
250
+ let passed = 0;
251
+ for (const item of items) {
252
+ const result = evalCustomApp(item);
253
+ if (result.ok) passed += 1;
254
+ else failed.push({ item, error: result.error });
255
+ }
256
+
257
+ return { passed, failed, total: items.length, items };
258
+ }