@feeef.dev/cli 0.2.0 → 0.2.1
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/README.md +129 -35
- package/dist/bin.js +221 -58
- package/dist/bin.js.map +1 -1
- package/dist/index.js +221 -58
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/scaffolds/blank/AGENTS.md +14 -11
- package/scaffolds/blank/README.md +8 -4
- package/scaffolds/blank/docs/00-INDEX.md +6 -4
- package/scaffolds/blank/docs/04-WORKFLOW.md +29 -24
- package/scaffolds/blank/docs/05-ANTI-PATTERNS.md +3 -1
- package/scaffolds/blank/package.json +3 -2
- package/vendor/template-kit/bin/feeef-template.mjs +32 -0
- package/vendor/template-kit/lib/build.mjs +22 -6
- package/vendor/template-kit/lib/library.mjs +2 -2
- package/vendor/template-kit/lib/smoke.mjs +258 -0
|
@@ -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
|
+
}
|