@numueg/theme-cli 0.1.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/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/index.js +3882 -0
- package/package.json +65 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3882 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __esm = (fn, res) => function __init() {
|
|
10
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
11
|
+
};
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
var __copyProps = (to, from, except, desc) => {
|
|
17
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
18
|
+
for (let key of __getOwnPropNames(from))
|
|
19
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
20
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
21
|
+
}
|
|
22
|
+
return to;
|
|
23
|
+
};
|
|
24
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
25
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
26
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
27
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
28
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
29
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
30
|
+
mod
|
|
31
|
+
));
|
|
32
|
+
|
|
33
|
+
// src/lint/rules/schema-registry-sync.ts
|
|
34
|
+
var schema_registry_sync_exports = {};
|
|
35
|
+
__export(schema_registry_sync_exports, {
|
|
36
|
+
default: () => schema_registry_sync_default
|
|
37
|
+
});
|
|
38
|
+
var rule, schema_registry_sync_default;
|
|
39
|
+
var init_schema_registry_sync = __esm({
|
|
40
|
+
"src/lint/rules/schema-registry-sync.ts"() {
|
|
41
|
+
"use strict";
|
|
42
|
+
rule = {
|
|
43
|
+
id: "schema-registry-sync",
|
|
44
|
+
description: "Every section/block referenced in presets has a matching schema file",
|
|
45
|
+
check(ctx) {
|
|
46
|
+
const issues = [];
|
|
47
|
+
const presets = ctx.manifest.presets || {};
|
|
48
|
+
const templates = presets.templates || {};
|
|
49
|
+
const referencedSectionTypes = /* @__PURE__ */ new Set();
|
|
50
|
+
for (const template of Object.values(templates)) {
|
|
51
|
+
const sections = template?.sections || {};
|
|
52
|
+
for (const section of Object.values(sections)) {
|
|
53
|
+
if (section?.type) referencedSectionTypes.add(section.type);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const type of referencedSectionTypes) {
|
|
57
|
+
if (!ctx.sectionSchemas[type]) {
|
|
58
|
+
issues.push({
|
|
59
|
+
rule: rule.id,
|
|
60
|
+
severity: "error",
|
|
61
|
+
file: "theme.json",
|
|
62
|
+
message: `Preset references section type '${type}' but no schemas/sections/${type}.json exists.`,
|
|
63
|
+
suggestion: `Create schemas/sections/${type}.json or remove the section from the preset.`
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const type of Object.keys(ctx.sectionSchemas)) {
|
|
68
|
+
if (!referencedSectionTypes.has(type)) {
|
|
69
|
+
if (Object.keys(referencedSectionTypes).length === 0) continue;
|
|
70
|
+
issues.push({
|
|
71
|
+
rule: rule.id,
|
|
72
|
+
severity: "warning",
|
|
73
|
+
file: `schemas/sections/${type}.json`,
|
|
74
|
+
message: `Section type '${type}' has a schema but isn't used in any preset.`,
|
|
75
|
+
suggestion: `Add it to a preset or delete the unused schema.`
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return issues;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
schema_registry_sync_default = rule;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// src/lint/rules/locale-parity.ts
|
|
87
|
+
var locale_parity_exports = {};
|
|
88
|
+
__export(locale_parity_exports, {
|
|
89
|
+
default: () => locale_parity_default
|
|
90
|
+
});
|
|
91
|
+
function collectKeys(dict, prefix = "") {
|
|
92
|
+
const out = /* @__PURE__ */ new Set();
|
|
93
|
+
for (const [k, v] of Object.entries(dict)) {
|
|
94
|
+
const full = prefix ? `${prefix}.${k}` : k;
|
|
95
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
96
|
+
for (const nested of collectKeys(v, full)) {
|
|
97
|
+
out.add(nested);
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
out.add(full);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
var rule2, locale_parity_default;
|
|
106
|
+
var init_locale_parity = __esm({
|
|
107
|
+
"src/lint/rules/locale-parity.ts"() {
|
|
108
|
+
"use strict";
|
|
109
|
+
rule2 = {
|
|
110
|
+
id: "locale-parity",
|
|
111
|
+
description: "Non-default locale files have the same key set as the default",
|
|
112
|
+
check(ctx) {
|
|
113
|
+
const issues = [];
|
|
114
|
+
const locales = ctx.locales;
|
|
115
|
+
if (Object.keys(locales).length < 2) return issues;
|
|
116
|
+
const defaultCode = "en";
|
|
117
|
+
const defaultKeys = collectKeys(locales[defaultCode] || {});
|
|
118
|
+
if (defaultKeys.size === 0) return issues;
|
|
119
|
+
for (const [code, dict] of Object.entries(locales)) {
|
|
120
|
+
if (code === defaultCode) continue;
|
|
121
|
+
const keys = collectKeys(dict);
|
|
122
|
+
const missing = [];
|
|
123
|
+
const extra = [];
|
|
124
|
+
for (const k of defaultKeys) if (!keys.has(k)) missing.push(k);
|
|
125
|
+
for (const k of keys) if (!defaultKeys.has(k)) extra.push(k);
|
|
126
|
+
if (missing.length) {
|
|
127
|
+
issues.push({
|
|
128
|
+
rule: rule2.id,
|
|
129
|
+
severity: "error",
|
|
130
|
+
file: `locales/${code}.json`,
|
|
131
|
+
message: `Missing ${missing.length} key${missing.length === 1 ? "" : "s"} present in the default locale.`,
|
|
132
|
+
suggestion: `First missing keys: ` + missing.slice(0, 5).join(", ") + (missing.length > 5 ? ` (+${missing.length - 5} more)` : "")
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (extra.length) {
|
|
136
|
+
issues.push({
|
|
137
|
+
rule: rule2.id,
|
|
138
|
+
severity: "warning",
|
|
139
|
+
file: `locales/${code}.json`,
|
|
140
|
+
message: `Has ${extra.length} extra key${extra.length === 1 ? "" : "s"} not in the default locale (will fall back when the default is rendered).`,
|
|
141
|
+
suggestion: `Extra keys: ` + extra.slice(0, 5).join(", ") + (extra.length > 5 ? ` (+${extra.length - 5} more)` : "")
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return issues;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
locale_parity_default = rule2;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// src/lint/rules/preset-schema-conformance.ts
|
|
153
|
+
var preset_schema_conformance_exports = {};
|
|
154
|
+
__export(preset_schema_conformance_exports, {
|
|
155
|
+
default: () => preset_schema_conformance_default
|
|
156
|
+
});
|
|
157
|
+
function collectSettingDefs(defs) {
|
|
158
|
+
const out = /* @__PURE__ */ new Map();
|
|
159
|
+
for (const d of defs) {
|
|
160
|
+
if (d && typeof d === "object" && d.id) {
|
|
161
|
+
out.set(
|
|
162
|
+
d.id,
|
|
163
|
+
d
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
function validateSettingValue(def, value) {
|
|
170
|
+
const type = def.type;
|
|
171
|
+
if (type === "select" || type === "radio") {
|
|
172
|
+
const options = def.options || [];
|
|
173
|
+
const validValues = options.map((o) => o.value);
|
|
174
|
+
if (validValues.length > 0 && !validValues.includes(value)) {
|
|
175
|
+
return `value '${String(value)}' is not one of [${validValues.join(", ")}]`;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (type === "range" || type === "number") {
|
|
179
|
+
if (typeof value === "number") {
|
|
180
|
+
const min = def.min;
|
|
181
|
+
const max = def.max;
|
|
182
|
+
if (min !== void 0 && value < min)
|
|
183
|
+
return `value ${value} is below min (${min})`;
|
|
184
|
+
if (max !== void 0 && value > max)
|
|
185
|
+
return `value ${value} is above max (${max})`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (type === "checkbox" && typeof value !== "boolean") {
|
|
189
|
+
return `expected boolean, got ${typeof value}`;
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
var rule3, preset_schema_conformance_default;
|
|
194
|
+
var init_preset_schema_conformance = __esm({
|
|
195
|
+
"src/lint/rules/preset-schema-conformance.ts"() {
|
|
196
|
+
"use strict";
|
|
197
|
+
rule3 = {
|
|
198
|
+
id: "preset-schema-conformance",
|
|
199
|
+
description: "Preset settings only use schema-declared ids + valid values",
|
|
200
|
+
check(ctx) {
|
|
201
|
+
const issues = [];
|
|
202
|
+
const presets = ctx.manifest.presets || {};
|
|
203
|
+
const templates = presets.templates || {};
|
|
204
|
+
for (const [templateKey, template] of Object.entries(templates)) {
|
|
205
|
+
const sections = template?.sections || {};
|
|
206
|
+
for (const [sectionKey, section] of Object.entries(sections)) {
|
|
207
|
+
if (!section?.type) continue;
|
|
208
|
+
const schema = ctx.sectionSchemas[section.type];
|
|
209
|
+
if (!schema) continue;
|
|
210
|
+
const allowed = collectSettingDefs(
|
|
211
|
+
schema.settings || []
|
|
212
|
+
);
|
|
213
|
+
const setSettings = section.settings || {};
|
|
214
|
+
for (const [settingId, value] of Object.entries(setSettings)) {
|
|
215
|
+
const def = allowed.get(settingId);
|
|
216
|
+
if (!def) {
|
|
217
|
+
issues.push({
|
|
218
|
+
rule: rule3.id,
|
|
219
|
+
severity: "error",
|
|
220
|
+
file: "theme.json",
|
|
221
|
+
message: `Template '${templateKey}' section '${sectionKey}' sets '${settingId}' but section schema '${section.type}' doesn't declare it.`,
|
|
222
|
+
suggestion: `Add the setting to schemas/sections/${section.type}.json or remove it from the preset.`
|
|
223
|
+
});
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const optionError = validateSettingValue(def, value);
|
|
227
|
+
if (optionError) {
|
|
228
|
+
issues.push({
|
|
229
|
+
rule: rule3.id,
|
|
230
|
+
severity: "error",
|
|
231
|
+
file: "theme.json",
|
|
232
|
+
message: `Template '${templateKey}' section '${sectionKey}' setting '${settingId}': ${optionError}`
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return issues;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
preset_schema_conformance_default = rule3;
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// src/lint/rules/unused-settings.ts
|
|
246
|
+
var unused_settings_exports = {};
|
|
247
|
+
__export(unused_settings_exports, {
|
|
248
|
+
default: () => unused_settings_default
|
|
249
|
+
});
|
|
250
|
+
function escapeRe(s) {
|
|
251
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
252
|
+
}
|
|
253
|
+
var rule4, unused_settings_default;
|
|
254
|
+
var init_unused_settings = __esm({
|
|
255
|
+
"src/lint/rules/unused-settings.ts"() {
|
|
256
|
+
"use strict";
|
|
257
|
+
rule4 = {
|
|
258
|
+
id: "unused-settings",
|
|
259
|
+
description: "Every settings_schema entry is read by some component",
|
|
260
|
+
check(ctx) {
|
|
261
|
+
const issues = [];
|
|
262
|
+
const schema = ctx.settingsSchema || [];
|
|
263
|
+
const declared = [];
|
|
264
|
+
for (const def of schema) {
|
|
265
|
+
if (def && typeof def === "object") {
|
|
266
|
+
const id = def.id;
|
|
267
|
+
if (typeof id === "string") declared.push(id);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (declared.length === 0) return issues;
|
|
271
|
+
const haystack = [
|
|
272
|
+
...Object.values(ctx.sources),
|
|
273
|
+
...Object.values(ctx.sectionSchemas).map((s) => JSON.stringify(s)),
|
|
274
|
+
...Object.values(ctx.blockSchemas).map((s) => JSON.stringify(s))
|
|
275
|
+
].join("\n");
|
|
276
|
+
for (const id of declared) {
|
|
277
|
+
const patterns = [
|
|
278
|
+
new RegExp(`["']${escapeRe(id)}["']`),
|
|
279
|
+
new RegExp(`\\.${escapeRe(id)}\\b`)
|
|
280
|
+
];
|
|
281
|
+
const used = patterns.some((p) => p.test(haystack));
|
|
282
|
+
if (!used) {
|
|
283
|
+
issues.push({
|
|
284
|
+
rule: rule4.id,
|
|
285
|
+
severity: "warning",
|
|
286
|
+
file: "settings_schema.json",
|
|
287
|
+
message: `Setting '${id}' is declared but never read.`,
|
|
288
|
+
suggestion: `Remove from settings_schema.json or wire it up in a section.`
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return issues;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
unused_settings_default = rule4;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// src/lint/rules/img-missing-alt.ts
|
|
300
|
+
var img_missing_alt_exports = {};
|
|
301
|
+
__export(img_missing_alt_exports, {
|
|
302
|
+
default: () => img_missing_alt_default
|
|
303
|
+
});
|
|
304
|
+
var rule5, img_missing_alt_default;
|
|
305
|
+
var init_img_missing_alt = __esm({
|
|
306
|
+
"src/lint/rules/img-missing-alt.ts"() {
|
|
307
|
+
"use strict";
|
|
308
|
+
rule5 = {
|
|
309
|
+
id: "img-missing-alt",
|
|
310
|
+
description: "Every <img> tag has an alt attribute",
|
|
311
|
+
check(ctx) {
|
|
312
|
+
const issues = [];
|
|
313
|
+
for (const [file, source] of Object.entries(ctx.sources)) {
|
|
314
|
+
const lines = source.split("\n");
|
|
315
|
+
for (let i = 0; i < lines.length; i++) {
|
|
316
|
+
const line = lines[i];
|
|
317
|
+
const imgMatches = line.match(/<img\b[^>]*>/g);
|
|
318
|
+
if (!imgMatches) continue;
|
|
319
|
+
for (const match of imgMatches) {
|
|
320
|
+
if (!/\balt\s*=/.test(match)) {
|
|
321
|
+
issues.push({
|
|
322
|
+
rule: rule5.id,
|
|
323
|
+
severity: "warning",
|
|
324
|
+
file,
|
|
325
|
+
line: i + 1,
|
|
326
|
+
message: `<img> tag missing alt attribute.`,
|
|
327
|
+
suggestion: `Add alt="..." (or alt="" for purely decorative images), or use <Image> from @numueg/theme-sdk.`
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return issues;
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
img_missing_alt_default = rule5;
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// src/lint/rules/hardcoded-text.ts
|
|
341
|
+
var hardcoded_text_exports = {};
|
|
342
|
+
__export(hardcoded_text_exports, {
|
|
343
|
+
default: () => hardcoded_text_default
|
|
344
|
+
});
|
|
345
|
+
function isUiCopy(text) {
|
|
346
|
+
if (!/[a-zA-Z]/.test(text)) return false;
|
|
347
|
+
const words = text.split(/\s+/).filter((w) => /[a-zA-Z]/.test(w));
|
|
348
|
+
if (words.length < 3) return false;
|
|
349
|
+
if (/^https?:\/\//.test(text)) return false;
|
|
350
|
+
if (/^[a-z][\w-]*$/.test(text)) return false;
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
function truncate(s, n) {
|
|
354
|
+
return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
|
|
355
|
+
}
|
|
356
|
+
var rule6, hardcoded_text_default;
|
|
357
|
+
var init_hardcoded_text = __esm({
|
|
358
|
+
"src/lint/rules/hardcoded-text.ts"() {
|
|
359
|
+
"use strict";
|
|
360
|
+
rule6 = {
|
|
361
|
+
id: "hardcoded-text",
|
|
362
|
+
description: "JSX text content with 3+ words should use t() for translatability",
|
|
363
|
+
check(ctx) {
|
|
364
|
+
const issues = [];
|
|
365
|
+
for (const [file, source] of Object.entries(ctx.sources)) {
|
|
366
|
+
if (file.includes("/dev-entry") || file.endsWith(".test.tsx")) continue;
|
|
367
|
+
const lines = source.split("\n");
|
|
368
|
+
for (let i = 0; i < lines.length; i++) {
|
|
369
|
+
const line = lines[i];
|
|
370
|
+
const m = line.match(/>([^<>{}\n]{8,})</g);
|
|
371
|
+
if (!m) continue;
|
|
372
|
+
for (const block of m) {
|
|
373
|
+
const text = block.slice(1, -1).trim();
|
|
374
|
+
if (!isUiCopy(text)) continue;
|
|
375
|
+
issues.push({
|
|
376
|
+
rule: rule6.id,
|
|
377
|
+
severity: "warning",
|
|
378
|
+
file,
|
|
379
|
+
line: i + 1,
|
|
380
|
+
message: `Hardcoded text "${truncate(text, 40)}" \u2014 won't translate.`,
|
|
381
|
+
suggestion: `Replace with {t("section.key")} and add the key to locales/*.json.`
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return issues;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
hardcoded_text_default = rule6;
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// src/lint/rules/inline-color-literal.ts
|
|
394
|
+
var inline_color_literal_exports = {};
|
|
395
|
+
__export(inline_color_literal_exports, {
|
|
396
|
+
default: () => inline_color_literal_default
|
|
397
|
+
});
|
|
398
|
+
var rule7, inline_color_literal_default;
|
|
399
|
+
var init_inline_color_literal = __esm({
|
|
400
|
+
"src/lint/rules/inline-color-literal.ts"() {
|
|
401
|
+
"use strict";
|
|
402
|
+
rule7 = {
|
|
403
|
+
id: "inline-color-literal",
|
|
404
|
+
description: "Don't hardcode hex colors in style props \u2014 use theme settings",
|
|
405
|
+
check(ctx) {
|
|
406
|
+
const issues = [];
|
|
407
|
+
for (const [file, source] of Object.entries(ctx.sources)) {
|
|
408
|
+
const lines = source.split("\n");
|
|
409
|
+
for (let i = 0; i < lines.length; i++) {
|
|
410
|
+
const line = lines[i];
|
|
411
|
+
if (!/style\s*=\s*\{/.test(line) && !/style=\{\{/.test(line)) continue;
|
|
412
|
+
const hexMatch = line.match(/#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b/g);
|
|
413
|
+
if (hexMatch) {
|
|
414
|
+
issues.push({
|
|
415
|
+
rule: rule7.id,
|
|
416
|
+
severity: "warning",
|
|
417
|
+
file,
|
|
418
|
+
line: i + 1,
|
|
419
|
+
message: `Hardcoded color (${hexMatch.join(", ")}) in style prop.`,
|
|
420
|
+
suggestion: `Read from useThemeSettings() or a color_scheme setting so merchants can rebrand.`
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
const rgbMatch = line.match(/rgba?\s*\(/g);
|
|
424
|
+
if (rgbMatch) {
|
|
425
|
+
issues.push({
|
|
426
|
+
rule: rule7.id,
|
|
427
|
+
severity: "warning",
|
|
428
|
+
file,
|
|
429
|
+
line: i + 1,
|
|
430
|
+
message: `Hardcoded rgb()/rgba() in style prop.`,
|
|
431
|
+
suggestion: `Read from useThemeSettings() or a color_scheme setting.`
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return issues;
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
inline_color_literal_default = rule7;
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// src/lint/rules/forbidden-script-tag.ts
|
|
444
|
+
var forbidden_script_tag_exports = {};
|
|
445
|
+
__export(forbidden_script_tag_exports, {
|
|
446
|
+
default: () => forbidden_script_tag_default
|
|
447
|
+
});
|
|
448
|
+
var rule8, forbidden_script_tag_default;
|
|
449
|
+
var init_forbidden_script_tag = __esm({
|
|
450
|
+
"src/lint/rules/forbidden-script-tag.ts"() {
|
|
451
|
+
"use strict";
|
|
452
|
+
rule8 = {
|
|
453
|
+
id: "forbidden-script-tag",
|
|
454
|
+
description: "Section components can't contain <script> tags (CSP + AST scanner forbid)",
|
|
455
|
+
check(ctx) {
|
|
456
|
+
const issues = [];
|
|
457
|
+
for (const [file, source] of Object.entries(ctx.sources)) {
|
|
458
|
+
const lines = source.split("\n");
|
|
459
|
+
for (let i = 0; i < lines.length; i++) {
|
|
460
|
+
const line = lines[i];
|
|
461
|
+
if (/<script[\s>]/i.test(line)) {
|
|
462
|
+
issues.push({
|
|
463
|
+
rule: rule8.id,
|
|
464
|
+
severity: "error",
|
|
465
|
+
file,
|
|
466
|
+
line: i + 1,
|
|
467
|
+
message: `<script> tag in theme source \u2014 marketplace AST scanner will reject.`,
|
|
468
|
+
suggestion: `Use a useEffect hook to load behavior, or move analytics to useAnalytics().`
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return issues;
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
forbidden_script_tag_default = rule8;
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// src/lint/rules/use-app-no-availability-check.ts
|
|
481
|
+
var use_app_no_availability_check_exports = {};
|
|
482
|
+
__export(use_app_no_availability_check_exports, {
|
|
483
|
+
default: () => use_app_no_availability_check_default
|
|
484
|
+
});
|
|
485
|
+
var rule9, use_app_no_availability_check_default;
|
|
486
|
+
var init_use_app_no_availability_check = __esm({
|
|
487
|
+
"src/lint/rules/use-app-no-availability-check.ts"() {
|
|
488
|
+
"use strict";
|
|
489
|
+
rule9 = {
|
|
490
|
+
id: "use-app-no-availability-check",
|
|
491
|
+
description: "useApp() calls should branch on .available for graceful fallback",
|
|
492
|
+
check(ctx) {
|
|
493
|
+
const issues = [];
|
|
494
|
+
for (const [file, source] of Object.entries(ctx.sources)) {
|
|
495
|
+
if (!/\buseApp\s*\(/.test(source)) continue;
|
|
496
|
+
const hasAvailableCheck = /\.available\b/.test(source) || /\{\s*[^}]*\bavailable\b/.test(source);
|
|
497
|
+
if (!hasAvailableCheck) {
|
|
498
|
+
const lines = source.split("\n");
|
|
499
|
+
let lineNum = 0;
|
|
500
|
+
for (let i = 0; i < lines.length; i++) {
|
|
501
|
+
if (/\buseApp\s*\(/.test(lines[i])) {
|
|
502
|
+
lineNum = i + 1;
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
issues.push({
|
|
507
|
+
rule: rule9.id,
|
|
508
|
+
severity: "warning",
|
|
509
|
+
file,
|
|
510
|
+
line: lineNum,
|
|
511
|
+
message: `useApp() result is used without checking .available \u2014 UI breaks on stores without the app installed.`,
|
|
512
|
+
suggestion: `if (!app.available) return <Fallback />; before rendering app.data.`
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return issues;
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
use_app_no_availability_check_default = rule9;
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// src/lint/rules/manifest-required-fields.ts
|
|
524
|
+
var manifest_required_fields_exports = {};
|
|
525
|
+
__export(manifest_required_fields_exports, {
|
|
526
|
+
default: () => manifest_required_fields_default
|
|
527
|
+
});
|
|
528
|
+
var SEMVER, ID_RE, rule10, manifest_required_fields_default;
|
|
529
|
+
var init_manifest_required_fields = __esm({
|
|
530
|
+
"src/lint/rules/manifest-required-fields.ts"() {
|
|
531
|
+
"use strict";
|
|
532
|
+
SEMVER = /^\d+\.\d+\.\d+(?:[-+][\w.]+)?$/;
|
|
533
|
+
ID_RE = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/i;
|
|
534
|
+
rule10 = {
|
|
535
|
+
id: "manifest-required-fields",
|
|
536
|
+
description: "theme.json has id / name / version / author with valid shapes",
|
|
537
|
+
check(ctx) {
|
|
538
|
+
const issues = [];
|
|
539
|
+
const m = ctx.manifest;
|
|
540
|
+
if (!m.id || typeof m.id !== "string") {
|
|
541
|
+
issues.push({
|
|
542
|
+
rule: rule10.id,
|
|
543
|
+
severity: "error",
|
|
544
|
+
file: "theme.json",
|
|
545
|
+
message: "Missing required field 'id'."
|
|
546
|
+
});
|
|
547
|
+
} else if (!ID_RE.test(m.id)) {
|
|
548
|
+
issues.push({
|
|
549
|
+
rule: rule10.id,
|
|
550
|
+
severity: "error",
|
|
551
|
+
file: "theme.json",
|
|
552
|
+
message: `Field 'id' = '${m.id}' must be alphanumerics, dashes, or underscores.`
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
if (!m.name || typeof m.name !== "string" || !m.name.trim()) {
|
|
556
|
+
issues.push({
|
|
557
|
+
rule: rule10.id,
|
|
558
|
+
severity: "error",
|
|
559
|
+
file: "theme.json",
|
|
560
|
+
message: "Missing or empty 'name'."
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
if (!m.version || typeof m.version !== "string") {
|
|
564
|
+
issues.push({
|
|
565
|
+
rule: rule10.id,
|
|
566
|
+
severity: "error",
|
|
567
|
+
file: "theme.json",
|
|
568
|
+
message: "Missing required field 'version'."
|
|
569
|
+
});
|
|
570
|
+
} else if (!SEMVER.test(m.version)) {
|
|
571
|
+
issues.push({
|
|
572
|
+
rule: rule10.id,
|
|
573
|
+
severity: "error",
|
|
574
|
+
file: "theme.json",
|
|
575
|
+
message: `Field 'version' = '${m.version}' is not valid semver (x.y.z).`
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
if (!m.author || typeof m.author !== "string" || !m.author.trim()) {
|
|
579
|
+
issues.push({
|
|
580
|
+
rule: rule10.id,
|
|
581
|
+
severity: "warning",
|
|
582
|
+
file: "theme.json",
|
|
583
|
+
message: "Field 'author' is empty \u2014 marketplace submission requires a non-empty author.",
|
|
584
|
+
suggestion: `Set "author": "Your Name" in theme.json.`
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
return issues;
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
manifest_required_fields_default = rule10;
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// src/index.ts
|
|
595
|
+
var import_commander16 = require("commander");
|
|
596
|
+
|
|
597
|
+
// src/commands/init.ts
|
|
598
|
+
var import_commander = require("commander");
|
|
599
|
+
var fs = __toESM(require("fs"));
|
|
600
|
+
var path = __toESM(require("path"));
|
|
601
|
+
var initCommand = new import_commander.Command("init").description("Scaffold a new NUMU theme project").argument("<name>", "Theme name").option("--template <template>", "Starter template", "basic").action(async (name, _options) => {
|
|
602
|
+
const dir = path.resolve(process.cwd(), name);
|
|
603
|
+
if (fs.existsSync(dir)) {
|
|
604
|
+
console.error(`Directory "${name}" already exists`);
|
|
605
|
+
process.exit(1);
|
|
606
|
+
}
|
|
607
|
+
console.log(`Creating NUMU theme: ${name}...`);
|
|
608
|
+
for (const d of [
|
|
609
|
+
"src/sections",
|
|
610
|
+
"src/blocks",
|
|
611
|
+
"schemas/sections",
|
|
612
|
+
"schemas/blocks",
|
|
613
|
+
"assets"
|
|
614
|
+
]) {
|
|
615
|
+
fs.mkdirSync(path.join(dir, d), { recursive: true });
|
|
616
|
+
}
|
|
617
|
+
fs.writeFileSync(
|
|
618
|
+
path.join(dir, "theme.json"),
|
|
619
|
+
JSON.stringify(
|
|
620
|
+
{
|
|
621
|
+
id: name.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
|
622
|
+
name,
|
|
623
|
+
author: "",
|
|
624
|
+
version: "0.1.0",
|
|
625
|
+
layout: "single-column",
|
|
626
|
+
description: `${name} NUMU theme`,
|
|
627
|
+
// Phase 7.3 — static BYOT templates for chrome that renders
|
|
628
|
+
// outside the React tree (the streaming loading skeleton +
|
|
629
|
+
// the client error boundary). Themes that want to fully
|
|
630
|
+
// own those moments edit these HTML files; absent or 404
|
|
631
|
+
// → the platform's hardcoded fallback renders.
|
|
632
|
+
error_template: "templates/error.html",
|
|
633
|
+
loading_template: "templates/loading.html",
|
|
634
|
+
presets: {
|
|
635
|
+
templates: {
|
|
636
|
+
home: {
|
|
637
|
+
name: "Home",
|
|
638
|
+
sections: [
|
|
639
|
+
{
|
|
640
|
+
type: "hero",
|
|
641
|
+
settings: { headline: "Welcome to our store" }
|
|
642
|
+
}
|
|
643
|
+
]
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
null,
|
|
649
|
+
2
|
|
650
|
+
)
|
|
651
|
+
);
|
|
652
|
+
fs.mkdirSync(path.join(dir, "templates"), { recursive: true });
|
|
653
|
+
fs.writeFileSync(
|
|
654
|
+
path.join(dir, "templates/error.html"),
|
|
655
|
+
`<!--
|
|
656
|
+
Static BYOT error template \u2014 rendered by the storefront when a
|
|
657
|
+
page throws. No JS available (the bundle might be the thing that
|
|
658
|
+
failed). Use <button data-numu-reset> to expose a retry button \u2014
|
|
659
|
+
the storefront wires the click for you.
|
|
660
|
+
-->
|
|
661
|
+
<main role="alert" style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1rem;font-family:system-ui">
|
|
662
|
+
<div style="text-align:center;max-width:28rem">
|
|
663
|
+
<h1 style="font-size:1.5rem;font-weight:700;color:#b91c1c">Something went wrong</h1>
|
|
664
|
+
<p style="color:#374151;margin-top:.5rem">Please try again in a moment.</p>
|
|
665
|
+
<button data-numu-reset type="button" style="margin-top:1.5rem;padding:.5rem 1rem;background:#1d4ed8;color:white;border-radius:.375rem;border:0;cursor:pointer">Try again</button>
|
|
666
|
+
</div>
|
|
667
|
+
</main>
|
|
668
|
+
`
|
|
669
|
+
);
|
|
670
|
+
fs.writeFileSync(
|
|
671
|
+
path.join(dir, "templates/loading.html"),
|
|
672
|
+
`<!-- Static BYOT loading skeleton. -->
|
|
673
|
+
<div role="status" aria-live="polite" aria-label="Loading" style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1rem;font-family:system-ui">
|
|
674
|
+
<div style="display:flex;align-items:center;gap:.75rem;color:#4b5563">
|
|
675
|
+
<svg style="width:1.25rem;height:1.25rem;animation:spin 1s linear infinite" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
676
|
+
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity=".25" stroke-width="3"></circle>
|
|
677
|
+
<path d="M22 12a10 10 0 0 1-10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round"></path>
|
|
678
|
+
</svg>
|
|
679
|
+
<span style="font-size:.875rem;font-weight:500">Loading\u2026</span>
|
|
680
|
+
</div>
|
|
681
|
+
<style>@keyframes spin { to { transform: rotate(360deg) } }</style>
|
|
682
|
+
</div>
|
|
683
|
+
`
|
|
684
|
+
);
|
|
685
|
+
fs.writeFileSync(
|
|
686
|
+
path.join(dir, "settings_schema.json"),
|
|
687
|
+
JSON.stringify(
|
|
688
|
+
[
|
|
689
|
+
{
|
|
690
|
+
type: "color",
|
|
691
|
+
id: "primary_color",
|
|
692
|
+
label: "Primary Color",
|
|
693
|
+
default: "#3B82F6"
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
type: "color",
|
|
697
|
+
id: "secondary_color",
|
|
698
|
+
label: "Secondary Color",
|
|
699
|
+
default: "#1E40AF"
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
type: "select",
|
|
703
|
+
id: "font_family",
|
|
704
|
+
label: "Font Family",
|
|
705
|
+
default: "Inter",
|
|
706
|
+
options: [
|
|
707
|
+
{ value: "Inter", label: "Inter" },
|
|
708
|
+
{ value: "Roboto", label: "Roboto" },
|
|
709
|
+
{ value: "Cairo", label: "Cairo" }
|
|
710
|
+
]
|
|
711
|
+
}
|
|
712
|
+
],
|
|
713
|
+
null,
|
|
714
|
+
2
|
|
715
|
+
)
|
|
716
|
+
);
|
|
717
|
+
fs.writeFileSync(
|
|
718
|
+
path.join(dir, "styles.css"),
|
|
719
|
+
":root { --numu-primary: #3B82F6; }\n"
|
|
720
|
+
);
|
|
721
|
+
fs.writeFileSync(
|
|
722
|
+
path.join(dir, "src/sections/Hero.tsx"),
|
|
723
|
+
`import type { SectionProps } from "@numueg/theme-sdk";
|
|
724
|
+
|
|
725
|
+
export default function Hero({ settings }: SectionProps) {
|
|
726
|
+
return (
|
|
727
|
+
<section className="min-h-[50vh] flex items-center justify-center bg-gray-900 text-white">
|
|
728
|
+
<div className="text-center">
|
|
729
|
+
<h1 className="text-4xl font-bold">{settings.headline as string || "Welcome"}</h1>
|
|
730
|
+
{settings.subtitle ? (
|
|
731
|
+
<p className="mt-4 text-xl">{settings.subtitle as string}</p>
|
|
732
|
+
) : null}
|
|
733
|
+
</div>
|
|
734
|
+
</section>
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
`
|
|
738
|
+
);
|
|
739
|
+
fs.writeFileSync(
|
|
740
|
+
path.join(dir, "schemas/sections/hero.json"),
|
|
741
|
+
JSON.stringify(
|
|
742
|
+
{
|
|
743
|
+
type: "hero",
|
|
744
|
+
name: "Hero Banner",
|
|
745
|
+
locales: { ar: { name: "\u0628\u0627\u0646\u0631 \u0631\u0626\u064A\u0633\u064A" } },
|
|
746
|
+
settings: [
|
|
747
|
+
{
|
|
748
|
+
type: "text",
|
|
749
|
+
id: "headline",
|
|
750
|
+
label: "Headline",
|
|
751
|
+
locales: { ar: { label: "\u0627\u0644\u0639\u0646\u0648\u0627\u0646" } },
|
|
752
|
+
default: "Welcome"
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
type: "text",
|
|
756
|
+
id: "subtitle",
|
|
757
|
+
label: "Subtitle",
|
|
758
|
+
locales: { ar: { label: "\u0627\u0644\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0641\u0631\u0639\u064A" } }
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
type: "image_picker",
|
|
762
|
+
id: "background_image",
|
|
763
|
+
label: "Background Image",
|
|
764
|
+
locales: { ar: { label: "\u0635\u0648\u0631\u0629 \u0627\u0644\u062E\u0644\u0641\u064A\u0629" } }
|
|
765
|
+
}
|
|
766
|
+
]
|
|
767
|
+
},
|
|
768
|
+
null,
|
|
769
|
+
2
|
|
770
|
+
)
|
|
771
|
+
);
|
|
772
|
+
fs.writeFileSync(
|
|
773
|
+
path.join(dir, "src/main.tsx"),
|
|
774
|
+
`import { createRoot, type Root } from "react-dom/client";
|
|
775
|
+
import type {
|
|
776
|
+
ThemeSettingsV3,
|
|
777
|
+
Page,
|
|
778
|
+
Product,
|
|
779
|
+
Collection,
|
|
780
|
+
Store,
|
|
781
|
+
} from "@numueg/theme-sdk";
|
|
782
|
+
import {
|
|
783
|
+
usePage,
|
|
784
|
+
PageContext,
|
|
785
|
+
ProductProvider,
|
|
786
|
+
CollectionProvider,
|
|
787
|
+
NuMuProvider,
|
|
788
|
+
} from "@numueg/theme-sdk";
|
|
789
|
+
import Hero from "./sections/Hero";
|
|
790
|
+
|
|
791
|
+
interface ThemeProps {
|
|
792
|
+
themeSettings: ThemeSettingsV3;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const SECTION_REGISTRY: Record<string, React.ComponentType<any>> = {
|
|
796
|
+
hero: Hero,
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
export default function Theme({ themeSettings }: ThemeProps) {
|
|
800
|
+
const page = usePage();
|
|
801
|
+
const pageType = page?.type || "home";
|
|
802
|
+
const template =
|
|
803
|
+
themeSettings.templates?.[pageType] || themeSettings.templates?.home;
|
|
804
|
+
|
|
805
|
+
return (
|
|
806
|
+
<main>
|
|
807
|
+
{template?.order.map((sectionId) => {
|
|
808
|
+
const section = template.sections[sectionId];
|
|
809
|
+
if (!section || section.disabled) return null;
|
|
810
|
+
const Component = SECTION_REGISTRY[section.type];
|
|
811
|
+
if (!Component) return null;
|
|
812
|
+
return (
|
|
813
|
+
<Component
|
|
814
|
+
key={sectionId}
|
|
815
|
+
settings={section.settings}
|
|
816
|
+
blocks={section.blocks}
|
|
817
|
+
blockOrder={section.block_order}
|
|
818
|
+
/>
|
|
819
|
+
);
|
|
820
|
+
})}
|
|
821
|
+
</main>
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// \u2500\u2500 BYOT mount helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
826
|
+
// Required by Next.js storefront's <ByotThemeBoundary>. Owns the React
|
|
827
|
+
// render cycle for the bundle's subtree so hooks work.
|
|
828
|
+
|
|
829
|
+
interface MountProps {
|
|
830
|
+
themeSettings: ThemeSettingsV3;
|
|
831
|
+
storeData?: Store;
|
|
832
|
+
page?: Page & { data?: Record<string, unknown> };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Fallback Store used only when host didn't pass storeData. NuMuProvider
|
|
836
|
+
// hard-requires Store.currency for Intl.NumberFormat \u2014 synthesize one
|
|
837
|
+
// instead of crashing.
|
|
838
|
+
const FALLBACK_STORE: Store = {
|
|
839
|
+
id: "",
|
|
840
|
+
name: "",
|
|
841
|
+
slug: "",
|
|
842
|
+
currency: "USD",
|
|
843
|
+
default_language: "en",
|
|
844
|
+
use_nextjs_storefront: true,
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
function ThemeWithContext({ themeSettings, storeData, page }: MountProps) {
|
|
848
|
+
// Storefront returns snake_case \`default_currency\` / \`default_language\`
|
|
849
|
+
// but SDK Store expects \`currency\`. Map both shapes so NuMuProvider's
|
|
850
|
+
// Intl.NumberFormat doesn't blow up on an empty currency code.
|
|
851
|
+
const raw = (storeData ?? FALLBACK_STORE) as Record<string, unknown> & Store;
|
|
852
|
+
const store: Store = {
|
|
853
|
+
...raw,
|
|
854
|
+
currency:
|
|
855
|
+
(raw.currency as string) ||
|
|
856
|
+
(raw.default_currency as string) ||
|
|
857
|
+
FALLBACK_STORE.currency,
|
|
858
|
+
default_language:
|
|
859
|
+
(raw.default_language as string) || FALLBACK_STORE.default_language,
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
const product = page?.data?.product as Product | undefined;
|
|
863
|
+
const collection = page?.data?.collection as Collection | undefined;
|
|
864
|
+
const pageValue: Page = page
|
|
865
|
+
? {
|
|
866
|
+
type: page.type,
|
|
867
|
+
title: page.title || "",
|
|
868
|
+
handle: page.handle,
|
|
869
|
+
data: page.data,
|
|
870
|
+
}
|
|
871
|
+
: { type: "home", title: "" };
|
|
872
|
+
|
|
873
|
+
let tree = <Theme themeSettings={themeSettings} />;
|
|
874
|
+
if (collection)
|
|
875
|
+
tree = <CollectionProvider collection={collection}>{tree}</CollectionProvider>;
|
|
876
|
+
if (product)
|
|
877
|
+
tree = <ProductProvider product={product}>{tree}</ProductProvider>;
|
|
878
|
+
|
|
879
|
+
return (
|
|
880
|
+
<NuMuProvider store={store} themeSettings={themeSettings} locale={store.default_language}>
|
|
881
|
+
<PageContext.Provider value={pageValue}>{tree}</PageContext.Provider>
|
|
882
|
+
</NuMuProvider>
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// The host (\`ByotThemeBoundary\`) prefers the object-shape return:
|
|
887
|
+
// { unmount, update }
|
|
888
|
+
// When \`update\` is present, the customizer forwards prop-only changes
|
|
889
|
+
// (themeSettings / storeData / page) into the SAME React tree without
|
|
890
|
+
// re-importing the bundle. Without \`update\`, every settings tweak
|
|
891
|
+
// would trigger a full remount \u2014 fine, but visibly slower.
|
|
892
|
+
export interface MountHandle {
|
|
893
|
+
unmount: () => void;
|
|
894
|
+
update: (next: MountProps) => void;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
export function mount(el: HTMLElement, props: MountProps): MountHandle {
|
|
898
|
+
const root: Root = createRoot(el);
|
|
899
|
+
let current: MountProps = props;
|
|
900
|
+
root.render(<ThemeWithContext {...current} />);
|
|
901
|
+
|
|
902
|
+
// Live preview: the storefront's PreviewBridge forwards customizer edits
|
|
903
|
+
// as \`numu:theme-update\` window events. Re-render with the new payload.
|
|
904
|
+
// Also covered by the \`update\` method below \u2014 both paths funnel into
|
|
905
|
+
// the same root.render() so they can't drift.
|
|
906
|
+
function handleUpdate(e: Event) {
|
|
907
|
+
const detail = (e as CustomEvent<ThemeSettingsV3>).detail;
|
|
908
|
+
if (!detail || typeof detail !== "object") return;
|
|
909
|
+
current = { ...current, themeSettings: detail };
|
|
910
|
+
root.render(<ThemeWithContext {...current} />);
|
|
911
|
+
}
|
|
912
|
+
window.addEventListener("numu:theme-update", handleUpdate);
|
|
913
|
+
|
|
914
|
+
return {
|
|
915
|
+
unmount: () => {
|
|
916
|
+
window.removeEventListener("numu:theme-update", handleUpdate);
|
|
917
|
+
root.unmount();
|
|
918
|
+
},
|
|
919
|
+
update: (next: MountProps) => {
|
|
920
|
+
current = next;
|
|
921
|
+
root.render(<ThemeWithContext {...current} />);
|
|
922
|
+
},
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
`
|
|
926
|
+
);
|
|
927
|
+
fs.writeFileSync(
|
|
928
|
+
path.join(dir, "index.html"),
|
|
929
|
+
`<!doctype html>
|
|
930
|
+
<html lang="en">
|
|
931
|
+
<head>
|
|
932
|
+
<meta charset="UTF-8" />
|
|
933
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
934
|
+
<title>${name} \u2014 NUMU Theme Dev</title>
|
|
935
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
936
|
+
</head>
|
|
937
|
+
<body>
|
|
938
|
+
<div id="root"></div>
|
|
939
|
+
<script type="module" src="/src/dev-entry.tsx"></script>
|
|
940
|
+
</body>
|
|
941
|
+
</html>
|
|
942
|
+
`
|
|
943
|
+
);
|
|
944
|
+
fs.writeFileSync(
|
|
945
|
+
path.join(dir, "src/dev-entry.tsx"),
|
|
946
|
+
`import { createRoot } from "react-dom/client";
|
|
947
|
+
import Theme from "./main";
|
|
948
|
+
|
|
949
|
+
const placeholder = {
|
|
950
|
+
schema_version: 3 as const,
|
|
951
|
+
theme_id: "${name}",
|
|
952
|
+
global_settings: {},
|
|
953
|
+
templates: {
|
|
954
|
+
home: {
|
|
955
|
+
name: "Home",
|
|
956
|
+
sections: { hero_1: { type: "hero", settings: { headline: "Hello, theme!" } } },
|
|
957
|
+
order: ["hero_1"],
|
|
958
|
+
},
|
|
959
|
+
},
|
|
960
|
+
section_groups: {},
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
const root = document.getElementById("root");
|
|
964
|
+
if (root) createRoot(root).render(<Theme themeSettings={placeholder as any} />);
|
|
965
|
+
`
|
|
966
|
+
);
|
|
967
|
+
fs.writeFileSync(
|
|
968
|
+
path.join(dir, "vite.config.ts"),
|
|
969
|
+
`import { defineConfig } from "vite";
|
|
970
|
+
import react from "@vitejs/plugin-react";
|
|
971
|
+
import { numuTheme } from "@numueg/theme-plugin";
|
|
972
|
+
|
|
973
|
+
// The @numueg/theme-plugin handles all the NUMU-specific glue:
|
|
974
|
+
// - validates theme.json + settings_schema.json + entry point
|
|
975
|
+
// - externalizes React + @numueg/theme-sdk (host-provided)
|
|
976
|
+
// - emits dist/manifest.json + dist/import-map.json after build
|
|
977
|
+
//
|
|
978
|
+
// You don't need to repeat \`build.lib\` / \`build.rollupOptions.external\`
|
|
979
|
+
// \u2014 the plugin sets sensible defaults when they're omitted.
|
|
980
|
+
|
|
981
|
+
export default defineConfig({
|
|
982
|
+
plugins: [react(), numuTheme()],
|
|
983
|
+
server: { port: 5173 },
|
|
984
|
+
});
|
|
985
|
+
`
|
|
986
|
+
);
|
|
987
|
+
fs.writeFileSync(
|
|
988
|
+
path.join(dir, "tsconfig.json"),
|
|
989
|
+
JSON.stringify(
|
|
990
|
+
{
|
|
991
|
+
compilerOptions: {
|
|
992
|
+
target: "ES2022",
|
|
993
|
+
lib: ["ES2022", "DOM", "DOM.Iterable"],
|
|
994
|
+
module: "ESNext",
|
|
995
|
+
moduleResolution: "bundler",
|
|
996
|
+
jsx: "react-jsx",
|
|
997
|
+
strict: true,
|
|
998
|
+
noEmit: true,
|
|
999
|
+
skipLibCheck: true,
|
|
1000
|
+
isolatedModules: true,
|
|
1001
|
+
esModuleInterop: true,
|
|
1002
|
+
resolveJsonModule: true
|
|
1003
|
+
},
|
|
1004
|
+
include: ["src"]
|
|
1005
|
+
},
|
|
1006
|
+
null,
|
|
1007
|
+
2
|
|
1008
|
+
)
|
|
1009
|
+
);
|
|
1010
|
+
fs.writeFileSync(
|
|
1011
|
+
path.join(dir, "package.json"),
|
|
1012
|
+
JSON.stringify(
|
|
1013
|
+
{
|
|
1014
|
+
name: `numu-theme-${name}`,
|
|
1015
|
+
version: "0.1.0",
|
|
1016
|
+
private: true,
|
|
1017
|
+
type: "module",
|
|
1018
|
+
scripts: {
|
|
1019
|
+
dev: "numu-theme dev",
|
|
1020
|
+
build: "numu-theme build",
|
|
1021
|
+
check: "numu-theme check"
|
|
1022
|
+
},
|
|
1023
|
+
dependencies: { "@numueg/theme-sdk": "^0.1.0" },
|
|
1024
|
+
devDependencies: {
|
|
1025
|
+
"@numueg/theme-cli": "^0.1.0",
|
|
1026
|
+
"@numueg/theme-plugin": "^0.1.0",
|
|
1027
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
1028
|
+
vite: "^6.0.0",
|
|
1029
|
+
typescript: "^5.8.0",
|
|
1030
|
+
"@types/react": "^19.0.0",
|
|
1031
|
+
"@types/react-dom": "^19.0.0",
|
|
1032
|
+
react: "^19.0.0",
|
|
1033
|
+
"react-dom": "^19.0.0"
|
|
1034
|
+
}
|
|
1035
|
+
},
|
|
1036
|
+
null,
|
|
1037
|
+
2
|
|
1038
|
+
)
|
|
1039
|
+
);
|
|
1040
|
+
fs.writeFileSync(
|
|
1041
|
+
path.join(dir, ".gitignore"),
|
|
1042
|
+
"node_modules\ndist\n.env\n.env.*\n.next\n"
|
|
1043
|
+
);
|
|
1044
|
+
console.log(
|
|
1045
|
+
`
|
|
1046
|
+
Theme "${name}" created.
|
|
1047
|
+
|
|
1048
|
+
Next steps:
|
|
1049
|
+
cd ${name}
|
|
1050
|
+
npm install
|
|
1051
|
+
npx numu-theme dev # local preview
|
|
1052
|
+
npx numu-theme build # production build
|
|
1053
|
+
`
|
|
1054
|
+
);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
// src/commands/dev.ts
|
|
1058
|
+
var import_commander2 = require("commander");
|
|
1059
|
+
var import_child_process = require("child_process");
|
|
1060
|
+
|
|
1061
|
+
// src/utils/config.ts
|
|
1062
|
+
var fs2 = __toESM(require("fs"));
|
|
1063
|
+
var path2 = __toESM(require("path"));
|
|
1064
|
+
var os = __toESM(require("os"));
|
|
1065
|
+
var RC_FILE = path2.join(os.homedir(), ".numurc");
|
|
1066
|
+
function loadConfig() {
|
|
1067
|
+
const config = {
|
|
1068
|
+
api_url: process.env.NUMU_API_URL || "https://api.numu.io/api/v1"
|
|
1069
|
+
};
|
|
1070
|
+
if (fs2.existsSync(RC_FILE)) {
|
|
1071
|
+
try {
|
|
1072
|
+
const rc = JSON.parse(fs2.readFileSync(RC_FILE, "utf-8"));
|
|
1073
|
+
if (rc.token) config.token = rc.token;
|
|
1074
|
+
if (rc.api_url) config.api_url = rc.api_url;
|
|
1075
|
+
if (rc.store_id) config.store_id = rc.store_id;
|
|
1076
|
+
} catch {
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (process.env.NUMU_TOKEN) config.token = process.env.NUMU_TOKEN;
|
|
1080
|
+
if (process.env.NUMU_STORE_ID) config.store_id = process.env.NUMU_STORE_ID;
|
|
1081
|
+
return config;
|
|
1082
|
+
}
|
|
1083
|
+
function saveConfig(updates) {
|
|
1084
|
+
let existing = {};
|
|
1085
|
+
if (fs2.existsSync(RC_FILE)) {
|
|
1086
|
+
try {
|
|
1087
|
+
existing = JSON.parse(fs2.readFileSync(RC_FILE, "utf-8"));
|
|
1088
|
+
} catch {
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
const merged = { ...existing, ...updates };
|
|
1092
|
+
fs2.writeFileSync(RC_FILE, JSON.stringify(merged, null, 2), { mode: 384 });
|
|
1093
|
+
try {
|
|
1094
|
+
fs2.chmodSync(RC_FILE, 384);
|
|
1095
|
+
} catch {
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// src/utils/api.ts
|
|
1100
|
+
var https = __toESM(require("https"));
|
|
1101
|
+
var http = __toESM(require("http"));
|
|
1102
|
+
var fs3 = __toESM(require("fs"));
|
|
1103
|
+
function parseBody(body) {
|
|
1104
|
+
let raw;
|
|
1105
|
+
try {
|
|
1106
|
+
raw = JSON.parse(body);
|
|
1107
|
+
} catch {
|
|
1108
|
+
return { unwrapped: body, raw: body };
|
|
1109
|
+
}
|
|
1110
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw) && Object.prototype.hasOwnProperty.call(raw, "data")) {
|
|
1111
|
+
return { unwrapped: raw.data, raw };
|
|
1112
|
+
}
|
|
1113
|
+
return { unwrapped: raw, raw };
|
|
1114
|
+
}
|
|
1115
|
+
function assertHttpsOrLocalhost(urlStr) {
|
|
1116
|
+
const url = new URL(urlStr);
|
|
1117
|
+
if (url.protocol === "https:") return url;
|
|
1118
|
+
if (url.protocol === "http:") {
|
|
1119
|
+
const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1";
|
|
1120
|
+
if (!isLocal) {
|
|
1121
|
+
throw new Error(
|
|
1122
|
+
`Refusing to send credentials over HTTP to ${url.hostname}. Set NUMU_API_URL to an https:// URL.`
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
return url;
|
|
1126
|
+
}
|
|
1127
|
+
throw new Error(`Unsupported protocol: ${url.protocol}`);
|
|
1128
|
+
}
|
|
1129
|
+
async function apiRequest(method, path14, body) {
|
|
1130
|
+
const config = loadConfig();
|
|
1131
|
+
const url = assertHttpsOrLocalhost(`${config.api_url}${path14}`);
|
|
1132
|
+
const isHttps = url.protocol === "https:";
|
|
1133
|
+
const transport = isHttps ? https : http;
|
|
1134
|
+
return new Promise((resolve7, reject) => {
|
|
1135
|
+
const headers = {};
|
|
1136
|
+
if (config.token) headers["Authorization"] = `Bearer ${config.token}`;
|
|
1137
|
+
let postData;
|
|
1138
|
+
if (body !== void 0) {
|
|
1139
|
+
postData = Buffer.from(JSON.stringify(body), "utf8");
|
|
1140
|
+
headers["Content-Type"] = "application/json";
|
|
1141
|
+
headers["Content-Length"] = String(postData.byteLength);
|
|
1142
|
+
}
|
|
1143
|
+
const req = transport.request(
|
|
1144
|
+
{
|
|
1145
|
+
hostname: url.hostname,
|
|
1146
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
1147
|
+
path: url.pathname + url.search,
|
|
1148
|
+
method,
|
|
1149
|
+
headers
|
|
1150
|
+
},
|
|
1151
|
+
(res) => {
|
|
1152
|
+
let data = "";
|
|
1153
|
+
res.on("data", (chunk) => data += chunk);
|
|
1154
|
+
res.on("end", () => {
|
|
1155
|
+
const { unwrapped, raw } = parseBody(data);
|
|
1156
|
+
resolve7({
|
|
1157
|
+
status: res.statusCode ?? 0,
|
|
1158
|
+
data: unwrapped,
|
|
1159
|
+
raw
|
|
1160
|
+
});
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
);
|
|
1164
|
+
req.on("error", reject);
|
|
1165
|
+
if (postData) req.write(postData);
|
|
1166
|
+
req.end();
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
async function uploadFile(path_, filePath) {
|
|
1170
|
+
const config = loadConfig();
|
|
1171
|
+
const url = assertHttpsOrLocalhost(`${config.api_url}${path_}`);
|
|
1172
|
+
const fileBuffer = fs3.readFileSync(filePath);
|
|
1173
|
+
const boundary = "----NuMuCLI" + Date.now();
|
|
1174
|
+
const fileName = filePath.split(/[\\/]/).pop() || "theme.zip";
|
|
1175
|
+
const head = Buffer.from(
|
|
1176
|
+
`--${boundary}\r
|
|
1177
|
+
Content-Disposition: form-data; name="file"; filename="${fileName}"\r
|
|
1178
|
+
Content-Type: application/zip\r
|
|
1179
|
+
\r
|
|
1180
|
+
`,
|
|
1181
|
+
"utf8"
|
|
1182
|
+
);
|
|
1183
|
+
const tail = Buffer.from(`\r
|
|
1184
|
+
--${boundary}--\r
|
|
1185
|
+
`, "utf8");
|
|
1186
|
+
const fullBody = Buffer.concat([head, fileBuffer, tail]);
|
|
1187
|
+
const isHttps = url.protocol === "https:";
|
|
1188
|
+
const transport = isHttps ? https : http;
|
|
1189
|
+
return new Promise((resolve7, reject) => {
|
|
1190
|
+
const headers = {
|
|
1191
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
1192
|
+
"Content-Length": String(fullBody.byteLength)
|
|
1193
|
+
};
|
|
1194
|
+
if (config.token) headers["Authorization"] = `Bearer ${config.token}`;
|
|
1195
|
+
const req = transport.request(
|
|
1196
|
+
{
|
|
1197
|
+
hostname: url.hostname,
|
|
1198
|
+
port: url.port || (isHttps ? 443 : 80),
|
|
1199
|
+
// Preserve querystring so callers can pass `?queue_build=false`
|
|
1200
|
+
// (used by install/submit to keep the uploaded ZIP from being
|
|
1201
|
+
// consumed by the BYOT-from-zip Celery task before the
|
|
1202
|
+
// marketplace builder picks it up).
|
|
1203
|
+
path: url.pathname + (url.search || ""),
|
|
1204
|
+
method: "POST",
|
|
1205
|
+
headers
|
|
1206
|
+
},
|
|
1207
|
+
(res) => {
|
|
1208
|
+
let data = "";
|
|
1209
|
+
res.on("data", (chunk) => data += chunk);
|
|
1210
|
+
res.on("end", () => {
|
|
1211
|
+
const { unwrapped, raw } = parseBody(data);
|
|
1212
|
+
resolve7({
|
|
1213
|
+
status: res.statusCode ?? 0,
|
|
1214
|
+
data: unwrapped,
|
|
1215
|
+
raw
|
|
1216
|
+
});
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
);
|
|
1220
|
+
req.on("error", reject);
|
|
1221
|
+
req.write(fullBody);
|
|
1222
|
+
req.end();
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// src/commands/dev.ts
|
|
1227
|
+
var devCommand = new import_commander2.Command("dev").description("Start local development server with HMR").option("-p, --port <port>", "Dev server port", "5173").option("-s, --store <store_id>", "Store ID to register dev URL with").option(
|
|
1228
|
+
"--expose",
|
|
1229
|
+
"Bind to 0.0.0.0 instead of localhost (use only on trusted networks)",
|
|
1230
|
+
false
|
|
1231
|
+
).option(
|
|
1232
|
+
"--watch",
|
|
1233
|
+
"Also run `vite build --watch` so dist/theme.js auto-rebuilds on save",
|
|
1234
|
+
false
|
|
1235
|
+
).action(
|
|
1236
|
+
async (options) => {
|
|
1237
|
+
const config = loadConfig();
|
|
1238
|
+
const storeId = options.store || config.store_id;
|
|
1239
|
+
if (storeId && config.token) {
|
|
1240
|
+
console.log(`Registering dev server with store ${storeId}...`);
|
|
1241
|
+
try {
|
|
1242
|
+
const res = await apiRequest(
|
|
1243
|
+
"POST",
|
|
1244
|
+
`/stores/${encodeURIComponent(storeId)}/themes/external/dev-mode`,
|
|
1245
|
+
{
|
|
1246
|
+
dev_url: `http://localhost:${options.port}`,
|
|
1247
|
+
mode: "development"
|
|
1248
|
+
}
|
|
1249
|
+
);
|
|
1250
|
+
if (res.status >= 300) {
|
|
1251
|
+
console.warn(
|
|
1252
|
+
` Registration returned ${res.status}: ${JSON.stringify(res.data)}`
|
|
1253
|
+
);
|
|
1254
|
+
} else {
|
|
1255
|
+
console.log(" Registered. Edits will reflect in your storefront.");
|
|
1256
|
+
}
|
|
1257
|
+
} catch (err) {
|
|
1258
|
+
console.warn(
|
|
1259
|
+
` Could not register dev server: ${err.message}`
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
const args = ["vite", "--port", options.port];
|
|
1264
|
+
if (options.expose) args.push("--host");
|
|
1265
|
+
console.log(
|
|
1266
|
+
`Starting Vite on ${options.expose ? "0.0.0.0" : "localhost"}:${options.port}...`
|
|
1267
|
+
);
|
|
1268
|
+
const child = (0, import_child_process.spawn)("npx", args, { stdio: "inherit", shell: true });
|
|
1269
|
+
let watcher = null;
|
|
1270
|
+
if (options.watch) {
|
|
1271
|
+
console.log("Starting bundle watcher (vite build --watch)...");
|
|
1272
|
+
watcher = (0, import_child_process.spawn)(
|
|
1273
|
+
"npx",
|
|
1274
|
+
["vite", "build", "--watch", "--mode", "development"],
|
|
1275
|
+
{ stdio: "inherit", shell: true }
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
function shutdown(code) {
|
|
1279
|
+
if (watcher && !watcher.killed) {
|
|
1280
|
+
watcher.kill();
|
|
1281
|
+
}
|
|
1282
|
+
process.exit(code);
|
|
1283
|
+
}
|
|
1284
|
+
child.on("exit", (code) => shutdown(code ?? 0));
|
|
1285
|
+
if (watcher) {
|
|
1286
|
+
watcher.on("exit", (code) => {
|
|
1287
|
+
if (code !== 0 && code !== null) {
|
|
1288
|
+
console.error(`Bundle watcher exited with code ${code}.`);
|
|
1289
|
+
}
|
|
1290
|
+
if (!child.killed) child.kill();
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
// src/commands/check.ts
|
|
1297
|
+
var import_commander3 = require("commander");
|
|
1298
|
+
|
|
1299
|
+
// src/utils/validator.ts
|
|
1300
|
+
var fs4 = __toESM(require("fs"));
|
|
1301
|
+
var path3 = __toESM(require("path"));
|
|
1302
|
+
var semver = __toESM(require("semver"));
|
|
1303
|
+
function validateTheme(themeDir) {
|
|
1304
|
+
const errors = [];
|
|
1305
|
+
const warnings = [];
|
|
1306
|
+
const themeJsonPath = path3.join(themeDir, "theme.json");
|
|
1307
|
+
if (!fs4.existsSync(themeJsonPath)) {
|
|
1308
|
+
errors.push("Missing theme.json in project root");
|
|
1309
|
+
return { valid: false, errors, warnings };
|
|
1310
|
+
}
|
|
1311
|
+
let themeJson;
|
|
1312
|
+
try {
|
|
1313
|
+
themeJson = JSON.parse(fs4.readFileSync(themeJsonPath, "utf-8"));
|
|
1314
|
+
} catch {
|
|
1315
|
+
errors.push("theme.json is not valid JSON");
|
|
1316
|
+
return { valid: false, errors, warnings };
|
|
1317
|
+
}
|
|
1318
|
+
for (const field of ["name", "version", "author"]) {
|
|
1319
|
+
if (!themeJson[field]) errors.push(`theme.json missing required field: ${field}`);
|
|
1320
|
+
}
|
|
1321
|
+
if (themeJson.version && !semver.valid(themeJson.version)) {
|
|
1322
|
+
errors.push(`theme.json version "${themeJson.version}" is not valid semver`);
|
|
1323
|
+
}
|
|
1324
|
+
const settingsPath = path3.join(themeDir, "settings_schema.json");
|
|
1325
|
+
if (!fs4.existsSync(settingsPath)) {
|
|
1326
|
+
warnings.push("Missing settings_schema.json \u2014 theme will have no global settings");
|
|
1327
|
+
} else {
|
|
1328
|
+
try {
|
|
1329
|
+
JSON.parse(fs4.readFileSync(settingsPath, "utf-8"));
|
|
1330
|
+
} catch {
|
|
1331
|
+
errors.push("settings_schema.json is not valid JSON");
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
const sectionsDir = path3.join(themeDir, "src", "sections");
|
|
1335
|
+
if (!fs4.existsSync(sectionsDir) || fs4.readdirSync(sectionsDir).length === 0) {
|
|
1336
|
+
errors.push("Theme must have at least one section in src/sections/");
|
|
1337
|
+
}
|
|
1338
|
+
const schemaDir = path3.join(themeDir, "schemas", "sections");
|
|
1339
|
+
const componentNames = /* @__PURE__ */ new Set();
|
|
1340
|
+
const schemaNames = /* @__PURE__ */ new Set();
|
|
1341
|
+
if (fs4.existsSync(sectionsDir)) {
|
|
1342
|
+
for (const file of fs4.readdirSync(sectionsDir)) {
|
|
1343
|
+
if (!/\.(tsx|ts|jsx|js)$/.test(file)) continue;
|
|
1344
|
+
componentNames.add(path3.basename(file, path3.extname(file)).toLowerCase());
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
if (fs4.existsSync(schemaDir)) {
|
|
1348
|
+
for (const file of fs4.readdirSync(schemaDir)) {
|
|
1349
|
+
if (!file.endsWith(".json")) continue;
|
|
1350
|
+
schemaNames.add(path3.basename(file, ".json").toLowerCase());
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
for (const name of componentNames) {
|
|
1354
|
+
if (!schemaNames.has(name)) {
|
|
1355
|
+
warnings.push(
|
|
1356
|
+
`Section "${name}" has no schema at schemas/sections/${name}.json \u2014 merchants won't be able to add this section via the customizer.`
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
for (const name of schemaNames) {
|
|
1361
|
+
if (!componentNames.has(name)) {
|
|
1362
|
+
errors.push(
|
|
1363
|
+
`schemas/sections/${name}.json has no matching component at src/sections/<${name}>.tsx \u2014 the storefront will throw 'unknown section type' the moment a merchant adds it.`
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
const pkgPath = path3.join(themeDir, "package.json");
|
|
1368
|
+
if (fs4.existsSync(pkgPath)) {
|
|
1369
|
+
try {
|
|
1370
|
+
const pkg = JSON.parse(fs4.readFileSync(pkgPath, "utf-8"));
|
|
1371
|
+
const deps = { ...pkg.dependencies, ...pkg.peerDependencies };
|
|
1372
|
+
if (!deps["@numueg/theme-sdk"]) {
|
|
1373
|
+
warnings.push("@numueg/theme-sdk not found in dependencies \u2014 hooks will not be available");
|
|
1374
|
+
}
|
|
1375
|
+
} catch {
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
for (const envFile of [".env", ".env.local", ".env.production"]) {
|
|
1379
|
+
if (fs4.existsSync(path3.join(themeDir, envFile))) {
|
|
1380
|
+
errors.push(`${envFile} found \u2014 secrets must not be included in themes`);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
if (!themeJson.presets || Object.keys(themeJson.presets).length === 0) {
|
|
1384
|
+
warnings.push("theme.json has no presets \u2014 merchants will start with an empty page");
|
|
1385
|
+
}
|
|
1386
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// src/commands/check.ts
|
|
1390
|
+
var checkCommand = new import_commander3.Command("check").description("Validate theme schemas and structure").option("-d, --dir <directory>", "Theme directory", ".").action(async (options) => {
|
|
1391
|
+
console.log("Validating theme...");
|
|
1392
|
+
const result = validateTheme(options.dir);
|
|
1393
|
+
if (result.warnings.length > 0) {
|
|
1394
|
+
console.log("\nWarnings:");
|
|
1395
|
+
result.warnings.forEach((w) => console.log(` \u26A0 ${w}`));
|
|
1396
|
+
}
|
|
1397
|
+
if (result.errors.length > 0) {
|
|
1398
|
+
console.log("\nErrors:");
|
|
1399
|
+
result.errors.forEach((e) => console.log(` \u2717 ${e}`));
|
|
1400
|
+
console.log(`
|
|
1401
|
+
Validation failed with ${result.errors.length} error(s)`);
|
|
1402
|
+
process.exit(1);
|
|
1403
|
+
}
|
|
1404
|
+
console.log(`
|
|
1405
|
+
\u2713 Theme is valid (${result.warnings.length} warning(s))`);
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
// src/commands/lint.ts
|
|
1409
|
+
var import_commander4 = require("commander");
|
|
1410
|
+
var fs6 = __toESM(require("fs"));
|
|
1411
|
+
var path5 = __toESM(require("path"));
|
|
1412
|
+
|
|
1413
|
+
// src/lint/runner.ts
|
|
1414
|
+
var fs5 = __toESM(require("fs"));
|
|
1415
|
+
var path4 = __toESM(require("path"));
|
|
1416
|
+
async function runAllRules(themeDir, options) {
|
|
1417
|
+
const ctx = buildContext(themeDir);
|
|
1418
|
+
const ruleLoaders = [
|
|
1419
|
+
() => Promise.resolve().then(() => (init_schema_registry_sync(), schema_registry_sync_exports)),
|
|
1420
|
+
() => Promise.resolve().then(() => (init_locale_parity(), locale_parity_exports)),
|
|
1421
|
+
() => Promise.resolve().then(() => (init_preset_schema_conformance(), preset_schema_conformance_exports)),
|
|
1422
|
+
() => Promise.resolve().then(() => (init_unused_settings(), unused_settings_exports)),
|
|
1423
|
+
() => Promise.resolve().then(() => (init_img_missing_alt(), img_missing_alt_exports)),
|
|
1424
|
+
() => Promise.resolve().then(() => (init_hardcoded_text(), hardcoded_text_exports)),
|
|
1425
|
+
() => Promise.resolve().then(() => (init_inline_color_literal(), inline_color_literal_exports)),
|
|
1426
|
+
() => Promise.resolve().then(() => (init_forbidden_script_tag(), forbidden_script_tag_exports)),
|
|
1427
|
+
() => Promise.resolve().then(() => (init_use_app_no_availability_check(), use_app_no_availability_check_exports)),
|
|
1428
|
+
() => Promise.resolve().then(() => (init_manifest_required_fields(), manifest_required_fields_exports))
|
|
1429
|
+
];
|
|
1430
|
+
const issues = [];
|
|
1431
|
+
for (const load of ruleLoaders) {
|
|
1432
|
+
let mod;
|
|
1433
|
+
try {
|
|
1434
|
+
mod = await load();
|
|
1435
|
+
} catch (e) {
|
|
1436
|
+
issues.push({
|
|
1437
|
+
rule: "internal",
|
|
1438
|
+
severity: "warning",
|
|
1439
|
+
message: `Failed to load rule module: ${e.message}`
|
|
1440
|
+
});
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
const rule11 = mod.default;
|
|
1444
|
+
if (options.enabledRules && !options.enabledRules.has(rule11.id)) continue;
|
|
1445
|
+
try {
|
|
1446
|
+
const ruleIssues = await rule11.check(ctx);
|
|
1447
|
+
for (const issue of ruleIssues) issues.push(issue);
|
|
1448
|
+
} catch (e) {
|
|
1449
|
+
issues.push({
|
|
1450
|
+
rule: rule11.id,
|
|
1451
|
+
severity: "warning",
|
|
1452
|
+
message: `Rule crashed: ${e.message}`
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
return issues;
|
|
1457
|
+
}
|
|
1458
|
+
function buildContext(themeDir) {
|
|
1459
|
+
const manifest = readJson(path4.join(themeDir, "theme.json"), {});
|
|
1460
|
+
const settingsSchema = readJson(
|
|
1461
|
+
path4.join(themeDir, "settings_schema.json"),
|
|
1462
|
+
[]
|
|
1463
|
+
);
|
|
1464
|
+
const sectionSchemas = readSchemaDir(
|
|
1465
|
+
path4.join(themeDir, "schemas", "sections")
|
|
1466
|
+
);
|
|
1467
|
+
const blockSchemas = readSchemaDir(path4.join(themeDir, "schemas", "blocks"));
|
|
1468
|
+
const locales = readLocales(path4.join(themeDir, "locales"));
|
|
1469
|
+
const sources = readSources(themeDir);
|
|
1470
|
+
return {
|
|
1471
|
+
themeDir,
|
|
1472
|
+
manifest,
|
|
1473
|
+
settingsSchema,
|
|
1474
|
+
sectionSchemas,
|
|
1475
|
+
blockSchemas,
|
|
1476
|
+
locales,
|
|
1477
|
+
sources
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
function readJson(filePath, fallback) {
|
|
1481
|
+
if (!fs5.existsSync(filePath)) return fallback;
|
|
1482
|
+
try {
|
|
1483
|
+
return JSON.parse(fs5.readFileSync(filePath, "utf-8"));
|
|
1484
|
+
} catch {
|
|
1485
|
+
return fallback;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
function readSchemaDir(dir) {
|
|
1489
|
+
const out = {};
|
|
1490
|
+
if (!fs5.existsSync(dir)) return out;
|
|
1491
|
+
for (const file of fs5.readdirSync(dir)) {
|
|
1492
|
+
if (!file.endsWith(".json")) continue;
|
|
1493
|
+
const parsed = readJson(path4.join(dir, file), null);
|
|
1494
|
+
if (parsed && typeof parsed === "object") {
|
|
1495
|
+
const type = parsed.type || file.replace(/\.json$/, "");
|
|
1496
|
+
out[type] = parsed;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
return out;
|
|
1500
|
+
}
|
|
1501
|
+
function readLocales(dir) {
|
|
1502
|
+
const out = {};
|
|
1503
|
+
if (!fs5.existsSync(dir)) return out;
|
|
1504
|
+
for (const file of fs5.readdirSync(dir)) {
|
|
1505
|
+
if (!file.endsWith(".json")) continue;
|
|
1506
|
+
const locale = file.replace(/\.(default\.)?json$/, "");
|
|
1507
|
+
const parsed = readJson(path4.join(dir, file), {});
|
|
1508
|
+
out[locale] = parsed;
|
|
1509
|
+
}
|
|
1510
|
+
return out;
|
|
1511
|
+
}
|
|
1512
|
+
function readSources(themeDir) {
|
|
1513
|
+
const out = {};
|
|
1514
|
+
const srcDir = path4.join(themeDir, "src");
|
|
1515
|
+
if (!fs5.existsSync(srcDir)) return out;
|
|
1516
|
+
walk(srcDir, (file) => {
|
|
1517
|
+
if (!/\.(tsx?|jsx?)$/.test(file)) return;
|
|
1518
|
+
const rel = path4.relative(themeDir, file).replace(/\\/g, "/");
|
|
1519
|
+
out[rel] = fs5.readFileSync(file, "utf-8");
|
|
1520
|
+
});
|
|
1521
|
+
return out;
|
|
1522
|
+
}
|
|
1523
|
+
function walk(dir, visit) {
|
|
1524
|
+
for (const entry of fs5.readdirSync(dir, { withFileTypes: true })) {
|
|
1525
|
+
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
|
1526
|
+
const full = path4.join(dir, entry.name);
|
|
1527
|
+
if (entry.isDirectory()) walk(full, visit);
|
|
1528
|
+
else visit(full);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// src/commands/lint.ts
|
|
1533
|
+
var lintCommand = new import_commander4.Command("lint").description("Lint a NUMU theme against the platform's contract + best practices").option("-d, --dir <directory>", "Theme directory", ".").option("--strict", "Treat warnings as errors (exit 1)").option("--json", "Output JSON instead of human-readable").option(
|
|
1534
|
+
"--rules <rules>",
|
|
1535
|
+
"Comma-separated list of rule IDs to run (default: all)"
|
|
1536
|
+
).action(
|
|
1537
|
+
async (options) => {
|
|
1538
|
+
const themeDir = path5.resolve(process.cwd(), options.dir);
|
|
1539
|
+
if (!fs6.existsSync(path5.join(themeDir, "theme.json"))) {
|
|
1540
|
+
console.error(
|
|
1541
|
+
`No theme.json found in ${themeDir}. Run 'numu-theme lint' from a theme project root.`
|
|
1542
|
+
);
|
|
1543
|
+
process.exit(1);
|
|
1544
|
+
}
|
|
1545
|
+
const enabledRules = options.rules ? new Set(options.rules.split(",").map((r) => r.trim())) : null;
|
|
1546
|
+
const issues = await runAllRules(themeDir, {
|
|
1547
|
+
enabledRules
|
|
1548
|
+
});
|
|
1549
|
+
if (options.json) {
|
|
1550
|
+
console.log(JSON.stringify({ issues }, null, 2));
|
|
1551
|
+
const hasErrors = issues.some((i) => i.severity === "error");
|
|
1552
|
+
process.exit(hasErrors || options.strict && issues.length > 0 ? 1 : 0);
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
if (issues.length === 0) {
|
|
1556
|
+
console.log("\u2713 No issues found.");
|
|
1557
|
+
process.exit(0);
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
const grouped = groupByFile(issues);
|
|
1561
|
+
for (const [file, fileIssues] of Object.entries(grouped)) {
|
|
1562
|
+
console.log(`
|
|
1563
|
+
${file}`);
|
|
1564
|
+
for (const issue of fileIssues) {
|
|
1565
|
+
const marker = issue.severity === "error" ? "\u2718" : "\u26A0";
|
|
1566
|
+
const loc = issue.line ? `:${issue.line}` : "";
|
|
1567
|
+
console.log(` ${marker} ${issue.rule}${loc} \u2014 ${issue.message}`);
|
|
1568
|
+
if (issue.suggestion) {
|
|
1569
|
+
console.log(` \u21B3 ${issue.suggestion}`);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
1574
|
+
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
1575
|
+
console.log(
|
|
1576
|
+
`
|
|
1577
|
+
${errorCount} error${errorCount === 1 ? "" : "s"}, ${warnCount} warning${warnCount === 1 ? "" : "s"}.`
|
|
1578
|
+
);
|
|
1579
|
+
const shouldFail = errorCount > 0 || options.strict && warnCount > 0;
|
|
1580
|
+
process.exit(shouldFail ? 1 : 0);
|
|
1581
|
+
}
|
|
1582
|
+
);
|
|
1583
|
+
function groupByFile(issues) {
|
|
1584
|
+
const out = {};
|
|
1585
|
+
for (const issue of issues) {
|
|
1586
|
+
const key = issue.file || "(theme-wide)";
|
|
1587
|
+
if (!out[key]) out[key] = [];
|
|
1588
|
+
out[key].push(issue);
|
|
1589
|
+
}
|
|
1590
|
+
return out;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// src/commands/build.ts
|
|
1594
|
+
var import_commander5 = require("commander");
|
|
1595
|
+
var import_child_process2 = require("child_process");
|
|
1596
|
+
var fs7 = __toESM(require("fs"));
|
|
1597
|
+
var path6 = __toESM(require("path"));
|
|
1598
|
+
var buildCommand = new import_commander5.Command("build").description("Build the theme for production").option("-d, --dir <directory>", "Theme directory", ".").action(async (options) => {
|
|
1599
|
+
console.log("Validating before build...");
|
|
1600
|
+
const result = validateTheme(options.dir);
|
|
1601
|
+
if (!result.valid) {
|
|
1602
|
+
console.error("Validation failed. Fix errors before building.");
|
|
1603
|
+
result.errors.forEach((e) => console.error(` \u2717 ${e}`));
|
|
1604
|
+
process.exit(1);
|
|
1605
|
+
}
|
|
1606
|
+
console.log("Building theme...");
|
|
1607
|
+
try {
|
|
1608
|
+
(0, import_child_process2.execSync)("npx vite build", { cwd: options.dir, stdio: "inherit" });
|
|
1609
|
+
} catch {
|
|
1610
|
+
console.error("Build failed");
|
|
1611
|
+
process.exit(1);
|
|
1612
|
+
}
|
|
1613
|
+
const distDir = path6.join(options.dir, "dist");
|
|
1614
|
+
if (fs7.existsSync(distDir)) {
|
|
1615
|
+
let totalSize = 0;
|
|
1616
|
+
const files = fs7.readdirSync(distDir, { recursive: true });
|
|
1617
|
+
for (const file of files) {
|
|
1618
|
+
const filePath = path6.join(distDir, file);
|
|
1619
|
+
if (fs7.statSync(filePath).isFile()) totalSize += fs7.statSync(filePath).size;
|
|
1620
|
+
}
|
|
1621
|
+
const sizeMB = (totalSize / 1024 / 1024).toFixed(2);
|
|
1622
|
+
console.log(`
|
|
1623
|
+
Bundle size: ${sizeMB} MB`);
|
|
1624
|
+
if (totalSize > 5 * 1024 * 1024) {
|
|
1625
|
+
console.warn("\u26A0 Bundle exceeds 5MB limit \u2014 optimize before submitting to marketplace");
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
console.log("\n\u2713 Build complete");
|
|
1629
|
+
});
|
|
1630
|
+
|
|
1631
|
+
// src/commands/push.ts
|
|
1632
|
+
var import_commander6 = require("commander");
|
|
1633
|
+
var path7 = __toESM(require("path"));
|
|
1634
|
+
var os2 = __toESM(require("os"));
|
|
1635
|
+
|
|
1636
|
+
// src/utils/zipper.ts
|
|
1637
|
+
var fs8 = __toESM(require("fs"));
|
|
1638
|
+
var import_archiver = __toESM(require("archiver"));
|
|
1639
|
+
async function zipDirectory(sourceDir, outputPath, optsOrLegacyExcludes = {}) {
|
|
1640
|
+
const opts = Array.isArray(optsOrLegacyExcludes) ? { excludePatterns: optsOrLegacyExcludes } : optsOrLegacyExcludes;
|
|
1641
|
+
return new Promise((resolve7, reject) => {
|
|
1642
|
+
const output = fs8.createWriteStream(outputPath);
|
|
1643
|
+
const archive = (0, import_archiver.default)("zip", { zlib: { level: 9 } });
|
|
1644
|
+
output.on("close", () => resolve7(outputPath));
|
|
1645
|
+
archive.on("error", reject);
|
|
1646
|
+
archive.pipe(output);
|
|
1647
|
+
const defaultExcludes = [
|
|
1648
|
+
"node_modules/**",
|
|
1649
|
+
".git/**",
|
|
1650
|
+
".env",
|
|
1651
|
+
".env.*",
|
|
1652
|
+
".next/**"
|
|
1653
|
+
];
|
|
1654
|
+
if (!opts.includeDist) defaultExcludes.push("dist/**");
|
|
1655
|
+
const allExcludes = [...defaultExcludes, ...opts.excludePatterns ?? []];
|
|
1656
|
+
archive.glob("**/*", {
|
|
1657
|
+
cwd: sourceDir,
|
|
1658
|
+
ignore: allExcludes,
|
|
1659
|
+
dot: false
|
|
1660
|
+
});
|
|
1661
|
+
archive.finalize();
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// src/commands/push.ts
|
|
1666
|
+
var pushCommand = new import_commander6.Command("push").description("Upload built theme to your developer account for testing").option("-d, --dir <directory>", "Theme directory", ".").action(async (options) => {
|
|
1667
|
+
const config = loadConfig();
|
|
1668
|
+
if (!config.token) {
|
|
1669
|
+
console.error("Not logged in. Run: numu-theme login");
|
|
1670
|
+
process.exit(1);
|
|
1671
|
+
}
|
|
1672
|
+
console.log("Validating...");
|
|
1673
|
+
const result = validateTheme(options.dir);
|
|
1674
|
+
if (!result.valid) {
|
|
1675
|
+
result.errors.forEach((e) => console.error(` \u2717 ${e}`));
|
|
1676
|
+
process.exit(1);
|
|
1677
|
+
}
|
|
1678
|
+
console.log("Packaging theme...");
|
|
1679
|
+
const zipPath = path7.join(os2.tmpdir(), `numu-theme-${Date.now()}.zip`);
|
|
1680
|
+
await zipDirectory(options.dir, zipPath);
|
|
1681
|
+
console.log("Uploading...");
|
|
1682
|
+
const res = await uploadFile("/themes/upload", zipPath);
|
|
1683
|
+
if (res.status === 200 || res.status === 201 || res.status === 202) {
|
|
1684
|
+
console.log("\n\u2713 Theme uploaded. Build queued.");
|
|
1685
|
+
if (res.data?.build_id) {
|
|
1686
|
+
console.log(` Build ID: ${res.data.build_id}`);
|
|
1687
|
+
console.log(" Poll status with:");
|
|
1688
|
+
console.log(
|
|
1689
|
+
` numu-theme status --build ${res.data.build_id}`
|
|
1690
|
+
);
|
|
1691
|
+
}
|
|
1692
|
+
} else {
|
|
1693
|
+
console.error(`
|
|
1694
|
+
Push failed (${res.status}): ${JSON.stringify(res.data)}`);
|
|
1695
|
+
process.exit(1);
|
|
1696
|
+
}
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
// src/commands/submit.ts
|
|
1700
|
+
var import_commander7 = require("commander");
|
|
1701
|
+
var path8 = __toESM(require("path"));
|
|
1702
|
+
var os3 = __toESM(require("os"));
|
|
1703
|
+
var fs9 = __toESM(require("fs"));
|
|
1704
|
+
var submitCommand = new import_commander7.Command("submit").description("Submit theme to the NUMU Marketplace for review").option("-d, --dir <directory>", "Theme directory", ".").requiredOption(
|
|
1705
|
+
"-t, --theme-id <theme_id>",
|
|
1706
|
+
"Marketplace theme listing UUID (create via dashboard first)"
|
|
1707
|
+
).option("-n, --notes <notes>", "Release notes for this version").action(
|
|
1708
|
+
async (options) => {
|
|
1709
|
+
const config = loadConfig();
|
|
1710
|
+
if (!config.token) {
|
|
1711
|
+
console.error("Not logged in. Run: numu-theme login");
|
|
1712
|
+
process.exit(1);
|
|
1713
|
+
}
|
|
1714
|
+
console.log("Running full validation...");
|
|
1715
|
+
const result = validateTheme(options.dir);
|
|
1716
|
+
if (!result.valid) {
|
|
1717
|
+
console.error(
|
|
1718
|
+
"Theme must pass all validations before marketplace submission."
|
|
1719
|
+
);
|
|
1720
|
+
result.errors.forEach((e) => console.error(` \u2717 ${e}`));
|
|
1721
|
+
process.exit(1);
|
|
1722
|
+
}
|
|
1723
|
+
if (result.warnings.length > 0) {
|
|
1724
|
+
console.log("\nWarnings:");
|
|
1725
|
+
result.warnings.forEach((w) => console.log(` \u26A0 ${w}`));
|
|
1726
|
+
}
|
|
1727
|
+
const themeJsonPath = path8.join(options.dir, "theme.json");
|
|
1728
|
+
let version;
|
|
1729
|
+
try {
|
|
1730
|
+
const tj = JSON.parse(fs9.readFileSync(themeJsonPath, "utf-8"));
|
|
1731
|
+
version = tj.version;
|
|
1732
|
+
} catch {
|
|
1733
|
+
console.error("Cannot read theme.json from theme directory.");
|
|
1734
|
+
process.exit(1);
|
|
1735
|
+
}
|
|
1736
|
+
if (!version) {
|
|
1737
|
+
console.error("theme.json has no `version` field.");
|
|
1738
|
+
process.exit(1);
|
|
1739
|
+
}
|
|
1740
|
+
console.log("\nPackaging theme source...");
|
|
1741
|
+
const zipPath = path8.join(
|
|
1742
|
+
os3.tmpdir(),
|
|
1743
|
+
`numu-theme-submit-${Date.now()}.zip`
|
|
1744
|
+
);
|
|
1745
|
+
await zipDirectory(options.dir, zipPath);
|
|
1746
|
+
console.log("Uploading source...");
|
|
1747
|
+
const uploadRes = await uploadFile(
|
|
1748
|
+
"/themes/upload?queue_build=false",
|
|
1749
|
+
zipPath
|
|
1750
|
+
);
|
|
1751
|
+
if (uploadRes.status >= 300) {
|
|
1752
|
+
console.error(
|
|
1753
|
+
`
|
|
1754
|
+
Upload failed (${uploadRes.status}): ${JSON.stringify(uploadRes.data)}`
|
|
1755
|
+
);
|
|
1756
|
+
process.exit(1);
|
|
1757
|
+
}
|
|
1758
|
+
const sourceZipPath = uploadRes.data?.source_zip_path;
|
|
1759
|
+
if (!sourceZipPath) {
|
|
1760
|
+
console.error(
|
|
1761
|
+
"\nUpload response missing `source_zip_path`. Backend may be out of date."
|
|
1762
|
+
);
|
|
1763
|
+
process.exit(1);
|
|
1764
|
+
}
|
|
1765
|
+
console.log(`Submitting version ${version} to marketplace...`);
|
|
1766
|
+
const submitRes = await apiRequest(
|
|
1767
|
+
"POST",
|
|
1768
|
+
`/marketplace/developer/themes/${encodeURIComponent(options.themeId)}/versions`,
|
|
1769
|
+
{
|
|
1770
|
+
version_string: version,
|
|
1771
|
+
source_zip_path: sourceZipPath,
|
|
1772
|
+
release_notes: options.notes ?? null
|
|
1773
|
+
}
|
|
1774
|
+
);
|
|
1775
|
+
if (submitRes.status === 200 || submitRes.status === 202) {
|
|
1776
|
+
console.log("\n\u2713 Theme submitted for review!");
|
|
1777
|
+
console.log(` Version ID: ${submitRes.data.version_id}`);
|
|
1778
|
+
console.log(` Status: ${submitRes.data.status}`);
|
|
1779
|
+
console.log("\n Poll status with:");
|
|
1780
|
+
console.log(
|
|
1781
|
+
` numu-theme status --version ${submitRes.data.version_id}`
|
|
1782
|
+
);
|
|
1783
|
+
} else {
|
|
1784
|
+
console.error(
|
|
1785
|
+
`
|
|
1786
|
+
Submission failed (${submitRes.status}): ${JSON.stringify(submitRes.data)}`
|
|
1787
|
+
);
|
|
1788
|
+
process.exit(1);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
);
|
|
1792
|
+
|
|
1793
|
+
// src/commands/install.ts
|
|
1794
|
+
var import_commander8 = require("commander");
|
|
1795
|
+
var import_child_process3 = require("child_process");
|
|
1796
|
+
var path9 = __toESM(require("path"));
|
|
1797
|
+
var os4 = __toESM(require("os"));
|
|
1798
|
+
var fs10 = __toESM(require("fs"));
|
|
1799
|
+
var installCommand = new import_commander8.Command("install").description(
|
|
1800
|
+
"Build + upload + install a theme directly on a store you own (bypasses marketplace review)"
|
|
1801
|
+
).option("-d, --dir <directory>", "Theme directory", ".").requiredOption(
|
|
1802
|
+
"-t, --theme-id <theme_id>",
|
|
1803
|
+
"Marketplace theme listing UUID (create via dashboard first)"
|
|
1804
|
+
).requiredOption(
|
|
1805
|
+
"-s, --store <store_id>",
|
|
1806
|
+
"Store UUID to install on. Falls back to the configured store_id when omitted."
|
|
1807
|
+
).option(
|
|
1808
|
+
"--poll-timeout <seconds>",
|
|
1809
|
+
"How long to wait for the build to finish (default: 240)",
|
|
1810
|
+
"240"
|
|
1811
|
+
).option(
|
|
1812
|
+
"--poll-interval <seconds>",
|
|
1813
|
+
"How often to poll build status (default: 4)",
|
|
1814
|
+
"4"
|
|
1815
|
+
).option("-n, --notes <notes>", "Release notes for this version").action(
|
|
1816
|
+
async (options) => {
|
|
1817
|
+
const config = loadConfig();
|
|
1818
|
+
if (!config.token) {
|
|
1819
|
+
console.error("Not logged in. Run: numu-theme login");
|
|
1820
|
+
process.exit(1);
|
|
1821
|
+
}
|
|
1822
|
+
const storeId = options.store || config.store_id;
|
|
1823
|
+
if (!storeId) {
|
|
1824
|
+
console.error(
|
|
1825
|
+
"No store specified. Pass --store <id> or set store_id in your config."
|
|
1826
|
+
);
|
|
1827
|
+
process.exit(1);
|
|
1828
|
+
}
|
|
1829
|
+
console.log("Validating theme...");
|
|
1830
|
+
const result = validateTheme(options.dir);
|
|
1831
|
+
if (!result.valid) {
|
|
1832
|
+
console.error("Theme has validation errors. Fix these first:");
|
|
1833
|
+
result.errors.forEach((e) => console.error(` \u2717 ${e}`));
|
|
1834
|
+
process.exit(1);
|
|
1835
|
+
}
|
|
1836
|
+
result.warnings.forEach((w) => console.log(` \u26A0 ${w}`));
|
|
1837
|
+
const themeJsonPath = path9.join(options.dir, "theme.json");
|
|
1838
|
+
let baseVersion;
|
|
1839
|
+
try {
|
|
1840
|
+
const tj = JSON.parse(fs10.readFileSync(themeJsonPath, "utf-8"));
|
|
1841
|
+
baseVersion = tj.version;
|
|
1842
|
+
} catch {
|
|
1843
|
+
console.error("Cannot read theme.json from theme directory.");
|
|
1844
|
+
process.exit(1);
|
|
1845
|
+
}
|
|
1846
|
+
if (!baseVersion) {
|
|
1847
|
+
console.error("theme.json has no `version` field.");
|
|
1848
|
+
process.exit(1);
|
|
1849
|
+
}
|
|
1850
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\..+/, "Z") + "-" + Math.random().toString(36).slice(2, 6);
|
|
1851
|
+
const version = `${baseVersion}-dev.${stamp}`;
|
|
1852
|
+
console.log(` base version: ${baseVersion}`);
|
|
1853
|
+
console.log(` install tag: ${version}`);
|
|
1854
|
+
console.log("Building locally (so worker can skip npm install)...");
|
|
1855
|
+
try {
|
|
1856
|
+
(0, import_child_process3.execSync)("npm run build", {
|
|
1857
|
+
cwd: options.dir,
|
|
1858
|
+
stdio: "inherit",
|
|
1859
|
+
env: { ...process.env, NODE_ENV: "production" }
|
|
1860
|
+
});
|
|
1861
|
+
} catch {
|
|
1862
|
+
console.error(
|
|
1863
|
+
"\nLocal build failed. Fix the build errors above before running install again."
|
|
1864
|
+
);
|
|
1865
|
+
process.exit(1);
|
|
1866
|
+
}
|
|
1867
|
+
const distEntry = path9.join(options.dir, "dist", "theme.js");
|
|
1868
|
+
if (!fs10.existsSync(distEntry)) {
|
|
1869
|
+
console.error(
|
|
1870
|
+
"\nBuild completed but dist/theme.js is missing. Check vite.config.ts entry / output filename."
|
|
1871
|
+
);
|
|
1872
|
+
process.exit(1);
|
|
1873
|
+
}
|
|
1874
|
+
console.log("Packaging source + dist...");
|
|
1875
|
+
const zipPath = path9.join(
|
|
1876
|
+
os4.tmpdir(),
|
|
1877
|
+
`numu-theme-install-${Date.now()}.zip`
|
|
1878
|
+
);
|
|
1879
|
+
await zipDirectory(options.dir, zipPath, { includeDist: true });
|
|
1880
|
+
console.log("Uploading...");
|
|
1881
|
+
const uploadRes = await uploadFile(
|
|
1882
|
+
"/themes/upload?queue_build=false",
|
|
1883
|
+
zipPath
|
|
1884
|
+
);
|
|
1885
|
+
if (uploadRes.status >= 300) {
|
|
1886
|
+
console.error(
|
|
1887
|
+
`Upload failed (${uploadRes.status}): ${JSON.stringify(uploadRes.data)}`
|
|
1888
|
+
);
|
|
1889
|
+
process.exit(1);
|
|
1890
|
+
}
|
|
1891
|
+
const sourceZipPath = uploadRes.data?.source_zip_path;
|
|
1892
|
+
if (!sourceZipPath) {
|
|
1893
|
+
console.error(
|
|
1894
|
+
"Upload response missing `source_zip_path`. Backend out of date?"
|
|
1895
|
+
);
|
|
1896
|
+
process.exit(1);
|
|
1897
|
+
}
|
|
1898
|
+
console.log(`Submitting version ${version}...`);
|
|
1899
|
+
const submitRes = await apiRequest(
|
|
1900
|
+
"POST",
|
|
1901
|
+
`/marketplace/developer/themes/${encodeURIComponent(options.themeId)}/versions`,
|
|
1902
|
+
{
|
|
1903
|
+
version_string: version,
|
|
1904
|
+
source_zip_path: sourceZipPath,
|
|
1905
|
+
release_notes: options.notes ?? null
|
|
1906
|
+
}
|
|
1907
|
+
);
|
|
1908
|
+
if (submitRes.status !== 200 && submitRes.status !== 202) {
|
|
1909
|
+
console.error(
|
|
1910
|
+
`Submit failed (${submitRes.status}): ${JSON.stringify(submitRes.data)}`
|
|
1911
|
+
);
|
|
1912
|
+
process.exit(1);
|
|
1913
|
+
}
|
|
1914
|
+
const versionId = submitRes.data.version_id;
|
|
1915
|
+
console.log(` version_id: ${versionId}`);
|
|
1916
|
+
const timeoutMs = Math.max(60, parseInt(options.pollTimeout, 10) || 240) * 1e3;
|
|
1917
|
+
const intervalMs = Math.max(2, parseInt(options.pollInterval, 10) || 4) * 1e3;
|
|
1918
|
+
const deadline = Date.now() + timeoutMs;
|
|
1919
|
+
console.log("Waiting for build...");
|
|
1920
|
+
let lastStatus = "";
|
|
1921
|
+
while (Date.now() < deadline) {
|
|
1922
|
+
const statusRes = await apiRequest(
|
|
1923
|
+
"GET",
|
|
1924
|
+
`/marketplace/developer/versions/${encodeURIComponent(versionId)}/status`
|
|
1925
|
+
);
|
|
1926
|
+
if (statusRes.status >= 300) {
|
|
1927
|
+
console.error(
|
|
1928
|
+
` status check failed (${statusRes.status}): ${JSON.stringify(statusRes.data)}`
|
|
1929
|
+
);
|
|
1930
|
+
process.exit(1);
|
|
1931
|
+
}
|
|
1932
|
+
const s = statusRes.data;
|
|
1933
|
+
if (s.status !== lastStatus) {
|
|
1934
|
+
console.log(` ${s.status}`);
|
|
1935
|
+
lastStatus = s.status;
|
|
1936
|
+
}
|
|
1937
|
+
if (s.bundle_url) break;
|
|
1938
|
+
if (s.status === "build_failed") {
|
|
1939
|
+
console.error("\nBuild failed.");
|
|
1940
|
+
if (s.build_log) {
|
|
1941
|
+
console.error("--- build log (tail) ---");
|
|
1942
|
+
const tail = s.build_log.split("\n").slice(-60).join("\n");
|
|
1943
|
+
console.error(tail);
|
|
1944
|
+
console.error("--- end log ---");
|
|
1945
|
+
}
|
|
1946
|
+
process.exit(1);
|
|
1947
|
+
}
|
|
1948
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
1949
|
+
}
|
|
1950
|
+
if (Date.now() >= deadline) {
|
|
1951
|
+
console.error(
|
|
1952
|
+
`
|
|
1953
|
+
Timed out waiting for build (${options.pollTimeout}s). Run \`numu-theme status --version ${versionId}\` to keep watching.`
|
|
1954
|
+
);
|
|
1955
|
+
process.exit(1);
|
|
1956
|
+
}
|
|
1957
|
+
console.log(`Installing on store ${storeId}...`);
|
|
1958
|
+
const installRes = await apiRequest(
|
|
1959
|
+
"POST",
|
|
1960
|
+
`/stores/${encodeURIComponent(storeId)}/marketplace/developer-install/${encodeURIComponent(options.themeId)}`,
|
|
1961
|
+
{}
|
|
1962
|
+
);
|
|
1963
|
+
if (installRes.status >= 300) {
|
|
1964
|
+
console.error(
|
|
1965
|
+
`Install failed (${installRes.status}): ${JSON.stringify(installRes.data)}`
|
|
1966
|
+
);
|
|
1967
|
+
process.exit(1);
|
|
1968
|
+
}
|
|
1969
|
+
const activateRes = await apiRequest(
|
|
1970
|
+
"POST",
|
|
1971
|
+
`/stores/${encodeURIComponent(storeId)}/marketplace/activate`,
|
|
1972
|
+
{ marketplace_theme_id: options.themeId }
|
|
1973
|
+
);
|
|
1974
|
+
if (activateRes.status >= 300) {
|
|
1975
|
+
console.warn(
|
|
1976
|
+
` Installed but activation returned ${activateRes.status}. Visit the dashboard to activate.`
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
1979
|
+
console.log("\n\u2713 Installed and activated on your store.");
|
|
1980
|
+
console.log(
|
|
1981
|
+
` installation_id: ${installRes.data.installation_id}`
|
|
1982
|
+
);
|
|
1983
|
+
console.log(` version: ${installRes.data.version_string}`);
|
|
1984
|
+
console.log(` version_status: ${installRes.data.version_status}`);
|
|
1985
|
+
}
|
|
1986
|
+
);
|
|
1987
|
+
|
|
1988
|
+
// src/commands/login.ts
|
|
1989
|
+
var import_commander9 = require("commander");
|
|
1990
|
+
var import_inquirer = __toESM(require("inquirer"));
|
|
1991
|
+
var loginCommand = new import_commander9.Command("login").description("Authenticate with the NUMU API").option("--token <token>", "API token (skip interactive login)").option("--api-url <url>", "Custom API URL").action(async (options) => {
|
|
1992
|
+
if (options.apiUrl) {
|
|
1993
|
+
saveConfig({ api_url: options.apiUrl });
|
|
1994
|
+
console.log(`API URL set to: ${options.apiUrl}`);
|
|
1995
|
+
}
|
|
1996
|
+
if (options.token) {
|
|
1997
|
+
saveConfig({ token: options.token });
|
|
1998
|
+
console.log("\u2713 Token saved");
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
console.log("NUMU Theme Developer Login\n");
|
|
2002
|
+
const answers = await import_inquirer.default.prompt([
|
|
2003
|
+
{
|
|
2004
|
+
type: "input",
|
|
2005
|
+
name: "email",
|
|
2006
|
+
message: "Email:",
|
|
2007
|
+
validate: (v) => v.includes("@") || "Enter a valid email"
|
|
2008
|
+
},
|
|
2009
|
+
{ type: "password", name: "password", message: "Password:", mask: "*" }
|
|
2010
|
+
]);
|
|
2011
|
+
try {
|
|
2012
|
+
const res = await apiRequest("POST", "/auth/login", {
|
|
2013
|
+
email: answers.email,
|
|
2014
|
+
password: answers.password
|
|
2015
|
+
});
|
|
2016
|
+
if (res.status === 200 && res.data?.requires_2fa) {
|
|
2017
|
+
console.error(
|
|
2018
|
+
"\nLogin failed: this account has 2FA enabled. The CLI cannot complete a 2FA challenge \u2014 generate an API token in the dashboard and run `numu-theme login --token <token>`."
|
|
2019
|
+
);
|
|
2020
|
+
process.exit(1);
|
|
2021
|
+
} else if (res.status === 200 && res.data?.tokens?.access_token) {
|
|
2022
|
+
saveConfig({ token: res.data.tokens.access_token });
|
|
2023
|
+
console.log("\n\u2713 Logged in successfully");
|
|
2024
|
+
} else {
|
|
2025
|
+
const detail = res.data?.detail ?? `HTTP ${res.status}`;
|
|
2026
|
+
console.error(`
|
|
2027
|
+
Login failed: ${detail}`);
|
|
2028
|
+
process.exit(1);
|
|
2029
|
+
}
|
|
2030
|
+
} catch (err) {
|
|
2031
|
+
console.error(`
|
|
2032
|
+
Login failed: ${err.message}`);
|
|
2033
|
+
process.exit(1);
|
|
2034
|
+
}
|
|
2035
|
+
});
|
|
2036
|
+
|
|
2037
|
+
// src/commands/status.ts
|
|
2038
|
+
var import_commander10 = require("commander");
|
|
2039
|
+
var statusCommand = new import_commander10.Command("status").description("Poll the status of a theme build or marketplace version").option("--build <build_id>", "Build ID returned by `numu-theme push`").option(
|
|
2040
|
+
"--version <version_id>",
|
|
2041
|
+
"Marketplace version ID returned by `numu-theme submit`"
|
|
2042
|
+
).option("-w, --watch", "Poll until the build reaches a terminal state").option(
|
|
2043
|
+
"-i, --interval <seconds>",
|
|
2044
|
+
"Polling interval in seconds (with --watch)",
|
|
2045
|
+
"3"
|
|
2046
|
+
).action(async (options) => {
|
|
2047
|
+
const config = loadConfig();
|
|
2048
|
+
if (!config.token) {
|
|
2049
|
+
console.error("Not logged in. Run: numu-theme login");
|
|
2050
|
+
process.exit(1);
|
|
2051
|
+
}
|
|
2052
|
+
if (!options.build && !options.version) {
|
|
2053
|
+
console.error("Provide --build <id> or --version <id>");
|
|
2054
|
+
process.exit(1);
|
|
2055
|
+
}
|
|
2056
|
+
if (options.build && options.version) {
|
|
2057
|
+
console.error("Use one of --build or --version, not both");
|
|
2058
|
+
process.exit(1);
|
|
2059
|
+
}
|
|
2060
|
+
const intervalMs = Math.max(
|
|
2061
|
+
1e3,
|
|
2062
|
+
Math.floor(parseFloat(options.interval || "3") * 1e3)
|
|
2063
|
+
);
|
|
2064
|
+
const TERMINAL_BUILD = /* @__PURE__ */ new Set([
|
|
2065
|
+
"complete",
|
|
2066
|
+
"completed",
|
|
2067
|
+
"failed",
|
|
2068
|
+
"error"
|
|
2069
|
+
]);
|
|
2070
|
+
const TERMINAL_VERSION = /* @__PURE__ */ new Set([
|
|
2071
|
+
"pending_review",
|
|
2072
|
+
"published",
|
|
2073
|
+
"rejected",
|
|
2074
|
+
"build_failed"
|
|
2075
|
+
]);
|
|
2076
|
+
async function pollOnce() {
|
|
2077
|
+
if (options.build) {
|
|
2078
|
+
const res2 = await apiRequest(
|
|
2079
|
+
"GET",
|
|
2080
|
+
`/themes/builds/${encodeURIComponent(options.build)}`
|
|
2081
|
+
);
|
|
2082
|
+
if (res2.status === 404) {
|
|
2083
|
+
console.error(`Build ${options.build} not found`);
|
|
2084
|
+
return { done: true, failed: true };
|
|
2085
|
+
}
|
|
2086
|
+
if (res2.status >= 300) {
|
|
2087
|
+
console.error(`HTTP ${res2.status}: ${JSON.stringify(res2.data)}`);
|
|
2088
|
+
return { done: true, failed: true };
|
|
2089
|
+
}
|
|
2090
|
+
const s2 = res2.data;
|
|
2091
|
+
printBuild(s2);
|
|
2092
|
+
const terminal2 = TERMINAL_BUILD.has(s2.status);
|
|
2093
|
+
const failed2 = s2.status === "failed" || s2.status === "error";
|
|
2094
|
+
return { done: terminal2, failed: failed2 };
|
|
2095
|
+
}
|
|
2096
|
+
const res = await apiRequest(
|
|
2097
|
+
"GET",
|
|
2098
|
+
`/marketplace/developer/versions/${encodeURIComponent(options.version)}/status`
|
|
2099
|
+
);
|
|
2100
|
+
if (res.status === 404) {
|
|
2101
|
+
console.error(`Version ${options.version} not found`);
|
|
2102
|
+
return { done: true, failed: true };
|
|
2103
|
+
}
|
|
2104
|
+
if (res.status >= 300) {
|
|
2105
|
+
console.error(`HTTP ${res.status}: ${JSON.stringify(res.data)}`);
|
|
2106
|
+
return { done: true, failed: true };
|
|
2107
|
+
}
|
|
2108
|
+
const s = res.data;
|
|
2109
|
+
printVersion(s);
|
|
2110
|
+
const terminal = TERMINAL_VERSION.has(s.status);
|
|
2111
|
+
const failed = s.status === "rejected" || s.status === "build_failed";
|
|
2112
|
+
return { done: terminal, failed };
|
|
2113
|
+
}
|
|
2114
|
+
if (!options.watch) {
|
|
2115
|
+
const { failed } = await pollOnce();
|
|
2116
|
+
process.exit(failed ? 1 : 0);
|
|
2117
|
+
}
|
|
2118
|
+
while (true) {
|
|
2119
|
+
const { done, failed } = await pollOnce();
|
|
2120
|
+
if (done) {
|
|
2121
|
+
process.exit(failed ? 1 : 0);
|
|
2122
|
+
}
|
|
2123
|
+
await sleep(intervalMs);
|
|
2124
|
+
}
|
|
2125
|
+
});
|
|
2126
|
+
function sleep(ms) {
|
|
2127
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
2128
|
+
}
|
|
2129
|
+
function printBuild(s) {
|
|
2130
|
+
console.log(`Build: ${s.build_id}`);
|
|
2131
|
+
console.log(`Status: ${s.status}`);
|
|
2132
|
+
if (s.version) console.log(`Version: ${s.version}`);
|
|
2133
|
+
if (s.theme_slug) console.log(`Theme: ${s.theme_slug}`);
|
|
2134
|
+
if (s.bundle_url) console.log(`Bundle: ${s.bundle_url}`);
|
|
2135
|
+
if (s.css_url) console.log(`CSS: ${s.css_url}`);
|
|
2136
|
+
if (s.size_bytes != null)
|
|
2137
|
+
console.log(`Size: ${formatBytes(s.size_bytes)}`);
|
|
2138
|
+
if (s.checksum) console.log(`Checksum: ${s.checksum}`);
|
|
2139
|
+
if (s.updated_at) console.log(`Updated: ${s.updated_at}`);
|
|
2140
|
+
if (s.error) console.log(`
|
|
2141
|
+
Error:
|
|
2142
|
+
${s.error}`);
|
|
2143
|
+
console.log("");
|
|
2144
|
+
}
|
|
2145
|
+
function printVersion(s) {
|
|
2146
|
+
console.log(`Version: ${s.version_id}`);
|
|
2147
|
+
if (s.version_string) console.log(`Number: ${s.version_string}`);
|
|
2148
|
+
console.log(`Status: ${s.status}`);
|
|
2149
|
+
if (s.bundle_url) console.log(`Bundle: ${s.bundle_url}`);
|
|
2150
|
+
if (s.css_url) console.log(`CSS: ${s.css_url}`);
|
|
2151
|
+
if (s.size_bytes != null)
|
|
2152
|
+
console.log(`Size: ${formatBytes(s.size_bytes)}`);
|
|
2153
|
+
if (s.checksum) console.log(`Checksum: ${s.checksum}`);
|
|
2154
|
+
if (s.build_log) console.log(`
|
|
2155
|
+
Build log:
|
|
2156
|
+
${s.build_log}`);
|
|
2157
|
+
console.log("");
|
|
2158
|
+
}
|
|
2159
|
+
function formatBytes(n) {
|
|
2160
|
+
if (n < 1024) return `${n} B`;
|
|
2161
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
2162
|
+
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// src/commands/doctor.ts
|
|
2166
|
+
var import_commander11 = require("commander");
|
|
2167
|
+
var fs11 = __toESM(require("fs"));
|
|
2168
|
+
var path10 = __toESM(require("path"));
|
|
2169
|
+
var doctorCommand = new import_commander11.Command("doctor").description("Diagnose common dev-loop problems (run inside a theme directory)").option("-d, --dir <directory>", "Theme directory", ".").option("-p, --port <port>", "Expected dev server port", "5173").action(async (options) => {
|
|
2170
|
+
let issues = 0;
|
|
2171
|
+
let warnings = 0;
|
|
2172
|
+
const themeDir = path10.resolve(options.dir);
|
|
2173
|
+
function ok(line) {
|
|
2174
|
+
console.log(` \x1B[32m\u2713\x1B[0m ${line}`);
|
|
2175
|
+
}
|
|
2176
|
+
function warn(line) {
|
|
2177
|
+
warnings += 1;
|
|
2178
|
+
console.log(` \x1B[33m\u26A0\x1B[0m ${line}`);
|
|
2179
|
+
}
|
|
2180
|
+
function fail(line) {
|
|
2181
|
+
issues += 1;
|
|
2182
|
+
console.log(` \x1B[31m\u2717\x1B[0m ${line}`);
|
|
2183
|
+
}
|
|
2184
|
+
console.log("\nProject");
|
|
2185
|
+
const themeJsonPath = path10.join(themeDir, "theme.json");
|
|
2186
|
+
const settingsPath = path10.join(themeDir, "settings_schema.json");
|
|
2187
|
+
if (!fs11.existsSync(themeJsonPath)) {
|
|
2188
|
+
fail(`No theme.json found in ${themeDir}`);
|
|
2189
|
+
console.log(
|
|
2190
|
+
" Run \x1B[36mnumu-theme init <name>\x1B[0m to scaffold a theme."
|
|
2191
|
+
);
|
|
2192
|
+
process.exit(1);
|
|
2193
|
+
}
|
|
2194
|
+
ok(`Theme directory: ${themeDir}`);
|
|
2195
|
+
if (!fs11.existsSync(settingsPath))
|
|
2196
|
+
warn("settings_schema.json missing \u2014 no global theme settings");
|
|
2197
|
+
else ok("settings_schema.json present");
|
|
2198
|
+
console.log("\nContract");
|
|
2199
|
+
const result = validateTheme(themeDir);
|
|
2200
|
+
for (const w of result.warnings) warn(w);
|
|
2201
|
+
for (const e of result.errors) fail(e);
|
|
2202
|
+
if (result.valid && result.errors.length === 0)
|
|
2203
|
+
ok(`numu-theme check passes (${result.warnings.length} warning(s))`);
|
|
2204
|
+
console.log("\nBundle contract");
|
|
2205
|
+
const entryCandidates = [
|
|
2206
|
+
"src/main.tsx",
|
|
2207
|
+
"src/main.ts",
|
|
2208
|
+
"src/index.tsx",
|
|
2209
|
+
"src/index.ts"
|
|
2210
|
+
];
|
|
2211
|
+
const entry = entryCandidates.find(
|
|
2212
|
+
(p) => fs11.existsSync(path10.join(themeDir, p))
|
|
2213
|
+
);
|
|
2214
|
+
if (!entry) {
|
|
2215
|
+
fail(`No entry point found (expected one of: ${entryCandidates.join(", ")})`);
|
|
2216
|
+
} else {
|
|
2217
|
+
const src = fs11.readFileSync(path10.join(themeDir, entry), "utf-8");
|
|
2218
|
+
const exportsMount = /\bexport\s+(?:async\s+)?function\s+mount\b/.test(src) || /\bexport\s+(?:const|let|var)\s+mount\b/.test(src) || /\bexport\s*\{[^}]*\bmount\b[^}]*\}/.test(src);
|
|
2219
|
+
if (exportsMount) ok(`${entry} exports mount(el, props)`);
|
|
2220
|
+
else
|
|
2221
|
+
fail(
|
|
2222
|
+
`${entry} does NOT export mount(el, props) \u2014 BYOT host can't render this theme. See THEME_AUTHORING.md for the contract, or scaffold a fresh theme with \`numu-theme init\`.`
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
2225
|
+
console.log("\nLocales");
|
|
2226
|
+
const localesDir = path10.join(themeDir, "locales");
|
|
2227
|
+
if (!fs11.existsSync(localesDir)) {
|
|
2228
|
+
warn(
|
|
2229
|
+
"locales/ directory not found \u2014 `useTranslation` will fall through to keys. Add locales/en.default.json (required) and locales/ar.json (recommended for MENA stores)."
|
|
2230
|
+
);
|
|
2231
|
+
} else {
|
|
2232
|
+
const localeFiles = fs11.readdirSync(localesDir).filter((f) => f.endsWith(".json"));
|
|
2233
|
+
const hasDefault = localeFiles.some((f) => f.endsWith(".default.json"));
|
|
2234
|
+
if (!hasDefault) {
|
|
2235
|
+
fail(
|
|
2236
|
+
"No default locale file found in locales/ \u2014 useTranslation needs a *.default.json fallback. Rename your primary locale to e.g. locales/en.default.json."
|
|
2237
|
+
);
|
|
2238
|
+
} else {
|
|
2239
|
+
ok(`locales/ has ${localeFiles.length} file(s) including default`);
|
|
2240
|
+
}
|
|
2241
|
+
for (const f of localeFiles) {
|
|
2242
|
+
try {
|
|
2243
|
+
JSON.parse(fs11.readFileSync(path10.join(localesDir, f), "utf-8"));
|
|
2244
|
+
} catch (err) {
|
|
2245
|
+
fail(`locales/${f} is not valid JSON: ${err.message}`);
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
console.log("\nAssets");
|
|
2250
|
+
const assetsDir = path10.join(themeDir, "assets");
|
|
2251
|
+
if (!fs11.existsSync(assetsDir)) {
|
|
2252
|
+
warn(
|
|
2253
|
+
"assets/ directory not found \u2014 assetUrl() helpers will fall back to bare paths under /assets/. Create assets/ and put images, fonts, and JSON fixtures there for the plugin to copy with content-hashed filenames."
|
|
2254
|
+
);
|
|
2255
|
+
} else {
|
|
2256
|
+
const count = fs11.readdirSync(assetsDir).filter(
|
|
2257
|
+
(f) => !f.startsWith(".")
|
|
2258
|
+
).length;
|
|
2259
|
+
if (count === 0) {
|
|
2260
|
+
warn("assets/ exists but is empty");
|
|
2261
|
+
} else {
|
|
2262
|
+
ok(`assets/ contains ${count} file(s)/folder(s)`);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
console.log("\nPresets");
|
|
2266
|
+
try {
|
|
2267
|
+
const themeJson = JSON.parse(
|
|
2268
|
+
fs11.readFileSync(themeJsonPath, "utf-8")
|
|
2269
|
+
);
|
|
2270
|
+
const presets = themeJson.presets ?? {};
|
|
2271
|
+
const sectionsDir = path10.join(themeDir, "src", "sections");
|
|
2272
|
+
const schemaDir = path10.join(themeDir, "schemas", "sections");
|
|
2273
|
+
const componentNames = fs11.existsSync(sectionsDir) ? new Set(
|
|
2274
|
+
fs11.readdirSync(sectionsDir).filter((f) => /\.(tsx|ts|jsx|js)$/.test(f)).map((f) => path10.basename(f, path10.extname(f)).toLowerCase())
|
|
2275
|
+
) : /* @__PURE__ */ new Set();
|
|
2276
|
+
const schemaNames = fs11.existsSync(schemaDir) ? new Set(
|
|
2277
|
+
fs11.readdirSync(schemaDir).filter((f) => f.endsWith(".json")).map((f) => path10.basename(f, ".json").toLowerCase())
|
|
2278
|
+
) : /* @__PURE__ */ new Set();
|
|
2279
|
+
let missingRefs = 0;
|
|
2280
|
+
for (const [presetName, presetVal] of Object.entries(presets)) {
|
|
2281
|
+
const sections = presetVal.sections ?? {};
|
|
2282
|
+
for (const [, sec] of Object.entries(sections)) {
|
|
2283
|
+
const t = (sec?.type || "").toLowerCase();
|
|
2284
|
+
if (!t) continue;
|
|
2285
|
+
if (!componentNames.has(t)) {
|
|
2286
|
+
fail(
|
|
2287
|
+
`Preset "${presetName}" references section type "${t}" but src/sections/${t}.tsx (or .jsx/.ts) is missing.`
|
|
2288
|
+
);
|
|
2289
|
+
missingRefs += 1;
|
|
2290
|
+
} else if (!schemaNames.has(t)) {
|
|
2291
|
+
warn(
|
|
2292
|
+
`Preset "${presetName}" references section type "${t}" but schemas/sections/${t}.json is missing \u2014 customizer will warn on selection.`
|
|
2293
|
+
);
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
if (Object.keys(presets).length === 0) {
|
|
2298
|
+
warn("theme.json has no presets \u2014 merchants start with an empty page");
|
|
2299
|
+
} else if (missingRefs === 0) {
|
|
2300
|
+
ok(
|
|
2301
|
+
`${Object.keys(presets).length} preset(s); all section refs resolve`
|
|
2302
|
+
);
|
|
2303
|
+
}
|
|
2304
|
+
} catch (err) {
|
|
2305
|
+
warn(`Could not parse theme.json presets: ${err.message}`);
|
|
2306
|
+
}
|
|
2307
|
+
console.log("\nBuild");
|
|
2308
|
+
const distJs = path10.join(themeDir, "dist", "theme.js");
|
|
2309
|
+
const distCss = path10.join(themeDir, "dist", "theme.css");
|
|
2310
|
+
if (!fs11.existsSync(distJs)) {
|
|
2311
|
+
warn("dist/theme.js not found \u2014 run `numu-theme build` first");
|
|
2312
|
+
} else {
|
|
2313
|
+
const stat = fs11.statSync(distJs);
|
|
2314
|
+
ok(`dist/theme.js (${(stat.size / 1024).toFixed(1)} KB)`);
|
|
2315
|
+
if (stat.size > 2 * 1024 * 1024)
|
|
2316
|
+
warn("Bundle exceeds 2 MB \u2014 marketplace will reject");
|
|
2317
|
+
}
|
|
2318
|
+
if (!fs11.existsSync(distCss)) {
|
|
2319
|
+
warn(
|
|
2320
|
+
"dist/theme.css not found \u2014 the plugin should copy styles.css; check that styles.css exists"
|
|
2321
|
+
);
|
|
2322
|
+
} else {
|
|
2323
|
+
ok("dist/theme.css present");
|
|
2324
|
+
}
|
|
2325
|
+
console.log("\nAuth");
|
|
2326
|
+
const config = loadConfig();
|
|
2327
|
+
if (!config.token) {
|
|
2328
|
+
warn("Not logged in \u2014 run `numu-theme login --api-url <url>` to enable push/submit");
|
|
2329
|
+
} else {
|
|
2330
|
+
ok(`Token loaded (${config.token.slice(0, 12)}\u2026)`);
|
|
2331
|
+
}
|
|
2332
|
+
if (!config.api_url) {
|
|
2333
|
+
warn("No api_url configured \u2014 run `numu-theme login --api-url <url>`");
|
|
2334
|
+
} else {
|
|
2335
|
+
ok(`API URL: ${config.api_url}`);
|
|
2336
|
+
}
|
|
2337
|
+
if (config.token && config.api_url) {
|
|
2338
|
+
console.log("\nBackend");
|
|
2339
|
+
try {
|
|
2340
|
+
const res = await apiRequest("GET", "/auth/me");
|
|
2341
|
+
if (res.status === 200) {
|
|
2342
|
+
ok("Backend reachable, token valid");
|
|
2343
|
+
} else if (res.status === 401) {
|
|
2344
|
+
fail("Token expired \u2014 run `numu-theme login` again");
|
|
2345
|
+
} else if (res.status === 404) {
|
|
2346
|
+
warn(
|
|
2347
|
+
`API URL may be wrong (HTTP 404 on /auth/me). Confirm api_url in ~/.numurc points to the /api/v1 prefix.`
|
|
2348
|
+
);
|
|
2349
|
+
} else {
|
|
2350
|
+
warn(`Backend returned HTTP ${res.status}`);
|
|
2351
|
+
}
|
|
2352
|
+
} catch (err) {
|
|
2353
|
+
fail(`Cannot reach ${config.api_url}: ${err.message}`);
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
console.log("\nDev server");
|
|
2357
|
+
const port = parseInt(options.port, 10);
|
|
2358
|
+
try {
|
|
2359
|
+
const probe = await fetch(`http://localhost:${port}/theme.json`, {
|
|
2360
|
+
signal: AbortSignal.timeout(1500)
|
|
2361
|
+
}).catch(() => null);
|
|
2362
|
+
if (!probe) {
|
|
2363
|
+
warn(
|
|
2364
|
+
`Nothing serving on http://localhost:${port} \u2014 run \`numu-theme dev\` in another terminal to enable dev-mode connect`
|
|
2365
|
+
);
|
|
2366
|
+
} else if (probe.ok) {
|
|
2367
|
+
ok(`Dev server is up on :${port} and serving theme.json`);
|
|
2368
|
+
} else {
|
|
2369
|
+
warn(
|
|
2370
|
+
`Port ${port} responds but didn't return theme.json (HTTP ${probe.status})`
|
|
2371
|
+
);
|
|
2372
|
+
}
|
|
2373
|
+
} catch (err) {
|
|
2374
|
+
warn(`Dev port check failed: ${err.message}`);
|
|
2375
|
+
}
|
|
2376
|
+
console.log("");
|
|
2377
|
+
if (issues === 0 && warnings === 0) {
|
|
2378
|
+
console.log("\x1B[32m\u2713 All checks passed.\x1B[0m");
|
|
2379
|
+
} else if (issues === 0) {
|
|
2380
|
+
console.log(
|
|
2381
|
+
`\x1B[33m\u26A0 ${warnings} warning(s) \u2014 theme should still work.\x1B[0m`
|
|
2382
|
+
);
|
|
2383
|
+
} else {
|
|
2384
|
+
console.log(
|
|
2385
|
+
`\x1B[31m\u2717 ${issues} error(s), ${warnings} warning(s).\x1B[0m`
|
|
2386
|
+
);
|
|
2387
|
+
process.exit(1);
|
|
2388
|
+
}
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
// src/commands/add-section.ts
|
|
2392
|
+
var import_commander12 = require("commander");
|
|
2393
|
+
var fs12 = __toESM(require("fs"));
|
|
2394
|
+
var path11 = __toESM(require("path"));
|
|
2395
|
+
|
|
2396
|
+
// src/section-library/entries/hero-with-cta.ts
|
|
2397
|
+
var heroWithCta = {
|
|
2398
|
+
slug: "hero-with-cta",
|
|
2399
|
+
name: "Hero with Call-to-Action",
|
|
2400
|
+
description: "Full-bleed hero \u2014 headline, subtitle, primary + secondary buttons, optional background image",
|
|
2401
|
+
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
2402
|
+
import { useDirection, useTranslation } from "@numueg/theme-sdk";
|
|
2403
|
+
|
|
2404
|
+
export default function HeroWithCta({ settings }: SectionProps) {
|
|
2405
|
+
const dir = useDirection();
|
|
2406
|
+
const { t } = useTranslation();
|
|
2407
|
+
const align = (settings.alignment as string) || "center";
|
|
2408
|
+
const headline = (settings.headline as string) || t("hero.title", "Welcome");
|
|
2409
|
+
const subtitle = (settings.subtitle as string) || "";
|
|
2410
|
+
const bg = settings.background_image as string | undefined;
|
|
2411
|
+
const cta1 = settings.cta1_label as string | undefined;
|
|
2412
|
+
const cta1Href = (settings.cta1_href as string) || "/";
|
|
2413
|
+
const cta2 = settings.cta2_label as string | undefined;
|
|
2414
|
+
const cta2Href = (settings.cta2_href as string) || "/";
|
|
2415
|
+
|
|
2416
|
+
const justify =
|
|
2417
|
+
align === "left" ? "items-start text-left"
|
|
2418
|
+
: align === "right" ? "items-end text-right"
|
|
2419
|
+
: "items-center text-center";
|
|
2420
|
+
|
|
2421
|
+
return (
|
|
2422
|
+
<section
|
|
2423
|
+
dir={dir}
|
|
2424
|
+
className="relative min-h-[60vh] flex flex-col justify-center px-6 py-20 bg-gray-900 text-white"
|
|
2425
|
+
style={
|
|
2426
|
+
bg
|
|
2427
|
+
? { backgroundImage: \`url(\${bg})\`, backgroundSize: "cover", backgroundPosition: "center" }
|
|
2428
|
+
: undefined
|
|
2429
|
+
}
|
|
2430
|
+
>
|
|
2431
|
+
{bg && <div className="absolute inset-0 bg-black/50" aria-hidden="true" />}
|
|
2432
|
+
<div className={\`relative max-w-4xl mx-auto w-full flex flex-col \${justify}\`}>
|
|
2433
|
+
<h1 className="text-4xl md:text-6xl font-bold tracking-tight">{headline}</h1>
|
|
2434
|
+
{subtitle && <p className="mt-6 text-lg md:text-xl text-gray-100 max-w-2xl">{subtitle}</p>}
|
|
2435
|
+
{(cta1 || cta2) && (
|
|
2436
|
+
<div className="mt-10 flex flex-wrap gap-3">
|
|
2437
|
+
{cta1 && (
|
|
2438
|
+
<a href={cta1Href} className="bg-white text-gray-900 px-6 py-3 rounded-md font-medium hover:bg-gray-100 transition">
|
|
2439
|
+
{cta1}
|
|
2440
|
+
</a>
|
|
2441
|
+
)}
|
|
2442
|
+
{cta2 && (
|
|
2443
|
+
<a href={cta2Href} className="border border-white text-white px-6 py-3 rounded-md font-medium hover:bg-white/10 transition">
|
|
2444
|
+
{cta2}
|
|
2445
|
+
</a>
|
|
2446
|
+
)}
|
|
2447
|
+
</div>
|
|
2448
|
+
)}
|
|
2449
|
+
</div>
|
|
2450
|
+
</section>
|
|
2451
|
+
);
|
|
2452
|
+
}
|
|
2453
|
+
`,
|
|
2454
|
+
schema: {
|
|
2455
|
+
type: "hero-with-cta",
|
|
2456
|
+
name: "Hero with CTA",
|
|
2457
|
+
locales: { ar: { name: "\u0628\u0627\u0646\u0631 \u0631\u0626\u064A\u0633\u064A \u0645\u0639 \u0632\u0631 \u062F\u0639\u0648\u0629" } },
|
|
2458
|
+
settings: [
|
|
2459
|
+
{
|
|
2460
|
+
type: "text",
|
|
2461
|
+
id: "headline",
|
|
2462
|
+
label: "Headline",
|
|
2463
|
+
locales: { ar: { label: "\u0627\u0644\u0639\u0646\u0648\u0627\u0646" } },
|
|
2464
|
+
default: "Welcome to our store"
|
|
2465
|
+
},
|
|
2466
|
+
{
|
|
2467
|
+
type: "textarea",
|
|
2468
|
+
id: "subtitle",
|
|
2469
|
+
label: "Subtitle",
|
|
2470
|
+
locales: { ar: { label: "\u0627\u0644\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0641\u0631\u0639\u064A" } }
|
|
2471
|
+
},
|
|
2472
|
+
{
|
|
2473
|
+
type: "image_picker",
|
|
2474
|
+
id: "background_image",
|
|
2475
|
+
label: "Background image",
|
|
2476
|
+
locales: { ar: { label: "\u0635\u0648\u0631\u0629 \u0627\u0644\u062E\u0644\u0641\u064A\u0629" } }
|
|
2477
|
+
},
|
|
2478
|
+
{
|
|
2479
|
+
type: "select",
|
|
2480
|
+
id: "alignment",
|
|
2481
|
+
label: "Content alignment",
|
|
2482
|
+
locales: { ar: { label: "\u0645\u062D\u0627\u0630\u0627\u0629 \u0627\u0644\u0645\u062D\u062A\u0648\u0649" } },
|
|
2483
|
+
default: "center",
|
|
2484
|
+
options: [
|
|
2485
|
+
{ value: "left", label: "Left" },
|
|
2486
|
+
{ value: "center", label: "Center" },
|
|
2487
|
+
{ value: "right", label: "Right" }
|
|
2488
|
+
]
|
|
2489
|
+
},
|
|
2490
|
+
{
|
|
2491
|
+
type: "text",
|
|
2492
|
+
id: "cta1_label",
|
|
2493
|
+
label: "Primary button label",
|
|
2494
|
+
locales: { ar: { label: "\u0646\u0635 \u0627\u0644\u0632\u0631 \u0627\u0644\u0631\u0626\u064A\u0633\u064A" } }
|
|
2495
|
+
},
|
|
2496
|
+
{
|
|
2497
|
+
type: "url",
|
|
2498
|
+
id: "cta1_href",
|
|
2499
|
+
label: "Primary button link",
|
|
2500
|
+
locales: { ar: { label: "\u0631\u0627\u0628\u0637 \u0627\u0644\u0632\u0631 \u0627\u0644\u0631\u0626\u064A\u0633\u064A" } },
|
|
2501
|
+
default: "/"
|
|
2502
|
+
},
|
|
2503
|
+
{
|
|
2504
|
+
type: "text",
|
|
2505
|
+
id: "cta2_label",
|
|
2506
|
+
label: "Secondary button label",
|
|
2507
|
+
locales: { ar: { label: "\u0646\u0635 \u0627\u0644\u0632\u0631 \u0627\u0644\u062B\u0627\u0646\u0648\u064A" } }
|
|
2508
|
+
},
|
|
2509
|
+
{
|
|
2510
|
+
type: "url",
|
|
2511
|
+
id: "cta2_href",
|
|
2512
|
+
label: "Secondary button link",
|
|
2513
|
+
locales: { ar: { label: "\u0631\u0627\u0628\u0637 \u0627\u0644\u0632\u0631 \u0627\u0644\u062B\u0627\u0646\u0648\u064A" } },
|
|
2514
|
+
default: "/"
|
|
2515
|
+
}
|
|
2516
|
+
]
|
|
2517
|
+
}
|
|
2518
|
+
};
|
|
2519
|
+
|
|
2520
|
+
// src/section-library/entries/featured-products.ts
|
|
2521
|
+
var featuredProducts = {
|
|
2522
|
+
slug: "featured-products",
|
|
2523
|
+
name: "Featured Products",
|
|
2524
|
+
description: "Grid of hand-picked products with title + price + CTA",
|
|
2525
|
+
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
2526
|
+
import { ProductCard, useProducts } from "@numueg/theme-sdk";
|
|
2527
|
+
|
|
2528
|
+
export default function FeaturedProducts({ settings }: SectionProps) {
|
|
2529
|
+
const ids = (settings.product_ids as string[]) || [];
|
|
2530
|
+
const limit = (settings.limit as number) || 8;
|
|
2531
|
+
const title = (settings.title as string) || "Featured products";
|
|
2532
|
+
|
|
2533
|
+
// When specific products are picked, fetch them; otherwise fetch a
|
|
2534
|
+
// top-N list from the store catalog.
|
|
2535
|
+
const { products, loading } = useProducts({
|
|
2536
|
+
ids: ids.length > 0 ? ids : undefined,
|
|
2537
|
+
limit: ids.length > 0 ? undefined : limit,
|
|
2538
|
+
});
|
|
2539
|
+
|
|
2540
|
+
if (loading) return <section className="py-16 text-center text-gray-500">Loading\u2026</section>;
|
|
2541
|
+
if (!products.length) return null;
|
|
2542
|
+
|
|
2543
|
+
return (
|
|
2544
|
+
<section className="py-16 px-6 max-w-7xl mx-auto">
|
|
2545
|
+
<h2 className="text-2xl md:text-3xl font-semibold mb-8 text-center">{title}</h2>
|
|
2546
|
+
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
|
2547
|
+
{products.slice(0, limit).map((p) => (
|
|
2548
|
+
<ProductCard key={p.id} product={p} />
|
|
2549
|
+
))}
|
|
2550
|
+
</div>
|
|
2551
|
+
</section>
|
|
2552
|
+
);
|
|
2553
|
+
}
|
|
2554
|
+
`,
|
|
2555
|
+
schema: {
|
|
2556
|
+
type: "featured-products",
|
|
2557
|
+
name: "Featured Products",
|
|
2558
|
+
locales: { ar: { name: "\u0645\u0646\u062A\u062C\u0627\u062A \u0645\u0645\u064A\u0632\u0629" } },
|
|
2559
|
+
settings: [
|
|
2560
|
+
{
|
|
2561
|
+
type: "text",
|
|
2562
|
+
id: "title",
|
|
2563
|
+
label: "Section title",
|
|
2564
|
+
locales: { ar: { label: "\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0642\u0633\u0645" } },
|
|
2565
|
+
default: "Featured products"
|
|
2566
|
+
},
|
|
2567
|
+
{
|
|
2568
|
+
type: "product_list",
|
|
2569
|
+
id: "product_ids",
|
|
2570
|
+
label: "Products to feature (leave empty for top-N)",
|
|
2571
|
+
locales: { ar: { label: "\u0627\u0644\u0645\u0646\u062A\u062C\u0627\u062A \u0627\u0644\u0645\u0645\u064A\u0632\u0629 (\u0641\u0627\u0631\u063A = \u0627\u0644\u0623\u0639\u0644\u0649 \u0645\u0628\u064A\u0639\u064B\u0627)" } }
|
|
2572
|
+
},
|
|
2573
|
+
{
|
|
2574
|
+
type: "range",
|
|
2575
|
+
id: "limit",
|
|
2576
|
+
label: "Max products",
|
|
2577
|
+
locales: { ar: { label: "\u0627\u0644\u062D\u062F \u0627\u0644\u0623\u0642\u0635\u0649" } },
|
|
2578
|
+
min: 2,
|
|
2579
|
+
max: 24,
|
|
2580
|
+
default: 8
|
|
2581
|
+
}
|
|
2582
|
+
]
|
|
2583
|
+
}
|
|
2584
|
+
};
|
|
2585
|
+
|
|
2586
|
+
// src/section-library/entries/image-with-text.ts
|
|
2587
|
+
var imageWithText = {
|
|
2588
|
+
slug: "image-with-text",
|
|
2589
|
+
name: "Image with Text",
|
|
2590
|
+
description: "Two-column section: image on one side, headline + body + CTA on the other",
|
|
2591
|
+
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
2592
|
+
import { useDirection } from "@numueg/theme-sdk";
|
|
2593
|
+
|
|
2594
|
+
export default function ImageWithText({ settings }: SectionProps) {
|
|
2595
|
+
const dir = useDirection();
|
|
2596
|
+
const image = settings.image as string | undefined;
|
|
2597
|
+
const layout = (settings.image_position as string) || "left";
|
|
2598
|
+
const heading = (settings.heading as string) || "";
|
|
2599
|
+
const body = (settings.body as string) || "";
|
|
2600
|
+
const ctaLabel = settings.cta_label as string | undefined;
|
|
2601
|
+
const ctaHref = (settings.cta_href as string) || "/";
|
|
2602
|
+
|
|
2603
|
+
// In RTL, "left/right" semantics flip naturally with flex-row-reverse.
|
|
2604
|
+
const order = layout === "right" ? "md:flex-row-reverse" : "md:flex-row";
|
|
2605
|
+
|
|
2606
|
+
return (
|
|
2607
|
+
<section dir={dir} className="py-16 px-6 max-w-7xl mx-auto">
|
|
2608
|
+
<div className={\`flex flex-col gap-10 \${order}\`}>
|
|
2609
|
+
<div className="flex-1">
|
|
2610
|
+
{image ? (
|
|
2611
|
+
<img src={image} alt="" className="w-full h-auto rounded-lg" />
|
|
2612
|
+
) : (
|
|
2613
|
+
<div className="aspect-square bg-gray-100 rounded-lg" aria-hidden="true" />
|
|
2614
|
+
)}
|
|
2615
|
+
</div>
|
|
2616
|
+
<div className="flex-1 flex flex-col justify-center">
|
|
2617
|
+
{heading && <h2 className="text-3xl font-semibold mb-4">{heading}</h2>}
|
|
2618
|
+
{body && <p className="text-gray-700 leading-relaxed">{body}</p>}
|
|
2619
|
+
{ctaLabel && (
|
|
2620
|
+
<div className="mt-6">
|
|
2621
|
+
<a href={ctaHref} className="inline-flex items-center px-6 py-3 bg-gray-900 text-white rounded-md hover:bg-gray-800 transition">
|
|
2622
|
+
{ctaLabel}
|
|
2623
|
+
</a>
|
|
2624
|
+
</div>
|
|
2625
|
+
)}
|
|
2626
|
+
</div>
|
|
2627
|
+
</div>
|
|
2628
|
+
</section>
|
|
2629
|
+
);
|
|
2630
|
+
}
|
|
2631
|
+
`,
|
|
2632
|
+
schema: {
|
|
2633
|
+
type: "image-with-text",
|
|
2634
|
+
name: "Image with Text",
|
|
2635
|
+
locales: { ar: { name: "\u0635\u0648\u0631\u0629 \u0645\u0639 \u0646\u0635" } },
|
|
2636
|
+
settings: [
|
|
2637
|
+
{
|
|
2638
|
+
type: "image_picker",
|
|
2639
|
+
id: "image",
|
|
2640
|
+
label: "Image",
|
|
2641
|
+
locales: { ar: { label: "\u0627\u0644\u0635\u0648\u0631\u0629" } }
|
|
2642
|
+
},
|
|
2643
|
+
{
|
|
2644
|
+
type: "select",
|
|
2645
|
+
id: "image_position",
|
|
2646
|
+
label: "Image position",
|
|
2647
|
+
locales: { ar: { label: "\u0645\u0648\u0636\u0639 \u0627\u0644\u0635\u0648\u0631\u0629" } },
|
|
2648
|
+
default: "left",
|
|
2649
|
+
options: [
|
|
2650
|
+
{ value: "left", label: "Left" },
|
|
2651
|
+
{ value: "right", label: "Right" }
|
|
2652
|
+
]
|
|
2653
|
+
},
|
|
2654
|
+
{
|
|
2655
|
+
type: "text",
|
|
2656
|
+
id: "heading",
|
|
2657
|
+
label: "Heading",
|
|
2658
|
+
locales: { ar: { label: "\u0627\u0644\u0639\u0646\u0648\u0627\u0646" } }
|
|
2659
|
+
},
|
|
2660
|
+
{
|
|
2661
|
+
type: "textarea",
|
|
2662
|
+
id: "body",
|
|
2663
|
+
label: "Body text",
|
|
2664
|
+
locales: { ar: { label: "\u0627\u0644\u0646\u0635" } }
|
|
2665
|
+
},
|
|
2666
|
+
{
|
|
2667
|
+
type: "text",
|
|
2668
|
+
id: "cta_label",
|
|
2669
|
+
label: "Button label (optional)",
|
|
2670
|
+
locales: { ar: { label: "\u0646\u0635 \u0627\u0644\u0632\u0631 (\u0627\u062E\u062A\u064A\u0627\u0631\u064A)" } }
|
|
2671
|
+
},
|
|
2672
|
+
{
|
|
2673
|
+
type: "url",
|
|
2674
|
+
id: "cta_href",
|
|
2675
|
+
label: "Button link",
|
|
2676
|
+
locales: { ar: { label: "\u0631\u0627\u0628\u0637 \u0627\u0644\u0632\u0631" } },
|
|
2677
|
+
default: "/"
|
|
2678
|
+
}
|
|
2679
|
+
]
|
|
2680
|
+
}
|
|
2681
|
+
};
|
|
2682
|
+
|
|
2683
|
+
// src/section-library/entries/newsletter-signup.ts
|
|
2684
|
+
var newsletterSignup = {
|
|
2685
|
+
slug: "newsletter-signup",
|
|
2686
|
+
name: "Newsletter Signup",
|
|
2687
|
+
description: "Email capture form with merchant-supplied heading + consent copy",
|
|
2688
|
+
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
2689
|
+
import { Form } from "@numueg/theme-sdk";
|
|
2690
|
+
|
|
2691
|
+
export default function NewsletterSignup({ settings }: SectionProps) {
|
|
2692
|
+
const heading = (settings.heading as string) || "Stay in touch";
|
|
2693
|
+
const subheading = (settings.subheading as string) || "";
|
|
2694
|
+
const placeholder = (settings.placeholder as string) || "Email address";
|
|
2695
|
+
const buttonLabel = (settings.button_label as string) || "Subscribe";
|
|
2696
|
+
const consent = (settings.consent_text as string) || "";
|
|
2697
|
+
|
|
2698
|
+
return (
|
|
2699
|
+
<section className="py-16 px-6 bg-gray-50">
|
|
2700
|
+
<div className="max-w-2xl mx-auto text-center">
|
|
2701
|
+
<h2 className="text-2xl md:text-3xl font-semibold">{heading}</h2>
|
|
2702
|
+
{subheading && <p className="mt-3 text-gray-600">{subheading}</p>}
|
|
2703
|
+
<Form
|
|
2704
|
+
action="/api/newsletter/subscribe"
|
|
2705
|
+
method="POST"
|
|
2706
|
+
className="mt-8 flex flex-col sm:flex-row gap-2 max-w-md mx-auto"
|
|
2707
|
+
>
|
|
2708
|
+
<input
|
|
2709
|
+
type="email"
|
|
2710
|
+
name="email"
|
|
2711
|
+
required
|
|
2712
|
+
placeholder={placeholder}
|
|
2713
|
+
aria-label={placeholder}
|
|
2714
|
+
className="flex-1 px-4 py-3 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-900"
|
|
2715
|
+
/>
|
|
2716
|
+
<button
|
|
2717
|
+
type="submit"
|
|
2718
|
+
className="px-6 py-3 bg-gray-900 text-white rounded-md hover:bg-gray-800 transition"
|
|
2719
|
+
>
|
|
2720
|
+
{buttonLabel}
|
|
2721
|
+
</button>
|
|
2722
|
+
</Form>
|
|
2723
|
+
{consent && <p className="mt-3 text-xs text-gray-500">{consent}</p>}
|
|
2724
|
+
</div>
|
|
2725
|
+
</section>
|
|
2726
|
+
);
|
|
2727
|
+
}
|
|
2728
|
+
`,
|
|
2729
|
+
schema: {
|
|
2730
|
+
type: "newsletter-signup",
|
|
2731
|
+
name: "Newsletter Signup",
|
|
2732
|
+
locales: { ar: { name: "\u0627\u0634\u062A\u0631\u0627\u0643 \u0641\u064A \u0627\u0644\u0646\u0634\u0631\u0629" } },
|
|
2733
|
+
settings: [
|
|
2734
|
+
{
|
|
2735
|
+
type: "text",
|
|
2736
|
+
id: "heading",
|
|
2737
|
+
label: "Heading",
|
|
2738
|
+
locales: { ar: { label: "\u0627\u0644\u0639\u0646\u0648\u0627\u0646" } },
|
|
2739
|
+
default: "Stay in touch"
|
|
2740
|
+
},
|
|
2741
|
+
{
|
|
2742
|
+
type: "text",
|
|
2743
|
+
id: "subheading",
|
|
2744
|
+
label: "Subheading",
|
|
2745
|
+
locales: { ar: { label: "\u0627\u0644\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0641\u0631\u0639\u064A" } }
|
|
2746
|
+
},
|
|
2747
|
+
{
|
|
2748
|
+
type: "text",
|
|
2749
|
+
id: "placeholder",
|
|
2750
|
+
label: "Input placeholder",
|
|
2751
|
+
locales: { ar: { label: "\u0646\u0635 \u062A\u0648\u0636\u064A\u062D\u064A" } },
|
|
2752
|
+
default: "Email address"
|
|
2753
|
+
},
|
|
2754
|
+
{
|
|
2755
|
+
type: "text",
|
|
2756
|
+
id: "button_label",
|
|
2757
|
+
label: "Button label",
|
|
2758
|
+
locales: { ar: { label: "\u0646\u0635 \u0627\u0644\u0632\u0631" } },
|
|
2759
|
+
default: "Subscribe"
|
|
2760
|
+
},
|
|
2761
|
+
{
|
|
2762
|
+
type: "textarea",
|
|
2763
|
+
id: "consent_text",
|
|
2764
|
+
label: "Consent / GDPR copy (optional)",
|
|
2765
|
+
locales: { ar: { label: "\u0646\u0635 \u0627\u0644\u0645\u0648\u0627\u0641\u0642\u0629 (\u0627\u062E\u062A\u064A\u0627\u0631\u064A)" } }
|
|
2766
|
+
}
|
|
2767
|
+
]
|
|
2768
|
+
}
|
|
2769
|
+
};
|
|
2770
|
+
|
|
2771
|
+
// src/section-library/entries/multi-column.ts
|
|
2772
|
+
var multiColumn = {
|
|
2773
|
+
slug: "multi-column",
|
|
2774
|
+
name: "Multi-column",
|
|
2775
|
+
description: "2 / 3 / 4-column features grid with icon + heading + text per column",
|
|
2776
|
+
component: `import type { SectionProps, BlockProps } from "@numueg/theme-sdk";
|
|
2777
|
+
import { Block } from "@numueg/theme-sdk";
|
|
2778
|
+
|
|
2779
|
+
export default function MultiColumn({ settings, blocks }: SectionProps & { blocks?: BlockProps[] }) {
|
|
2780
|
+
const heading = (settings.heading as string) || "";
|
|
2781
|
+
const cols = (settings.columns as number) || 3;
|
|
2782
|
+
const gridCols =
|
|
2783
|
+
cols === 2 ? "md:grid-cols-2" :
|
|
2784
|
+
cols === 4 ? "md:grid-cols-2 lg:grid-cols-4" :
|
|
2785
|
+
"md:grid-cols-3";
|
|
2786
|
+
|
|
2787
|
+
return (
|
|
2788
|
+
<section className="py-16 px-6 max-w-7xl mx-auto">
|
|
2789
|
+
{heading && (
|
|
2790
|
+
<h2 className="text-2xl md:text-3xl font-semibold text-center mb-12">{heading}</h2>
|
|
2791
|
+
)}
|
|
2792
|
+
<div className={\`grid grid-cols-1 \${gridCols} gap-10\`}>
|
|
2793
|
+
{(blocks || []).map((block, i) => (
|
|
2794
|
+
<Block key={i} block={block}>
|
|
2795
|
+
{block.type === "column" && (
|
|
2796
|
+
<div className="text-center">
|
|
2797
|
+
{block.settings.icon ? (
|
|
2798
|
+
<img
|
|
2799
|
+
src={block.settings.icon as string}
|
|
2800
|
+
alt=""
|
|
2801
|
+
className="w-12 h-12 mx-auto mb-4"
|
|
2802
|
+
/>
|
|
2803
|
+
) : null}
|
|
2804
|
+
<h3 className="text-lg font-semibold mb-2">
|
|
2805
|
+
{(block.settings.title as string) || ""}
|
|
2806
|
+
</h3>
|
|
2807
|
+
<p className="text-gray-600">{(block.settings.text as string) || ""}</p>
|
|
2808
|
+
</div>
|
|
2809
|
+
)}
|
|
2810
|
+
</Block>
|
|
2811
|
+
))}
|
|
2812
|
+
</div>
|
|
2813
|
+
</section>
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2816
|
+
`,
|
|
2817
|
+
schema: {
|
|
2818
|
+
type: "multi-column",
|
|
2819
|
+
name: "Multi-column",
|
|
2820
|
+
locales: { ar: { name: "\u0623\u0639\u0645\u062F\u0629 \u0645\u062A\u0639\u062F\u062F\u0629" } },
|
|
2821
|
+
settings: [
|
|
2822
|
+
{
|
|
2823
|
+
type: "text",
|
|
2824
|
+
id: "heading",
|
|
2825
|
+
label: "Section heading",
|
|
2826
|
+
locales: { ar: { label: "\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0642\u0633\u0645" } }
|
|
2827
|
+
},
|
|
2828
|
+
{
|
|
2829
|
+
type: "range",
|
|
2830
|
+
id: "columns",
|
|
2831
|
+
label: "Number of columns",
|
|
2832
|
+
locales: { ar: { label: "\u0639\u062F\u062F \u0627\u0644\u0623\u0639\u0645\u062F\u0629" } },
|
|
2833
|
+
min: 2,
|
|
2834
|
+
max: 4,
|
|
2835
|
+
default: 3
|
|
2836
|
+
}
|
|
2837
|
+
],
|
|
2838
|
+
blocks: [
|
|
2839
|
+
{
|
|
2840
|
+
type: "column",
|
|
2841
|
+
name: "Column",
|
|
2842
|
+
locales: { ar: { name: "\u0639\u0645\u0648\u062F" } },
|
|
2843
|
+
max_blocks: 8,
|
|
2844
|
+
settings: [
|
|
2845
|
+
{
|
|
2846
|
+
type: "image_picker",
|
|
2847
|
+
id: "icon",
|
|
2848
|
+
label: "Icon",
|
|
2849
|
+
locales: { ar: { label: "\u0623\u064A\u0642\u0648\u0646\u0629" } }
|
|
2850
|
+
},
|
|
2851
|
+
{
|
|
2852
|
+
type: "text",
|
|
2853
|
+
id: "title",
|
|
2854
|
+
label: "Title",
|
|
2855
|
+
locales: { ar: { label: "\u0627\u0644\u0639\u0646\u0648\u0627\u0646" } }
|
|
2856
|
+
},
|
|
2857
|
+
{
|
|
2858
|
+
type: "textarea",
|
|
2859
|
+
id: "text",
|
|
2860
|
+
label: "Text",
|
|
2861
|
+
locales: { ar: { label: "\u0627\u0644\u0646\u0635" } }
|
|
2862
|
+
}
|
|
2863
|
+
]
|
|
2864
|
+
}
|
|
2865
|
+
]
|
|
2866
|
+
}
|
|
2867
|
+
};
|
|
2868
|
+
|
|
2869
|
+
// src/section-library/entries/testimonials.ts
|
|
2870
|
+
var testimonials = {
|
|
2871
|
+
slug: "testimonials",
|
|
2872
|
+
name: "Testimonials",
|
|
2873
|
+
description: "Customer reviews carousel with quote, author, role, optional photo",
|
|
2874
|
+
component: `import type { SectionProps, BlockProps } from "@numueg/theme-sdk";
|
|
2875
|
+
|
|
2876
|
+
export default function Testimonials({ settings, blocks }: SectionProps & { blocks?: BlockProps[] }) {
|
|
2877
|
+
const heading = (settings.heading as string) || "What our customers say";
|
|
2878
|
+
return (
|
|
2879
|
+
<section className="py-16 px-6 bg-gray-50">
|
|
2880
|
+
<div className="max-w-5xl mx-auto">
|
|
2881
|
+
<h2 className="text-2xl md:text-3xl font-semibold text-center mb-12">{heading}</h2>
|
|
2882
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
2883
|
+
{(blocks || []).map((b, i) => (
|
|
2884
|
+
<figure key={i} className="bg-white p-6 rounded-lg shadow-sm">
|
|
2885
|
+
<blockquote className="text-gray-700 italic">"{(b.settings.quote as string) || ""}"</blockquote>
|
|
2886
|
+
<figcaption className="mt-4 flex items-center gap-3">
|
|
2887
|
+
{b.settings.photo ? <img src={b.settings.photo as string} alt="" className="w-10 h-10 rounded-full" /> : null}
|
|
2888
|
+
<div>
|
|
2889
|
+
<div className="font-medium">{(b.settings.author as string) || ""}</div>
|
|
2890
|
+
{b.settings.role ? <div className="text-sm text-gray-500">{b.settings.role as string}</div> : null}
|
|
2891
|
+
</div>
|
|
2892
|
+
</figcaption>
|
|
2893
|
+
</figure>
|
|
2894
|
+
))}
|
|
2895
|
+
</div>
|
|
2896
|
+
</div>
|
|
2897
|
+
</section>
|
|
2898
|
+
);
|
|
2899
|
+
}
|
|
2900
|
+
`,
|
|
2901
|
+
schema: {
|
|
2902
|
+
type: "testimonials",
|
|
2903
|
+
name: "Testimonials",
|
|
2904
|
+
locales: { ar: { name: "\u0622\u0631\u0627\u0621 \u0627\u0644\u0639\u0645\u0644\u0627\u0621" } },
|
|
2905
|
+
settings: [
|
|
2906
|
+
{ type: "text", id: "heading", label: "Heading", default: "What our customers say" }
|
|
2907
|
+
],
|
|
2908
|
+
blocks: [
|
|
2909
|
+
{
|
|
2910
|
+
type: "testimonial",
|
|
2911
|
+
name: "Testimonial",
|
|
2912
|
+
max_blocks: 12,
|
|
2913
|
+
settings: [
|
|
2914
|
+
{ type: "textarea", id: "quote", label: "Quote" },
|
|
2915
|
+
{ type: "text", id: "author", label: "Author" },
|
|
2916
|
+
{ type: "text", id: "role", label: "Role / company" },
|
|
2917
|
+
{ type: "image_picker", id: "photo", label: "Photo" }
|
|
2918
|
+
]
|
|
2919
|
+
}
|
|
2920
|
+
]
|
|
2921
|
+
}
|
|
2922
|
+
};
|
|
2923
|
+
|
|
2924
|
+
// src/section-library/entries/faq-accordion.ts
|
|
2925
|
+
var faqAccordion = {
|
|
2926
|
+
slug: "faq-accordion",
|
|
2927
|
+
name: "FAQ Accordion",
|
|
2928
|
+
description: "Collapsible question/answer pairs using native <details>/<summary>",
|
|
2929
|
+
component: `import type { SectionProps, BlockProps } from "@numueg/theme-sdk";
|
|
2930
|
+
|
|
2931
|
+
export default function FaqAccordion({ settings, blocks }: SectionProps & { blocks?: BlockProps[] }) {
|
|
2932
|
+
const heading = (settings.heading as string) || "Frequently asked questions";
|
|
2933
|
+
return (
|
|
2934
|
+
<section className="py-16 px-6 max-w-3xl mx-auto">
|
|
2935
|
+
<h2 className="text-2xl md:text-3xl font-semibold text-center mb-10">{heading}</h2>
|
|
2936
|
+
<div className="space-y-3">
|
|
2937
|
+
{(blocks || []).map((b, i) => (
|
|
2938
|
+
<details key={i} className="bg-white border border-gray-200 rounded-lg group">
|
|
2939
|
+
<summary className="cursor-pointer p-4 font-medium list-none flex justify-between items-center group-open:border-b">
|
|
2940
|
+
<span>{(b.settings.question as string) || ""}</span>
|
|
2941
|
+
<span className="text-gray-400 group-open:rotate-180 transition" aria-hidden="true">\u25BE</span>
|
|
2942
|
+
</summary>
|
|
2943
|
+
<div className="p-4 text-gray-700 whitespace-pre-wrap">{(b.settings.answer as string) || ""}</div>
|
|
2944
|
+
</details>
|
|
2945
|
+
))}
|
|
2946
|
+
</div>
|
|
2947
|
+
</section>
|
|
2948
|
+
);
|
|
2949
|
+
}
|
|
2950
|
+
`,
|
|
2951
|
+
schema: {
|
|
2952
|
+
type: "faq-accordion",
|
|
2953
|
+
name: "FAQ Accordion",
|
|
2954
|
+
locales: { ar: { name: "\u0627\u0644\u0623\u0633\u0626\u0644\u0629 \u0627\u0644\u0634\u0627\u0626\u0639\u0629" } },
|
|
2955
|
+
settings: [
|
|
2956
|
+
{ type: "text", id: "heading", label: "Heading", default: "Frequently asked questions" }
|
|
2957
|
+
],
|
|
2958
|
+
blocks: [
|
|
2959
|
+
{
|
|
2960
|
+
type: "qa",
|
|
2961
|
+
name: "Q&A",
|
|
2962
|
+
max_blocks: 30,
|
|
2963
|
+
settings: [
|
|
2964
|
+
{ type: "text", id: "question", label: "Question" },
|
|
2965
|
+
{ type: "textarea", id: "answer", label: "Answer" }
|
|
2966
|
+
]
|
|
2967
|
+
}
|
|
2968
|
+
]
|
|
2969
|
+
}
|
|
2970
|
+
};
|
|
2971
|
+
|
|
2972
|
+
// src/section-library/entries/logo-cloud.ts
|
|
2973
|
+
var logoCloud = {
|
|
2974
|
+
slug: "logo-cloud",
|
|
2975
|
+
name: "Logo Cloud",
|
|
2976
|
+
description: "Row of partner / press / payment logos in grayscale with hover restore",
|
|
2977
|
+
component: `import type { SectionProps, BlockProps } from "@numueg/theme-sdk";
|
|
2978
|
+
|
|
2979
|
+
export default function LogoCloud({ settings, blocks }: SectionProps & { blocks?: BlockProps[] }) {
|
|
2980
|
+
const heading = (settings.heading as string) || "";
|
|
2981
|
+
return (
|
|
2982
|
+
<section className="py-12 px-6 bg-white">
|
|
2983
|
+
<div className="max-w-7xl mx-auto">
|
|
2984
|
+
{heading && <p className="text-center text-sm text-gray-500 uppercase tracking-widest mb-8">{heading}</p>}
|
|
2985
|
+
<div className="flex flex-wrap items-center justify-center gap-x-12 gap-y-6">
|
|
2986
|
+
{(blocks || []).map((b, i) => {
|
|
2987
|
+
const src = b.settings.logo as string | undefined;
|
|
2988
|
+
const alt = (b.settings.name as string) || "";
|
|
2989
|
+
if (!src) return null;
|
|
2990
|
+
const inner = <img src={src} alt={alt} className="h-8 md:h-10 w-auto opacity-60 hover:opacity-100 grayscale hover:grayscale-0 transition" />;
|
|
2991
|
+
const href = b.settings.href as string | undefined;
|
|
2992
|
+
return href ? <a key={i} href={href} target="_blank" rel="noopener noreferrer">{inner}</a> : <div key={i}>{inner}</div>;
|
|
2993
|
+
})}
|
|
2994
|
+
</div>
|
|
2995
|
+
</div>
|
|
2996
|
+
</section>
|
|
2997
|
+
);
|
|
2998
|
+
}
|
|
2999
|
+
`,
|
|
3000
|
+
schema: {
|
|
3001
|
+
type: "logo-cloud",
|
|
3002
|
+
name: "Logo Cloud",
|
|
3003
|
+
locales: { ar: { name: "\u0634\u0639\u0627\u0631\u0627\u062A \u0627\u0644\u0634\u0631\u0643\u0627\u0621" } },
|
|
3004
|
+
settings: [{ type: "text", id: "heading", label: "Heading" }],
|
|
3005
|
+
blocks: [
|
|
3006
|
+
{
|
|
3007
|
+
type: "logo",
|
|
3008
|
+
name: "Logo",
|
|
3009
|
+
max_blocks: 24,
|
|
3010
|
+
settings: [
|
|
3011
|
+
{ type: "image_picker", id: "logo", label: "Logo image" },
|
|
3012
|
+
{ type: "text", id: "name", label: "Brand name (alt)" },
|
|
3013
|
+
{ type: "url", id: "href", label: "Link (optional)" }
|
|
3014
|
+
]
|
|
3015
|
+
}
|
|
3016
|
+
]
|
|
3017
|
+
}
|
|
3018
|
+
};
|
|
3019
|
+
|
|
3020
|
+
// src/section-library/entries/collection-list.ts
|
|
3021
|
+
var collectionList = {
|
|
3022
|
+
slug: "collection-list",
|
|
3023
|
+
name: "Collection List",
|
|
3024
|
+
description: "Grid of collection cards (image + name) linking to each collection's PLP",
|
|
3025
|
+
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3026
|
+
import { CollectionCard, useCollections } from "@numueg/theme-sdk";
|
|
3027
|
+
|
|
3028
|
+
export default function CollectionListSection({ settings }: SectionProps) {
|
|
3029
|
+
const ids = (settings.collection_ids as string[]) || [];
|
|
3030
|
+
const limit = (settings.limit as number) || 6;
|
|
3031
|
+
const heading = (settings.heading as string) || "Shop by collection";
|
|
3032
|
+
const { collections, loading } = useCollections({
|
|
3033
|
+
ids: ids.length > 0 ? ids : undefined,
|
|
3034
|
+
limit: ids.length > 0 ? undefined : limit,
|
|
3035
|
+
});
|
|
3036
|
+
if (loading) return <section className="py-16 text-center text-gray-500">Loading\u2026</section>;
|
|
3037
|
+
if (!collections.length) return null;
|
|
3038
|
+
return (
|
|
3039
|
+
<section className="py-16 px-6 max-w-7xl mx-auto">
|
|
3040
|
+
<h2 className="text-2xl md:text-3xl font-semibold text-center mb-10">{heading}</h2>
|
|
3041
|
+
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
|
|
3042
|
+
{collections.slice(0, limit).map((c) => <CollectionCard key={c.id} collection={c} />)}
|
|
3043
|
+
</div>
|
|
3044
|
+
</section>
|
|
3045
|
+
);
|
|
3046
|
+
}
|
|
3047
|
+
`,
|
|
3048
|
+
schema: {
|
|
3049
|
+
type: "collection-list",
|
|
3050
|
+
name: "Collection List",
|
|
3051
|
+
locales: { ar: { name: "\u0642\u0627\u0626\u0645\u0629 \u0627\u0644\u0645\u062C\u0645\u0648\u0639\u0627\u062A" } },
|
|
3052
|
+
settings: [
|
|
3053
|
+
{ type: "text", id: "heading", label: "Heading", default: "Shop by collection" },
|
|
3054
|
+
{ type: "collection_list", id: "collection_ids", label: "Collections (empty = newest)" },
|
|
3055
|
+
{ type: "range", id: "limit", label: "Max collections", min: 2, max: 12, default: 6 }
|
|
3056
|
+
]
|
|
3057
|
+
}
|
|
3058
|
+
};
|
|
3059
|
+
|
|
3060
|
+
// src/section-library/entries/video-embed.ts
|
|
3061
|
+
var videoEmbed = {
|
|
3062
|
+
slug: "video-embed",
|
|
3063
|
+
name: "Video Embed",
|
|
3064
|
+
description: "Responsive 16:9 video (YouTube / Vimeo / self-hosted MP4)",
|
|
3065
|
+
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3066
|
+
|
|
3067
|
+
export default function VideoEmbed({ settings }: SectionProps) {
|
|
3068
|
+
const heading = (settings.heading as string) || "";
|
|
3069
|
+
const url = (settings.video_url as string) || "";
|
|
3070
|
+
const poster = (settings.poster as string) || undefined;
|
|
3071
|
+
const isMp4 = /\\.(mp4|webm|mov)$/i.test(url);
|
|
3072
|
+
|
|
3073
|
+
return (
|
|
3074
|
+
<section className="py-16 px-6 max-w-5xl mx-auto">
|
|
3075
|
+
{heading && <h2 className="text-2xl md:text-3xl font-semibold text-center mb-8">{heading}</h2>}
|
|
3076
|
+
<div className="aspect-video bg-black rounded-lg overflow-hidden">
|
|
3077
|
+
{url ? (
|
|
3078
|
+
isMp4 ? (
|
|
3079
|
+
<video src={url} poster={poster} controls className="w-full h-full" />
|
|
3080
|
+
) : (
|
|
3081
|
+
<iframe
|
|
3082
|
+
src={url}
|
|
3083
|
+
title={heading || "Video"}
|
|
3084
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
3085
|
+
allowFullScreen
|
|
3086
|
+
className="w-full h-full border-0"
|
|
3087
|
+
/>
|
|
3088
|
+
)
|
|
3089
|
+
) : (
|
|
3090
|
+
<div className="flex items-center justify-center h-full text-white/60">Set a video URL</div>
|
|
3091
|
+
)}
|
|
3092
|
+
</div>
|
|
3093
|
+
</section>
|
|
3094
|
+
);
|
|
3095
|
+
}
|
|
3096
|
+
`,
|
|
3097
|
+
schema: {
|
|
3098
|
+
type: "video-embed",
|
|
3099
|
+
name: "Video Embed",
|
|
3100
|
+
locales: { ar: { name: "\u0641\u064A\u062F\u064A\u0648 \u0645\u0636\u0645\u0646" } },
|
|
3101
|
+
settings: [
|
|
3102
|
+
{ type: "text", id: "heading", label: "Heading" },
|
|
3103
|
+
{ type: "url", id: "video_url", label: "Video URL (YouTube / Vimeo / .mp4)" },
|
|
3104
|
+
{ type: "image_picker", id: "poster", label: "Poster image (mp4 only)" }
|
|
3105
|
+
]
|
|
3106
|
+
}
|
|
3107
|
+
};
|
|
3108
|
+
|
|
3109
|
+
// src/section-library/entries/rich-text.ts
|
|
3110
|
+
var richText = {
|
|
3111
|
+
slug: "rich-text",
|
|
3112
|
+
name: "Rich Text",
|
|
3113
|
+
description: "Merchant-edited rich text block \u2014 title + paragraph(s) + optional CTA",
|
|
3114
|
+
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3115
|
+
import { RichText } from "@numueg/theme-sdk";
|
|
3116
|
+
|
|
3117
|
+
export default function RichTextSection({ settings }: SectionProps) {
|
|
3118
|
+
const heading = (settings.heading as string) || "";
|
|
3119
|
+
const body = (settings.body as string) || "";
|
|
3120
|
+
const align = (settings.alignment as string) || "left";
|
|
3121
|
+
const cls =
|
|
3122
|
+
align === "center" ? "text-center mx-auto"
|
|
3123
|
+
: align === "right" ? "text-right ml-auto"
|
|
3124
|
+
: "text-left";
|
|
3125
|
+
|
|
3126
|
+
return (
|
|
3127
|
+
<section className="py-16 px-6">
|
|
3128
|
+
<div className={\`max-w-3xl \${cls}\`}>
|
|
3129
|
+
{heading && <h2 className="text-2xl md:text-3xl font-semibold mb-6">{heading}</h2>}
|
|
3130
|
+
{body && <RichText html={body} className="prose max-w-none" />}
|
|
3131
|
+
</div>
|
|
3132
|
+
</section>
|
|
3133
|
+
);
|
|
3134
|
+
}
|
|
3135
|
+
`,
|
|
3136
|
+
schema: {
|
|
3137
|
+
type: "rich-text",
|
|
3138
|
+
name: "Rich Text",
|
|
3139
|
+
locales: { ar: { name: "\u0646\u0635 \u0645\u0646\u0633\u0642" } },
|
|
3140
|
+
settings: [
|
|
3141
|
+
{ type: "text", id: "heading", label: "Heading" },
|
|
3142
|
+
{ type: "richtext", id: "body", label: "Body" },
|
|
3143
|
+
{
|
|
3144
|
+
type: "select",
|
|
3145
|
+
id: "alignment",
|
|
3146
|
+
label: "Alignment",
|
|
3147
|
+
default: "left",
|
|
3148
|
+
options: [
|
|
3149
|
+
{ value: "left", label: "Left" },
|
|
3150
|
+
{ value: "center", label: "Center" },
|
|
3151
|
+
{ value: "right", label: "Right" }
|
|
3152
|
+
]
|
|
3153
|
+
}
|
|
3154
|
+
]
|
|
3155
|
+
}
|
|
3156
|
+
};
|
|
3157
|
+
|
|
3158
|
+
// src/section-library/entries/announcement-bar.ts
|
|
3159
|
+
var announcementBar = {
|
|
3160
|
+
slug: "announcement-bar",
|
|
3161
|
+
name: "Announcement Bar",
|
|
3162
|
+
description: "Thin top-of-page bar for promos / shipping notices, optionally dismissible",
|
|
3163
|
+
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3164
|
+
import { useEffect, useState } from "react";
|
|
3165
|
+
|
|
3166
|
+
const DISMISS_KEY = "numu_announcement_dismissed";
|
|
3167
|
+
|
|
3168
|
+
export default function AnnouncementBar({ settings }: SectionProps) {
|
|
3169
|
+
const message = (settings.message as string) || "";
|
|
3170
|
+
const linkLabel = settings.link_label as string | undefined;
|
|
3171
|
+
const linkHref = (settings.link_href as string) || "/";
|
|
3172
|
+
const dismissible = Boolean(settings.dismissible ?? true);
|
|
3173
|
+
const [dismissed, setDismissed] = useState(false);
|
|
3174
|
+
|
|
3175
|
+
useEffect(() => {
|
|
3176
|
+
if (!dismissible) return;
|
|
3177
|
+
setDismissed(typeof window !== "undefined" && window.sessionStorage.getItem(DISMISS_KEY) === "1");
|
|
3178
|
+
}, [dismissible]);
|
|
3179
|
+
|
|
3180
|
+
if (!message || dismissed) return null;
|
|
3181
|
+
|
|
3182
|
+
return (
|
|
3183
|
+
<div role="region" aria-label="Announcement" className="bg-gray-900 text-white text-sm">
|
|
3184
|
+
<div className="max-w-7xl mx-auto px-4 py-2 flex items-center justify-center gap-3 relative">
|
|
3185
|
+
<p>
|
|
3186
|
+
{message}
|
|
3187
|
+
{linkLabel && (
|
|
3188
|
+
<>
|
|
3189
|
+
{" "}
|
|
3190
|
+
<a href={linkHref} className="underline font-medium ml-1">{linkLabel}</a>
|
|
3191
|
+
</>
|
|
3192
|
+
)}
|
|
3193
|
+
</p>
|
|
3194
|
+
{dismissible && (
|
|
3195
|
+
<button
|
|
3196
|
+
type="button"
|
|
3197
|
+
aria-label="Dismiss announcement"
|
|
3198
|
+
onClick={() => {
|
|
3199
|
+
setDismissed(true);
|
|
3200
|
+
try { window.sessionStorage.setItem(DISMISS_KEY, "1"); } catch {}
|
|
3201
|
+
}}
|
|
3202
|
+
className="absolute end-3 text-white/70 hover:text-white"
|
|
3203
|
+
>
|
|
3204
|
+
\xD7
|
|
3205
|
+
</button>
|
|
3206
|
+
)}
|
|
3207
|
+
</div>
|
|
3208
|
+
</div>
|
|
3209
|
+
);
|
|
3210
|
+
}
|
|
3211
|
+
`,
|
|
3212
|
+
schema: {
|
|
3213
|
+
type: "announcement-bar",
|
|
3214
|
+
name: "Announcement Bar",
|
|
3215
|
+
locales: { ar: { name: "\u0634\u0631\u064A\u0637 \u0627\u0644\u0625\u0639\u0644\u0627\u0646\u0627\u062A" } },
|
|
3216
|
+
settings: [
|
|
3217
|
+
{ type: "text", id: "message", label: "Message" },
|
|
3218
|
+
{ type: "text", id: "link_label", label: "Link label (optional)" },
|
|
3219
|
+
{ type: "url", id: "link_href", label: "Link target" },
|
|
3220
|
+
{ type: "checkbox", id: "dismissible", label: "Dismissible", default: true }
|
|
3221
|
+
]
|
|
3222
|
+
}
|
|
3223
|
+
};
|
|
3224
|
+
|
|
3225
|
+
// src/section-library/entries/countdown-timer.ts
|
|
3226
|
+
var countdownTimer = {
|
|
3227
|
+
slug: "countdown-timer",
|
|
3228
|
+
name: "Countdown Timer",
|
|
3229
|
+
description: "Sale-end countdown \u2014 DD:HH:MM:SS ticking to a merchant-set target date",
|
|
3230
|
+
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3231
|
+
import { useEffect, useState } from "react";
|
|
3232
|
+
|
|
3233
|
+
function diff(target: number) {
|
|
3234
|
+
const ms = Math.max(0, target - Date.now());
|
|
3235
|
+
const d = Math.floor(ms / 86_400_000);
|
|
3236
|
+
const h = Math.floor((ms / 3_600_000) % 24);
|
|
3237
|
+
const m = Math.floor((ms / 60_000) % 60);
|
|
3238
|
+
const s = Math.floor((ms / 1000) % 60);
|
|
3239
|
+
return { d, h, m, s, done: ms === 0 };
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
export default function CountdownTimer({ settings }: SectionProps) {
|
|
3243
|
+
const heading = (settings.heading as string) || "Sale ends in";
|
|
3244
|
+
const targetIso = settings.end_at as string | undefined;
|
|
3245
|
+
const target = targetIso ? Date.parse(targetIso) : 0;
|
|
3246
|
+
const [t, setT] = useState(() => diff(target));
|
|
3247
|
+
|
|
3248
|
+
useEffect(() => {
|
|
3249
|
+
if (!target) return;
|
|
3250
|
+
const id = window.setInterval(() => setT(diff(target)), 1000);
|
|
3251
|
+
return () => window.clearInterval(id);
|
|
3252
|
+
}, [target]);
|
|
3253
|
+
|
|
3254
|
+
if (!targetIso) return null;
|
|
3255
|
+
|
|
3256
|
+
return (
|
|
3257
|
+
<section className="py-12 px-6 bg-red-50 text-center">
|
|
3258
|
+
<h2 className="text-xl md:text-2xl font-semibold mb-4">{heading}</h2>
|
|
3259
|
+
{t.done ? (
|
|
3260
|
+
<p className="text-lg text-red-700">The sale has ended.</p>
|
|
3261
|
+
) : (
|
|
3262
|
+
<div className="flex justify-center gap-4 md:gap-6 font-mono">
|
|
3263
|
+
{[
|
|
3264
|
+
{ label: "days", value: t.d },
|
|
3265
|
+
{ label: "hrs", value: t.h },
|
|
3266
|
+
{ label: "min", value: t.m },
|
|
3267
|
+
{ label: "sec", value: t.s },
|
|
3268
|
+
].map((u) => (
|
|
3269
|
+
<div key={u.label} className="bg-white rounded-lg p-3 min-w-[64px] shadow-sm">
|
|
3270
|
+
<div className="text-2xl md:text-3xl font-bold">{String(u.value).padStart(2, "0")}</div>
|
|
3271
|
+
<div className="text-xs text-gray-500 uppercase">{u.label}</div>
|
|
3272
|
+
</div>
|
|
3273
|
+
))}
|
|
3274
|
+
</div>
|
|
3275
|
+
)}
|
|
3276
|
+
</section>
|
|
3277
|
+
);
|
|
3278
|
+
}
|
|
3279
|
+
`,
|
|
3280
|
+
schema: {
|
|
3281
|
+
type: "countdown-timer",
|
|
3282
|
+
name: "Countdown Timer",
|
|
3283
|
+
locales: { ar: { name: "\u0639\u062F\u0627\u062F \u062A\u0646\u0627\u0632\u0644\u064A" } },
|
|
3284
|
+
settings: [
|
|
3285
|
+
{ type: "text", id: "heading", label: "Heading", default: "Sale ends in" },
|
|
3286
|
+
{ type: "text", id: "end_at", label: "End date/time (ISO 8601)", default: "" }
|
|
3287
|
+
]
|
|
3288
|
+
}
|
|
3289
|
+
};
|
|
3290
|
+
|
|
3291
|
+
// src/section-library/entries/featured-blog-posts.ts
|
|
3292
|
+
var featuredBlogPosts = {
|
|
3293
|
+
slug: "featured-blog-posts",
|
|
3294
|
+
name: "Featured Blog Posts",
|
|
3295
|
+
description: "3-up grid of recent blog articles with cover image + title + excerpt",
|
|
3296
|
+
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3297
|
+
|
|
3298
|
+
interface Article {
|
|
3299
|
+
handle: string;
|
|
3300
|
+
title: string;
|
|
3301
|
+
excerpt?: string | null;
|
|
3302
|
+
published_at?: string | null;
|
|
3303
|
+
cover_image?: string | null;
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
export default function FeaturedBlogPosts({ settings }: SectionProps) {
|
|
3307
|
+
const heading = (settings.heading as string) || "From the blog";
|
|
3308
|
+
// Themes can pass articles in via page.data (the blogs route ships
|
|
3309
|
+
// them on /[domain]/blogs). On other pages, this section needs a
|
|
3310
|
+
// merchant-supplied list of links in the schema.
|
|
3311
|
+
const articles = (settings._injected_articles as Article[] | undefined) || [];
|
|
3312
|
+
if (articles.length === 0) return null;
|
|
3313
|
+
|
|
3314
|
+
return (
|
|
3315
|
+
<section className="py-16 px-6 max-w-7xl mx-auto">
|
|
3316
|
+
<h2 className="text-2xl md:text-3xl font-semibold text-center mb-10">{heading}</h2>
|
|
3317
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
|
3318
|
+
{articles.slice(0, 3).map((a) => (
|
|
3319
|
+
<article key={a.handle} className="flex flex-col">
|
|
3320
|
+
{a.cover_image && <img src={a.cover_image} alt="" className="aspect-[4/3] w-full object-cover rounded-md" />}
|
|
3321
|
+
<h3 className="mt-4 text-lg font-semibold">
|
|
3322
|
+
<a href={\`/blogs/articles/\${a.handle}\`} className="hover:underline">{a.title}</a>
|
|
3323
|
+
</h3>
|
|
3324
|
+
{a.published_at && (
|
|
3325
|
+
<p className="text-xs text-gray-500 mt-1">{new Date(a.published_at).toLocaleDateString()}</p>
|
|
3326
|
+
)}
|
|
3327
|
+
{a.excerpt && <p className="mt-2 text-gray-700">{a.excerpt}</p>}
|
|
3328
|
+
</article>
|
|
3329
|
+
))}
|
|
3330
|
+
</div>
|
|
3331
|
+
</section>
|
|
3332
|
+
);
|
|
3333
|
+
}
|
|
3334
|
+
`,
|
|
3335
|
+
schema: {
|
|
3336
|
+
type: "featured-blog-posts",
|
|
3337
|
+
name: "Featured Blog Posts",
|
|
3338
|
+
locales: { ar: { name: "\u0645\u0646 \u0627\u0644\u0645\u062F\u0648\u0646\u0629" } },
|
|
3339
|
+
settings: [
|
|
3340
|
+
{ type: "text", id: "heading", label: "Heading", default: "From the blog" }
|
|
3341
|
+
]
|
|
3342
|
+
}
|
|
3343
|
+
};
|
|
3344
|
+
|
|
3345
|
+
// src/section-library/entries/contact-map.ts
|
|
3346
|
+
var contactMap = {
|
|
3347
|
+
slug: "contact-map",
|
|
3348
|
+
name: "Contact + Map",
|
|
3349
|
+
description: "Two-column contact info (address / phone / hours) + embedded map iframe",
|
|
3350
|
+
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3351
|
+
|
|
3352
|
+
export default function ContactMap({ settings }: SectionProps) {
|
|
3353
|
+
const heading = (settings.heading as string) || "Visit us";
|
|
3354
|
+
const address = (settings.address as string) || "";
|
|
3355
|
+
const phone = (settings.phone as string) || "";
|
|
3356
|
+
const hours = (settings.hours as string) || "";
|
|
3357
|
+
const mapUrl = (settings.map_url as string) || "";
|
|
3358
|
+
|
|
3359
|
+
return (
|
|
3360
|
+
<section className="py-16 px-6 max-w-7xl mx-auto">
|
|
3361
|
+
<h2 className="text-2xl md:text-3xl font-semibold mb-10 text-center">{heading}</h2>
|
|
3362
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
|
|
3363
|
+
<div className="space-y-6">
|
|
3364
|
+
{address && (
|
|
3365
|
+
<div>
|
|
3366
|
+
<h3 className="font-semibold mb-1">Address</h3>
|
|
3367
|
+
<p className="text-gray-700 whitespace-pre-line">{address}</p>
|
|
3368
|
+
</div>
|
|
3369
|
+
)}
|
|
3370
|
+
{phone && (
|
|
3371
|
+
<div>
|
|
3372
|
+
<h3 className="font-semibold mb-1">Phone</h3>
|
|
3373
|
+
<a href={\`tel:\${phone}\`} className="text-blue-700 hover:underline">{phone}</a>
|
|
3374
|
+
</div>
|
|
3375
|
+
)}
|
|
3376
|
+
{hours && (
|
|
3377
|
+
<div>
|
|
3378
|
+
<h3 className="font-semibold mb-1">Hours</h3>
|
|
3379
|
+
<p className="text-gray-700 whitespace-pre-line">{hours}</p>
|
|
3380
|
+
</div>
|
|
3381
|
+
)}
|
|
3382
|
+
</div>
|
|
3383
|
+
<div className="aspect-square md:aspect-auto bg-gray-100 rounded-lg overflow-hidden">
|
|
3384
|
+
{mapUrl ? (
|
|
3385
|
+
<iframe
|
|
3386
|
+
src={mapUrl}
|
|
3387
|
+
title="Map"
|
|
3388
|
+
loading="lazy"
|
|
3389
|
+
className="w-full h-full border-0"
|
|
3390
|
+
referrerPolicy="no-referrer-when-downgrade"
|
|
3391
|
+
allowFullScreen
|
|
3392
|
+
/>
|
|
3393
|
+
) : (
|
|
3394
|
+
<div className="flex items-center justify-center h-full text-gray-400">Set a map embed URL</div>
|
|
3395
|
+
)}
|
|
3396
|
+
</div>
|
|
3397
|
+
</div>
|
|
3398
|
+
</section>
|
|
3399
|
+
);
|
|
3400
|
+
}
|
|
3401
|
+
`,
|
|
3402
|
+
schema: {
|
|
3403
|
+
type: "contact-map",
|
|
3404
|
+
name: "Contact + Map",
|
|
3405
|
+
locales: { ar: { name: "\u062A\u0648\u0627\u0635\u0644 \u0648\u062E\u0631\u064A\u0637\u0629" } },
|
|
3406
|
+
settings: [
|
|
3407
|
+
{ type: "text", id: "heading", label: "Heading", default: "Visit us" },
|
|
3408
|
+
{ type: "textarea", id: "address", label: "Address" },
|
|
3409
|
+
{ type: "text", id: "phone", label: "Phone" },
|
|
3410
|
+
{ type: "textarea", id: "hours", label: "Hours" },
|
|
3411
|
+
{ type: "url", id: "map_url", label: "Map embed URL (Google Maps / OSM)" }
|
|
3412
|
+
]
|
|
3413
|
+
}
|
|
3414
|
+
};
|
|
3415
|
+
|
|
3416
|
+
// src/section-library/index.ts
|
|
3417
|
+
var LIBRARY = [
|
|
3418
|
+
heroWithCta,
|
|
3419
|
+
featuredProducts,
|
|
3420
|
+
imageWithText,
|
|
3421
|
+
newsletterSignup,
|
|
3422
|
+
multiColumn,
|
|
3423
|
+
testimonials,
|
|
3424
|
+
faqAccordion,
|
|
3425
|
+
logoCloud,
|
|
3426
|
+
collectionList,
|
|
3427
|
+
videoEmbed,
|
|
3428
|
+
richText,
|
|
3429
|
+
announcementBar,
|
|
3430
|
+
countdownTimer,
|
|
3431
|
+
featuredBlogPosts,
|
|
3432
|
+
contactMap
|
|
3433
|
+
];
|
|
3434
|
+
function findEntry(slug) {
|
|
3435
|
+
return LIBRARY.find((e) => e.slug === slug);
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
// src/commands/add-section.ts
|
|
3439
|
+
var addSectionCommand = new import_commander12.Command("add-section").description("Scaffold a new section (optionally from the built-in library)").argument("[name]", "Section slug (kebab-case, e.g. 'hero-banner')").option(
|
|
3440
|
+
"--from-library <slug>",
|
|
3441
|
+
"Copy from the built-in section library (run with --list to see options)"
|
|
3442
|
+
).option("--list", "List the built-in section library and exit").option("-d, --dir <directory>", "Theme directory", ".").action(
|
|
3443
|
+
async (name, options) => {
|
|
3444
|
+
if (options.list) {
|
|
3445
|
+
console.log("Built-in section library:\n");
|
|
3446
|
+
for (const entry of LIBRARY) {
|
|
3447
|
+
console.log(` ${entry.slug.padEnd(24)} ${entry.description}`);
|
|
3448
|
+
}
|
|
3449
|
+
console.log(
|
|
3450
|
+
"\nUsage: numu-theme add-section <new-name> --from-library <slug>"
|
|
3451
|
+
);
|
|
3452
|
+
return;
|
|
3453
|
+
}
|
|
3454
|
+
if (!name) {
|
|
3455
|
+
console.error(
|
|
3456
|
+
"Section name is required. Run with --list to see library options."
|
|
3457
|
+
);
|
|
3458
|
+
process.exit(1);
|
|
3459
|
+
}
|
|
3460
|
+
const themeDir = path11.resolve(process.cwd(), options.dir);
|
|
3461
|
+
if (!fs12.existsSync(path11.join(themeDir, "theme.json"))) {
|
|
3462
|
+
console.error(
|
|
3463
|
+
"No theme.json in this directory. Run from a theme project root."
|
|
3464
|
+
);
|
|
3465
|
+
process.exit(1);
|
|
3466
|
+
}
|
|
3467
|
+
const slug = toKebab(name);
|
|
3468
|
+
const pascal = toPascal(name);
|
|
3469
|
+
let componentSource;
|
|
3470
|
+
let schemaJson;
|
|
3471
|
+
if (options.fromLibrary) {
|
|
3472
|
+
const entry = findEntry(options.fromLibrary);
|
|
3473
|
+
if (!entry) {
|
|
3474
|
+
console.error(
|
|
3475
|
+
`Unknown library slug: '${options.fromLibrary}'. Run --list to see options.`
|
|
3476
|
+
);
|
|
3477
|
+
process.exit(1);
|
|
3478
|
+
}
|
|
3479
|
+
componentSource = entry.component;
|
|
3480
|
+
schemaJson = JSON.parse(JSON.stringify(entry.schema));
|
|
3481
|
+
schemaJson.type = slug;
|
|
3482
|
+
} else {
|
|
3483
|
+
componentSource = emptySectionStub(pascal);
|
|
3484
|
+
schemaJson = {
|
|
3485
|
+
type: slug,
|
|
3486
|
+
name: humanize(slug),
|
|
3487
|
+
settings: [
|
|
3488
|
+
{
|
|
3489
|
+
type: "text",
|
|
3490
|
+
id: "headline",
|
|
3491
|
+
label: "Headline",
|
|
3492
|
+
default: humanize(slug)
|
|
3493
|
+
}
|
|
3494
|
+
]
|
|
3495
|
+
};
|
|
3496
|
+
}
|
|
3497
|
+
const componentPath = path11.join(themeDir, "src/sections", `${pascal}.tsx`);
|
|
3498
|
+
ensureDirOf(componentPath);
|
|
3499
|
+
if (fs12.existsSync(componentPath)) {
|
|
3500
|
+
console.error(`Already exists: ${componentPath}`);
|
|
3501
|
+
process.exit(1);
|
|
3502
|
+
}
|
|
3503
|
+
fs12.writeFileSync(componentPath, componentSource);
|
|
3504
|
+
const schemaPath = path11.join(themeDir, "schemas/sections", `${slug}.json`);
|
|
3505
|
+
ensureDirOf(schemaPath);
|
|
3506
|
+
fs12.writeFileSync(schemaPath, JSON.stringify(schemaJson, null, 2));
|
|
3507
|
+
tryWireMain(themeDir, slug, pascal);
|
|
3508
|
+
tryAddToHomePreset(themeDir, slug);
|
|
3509
|
+
console.log(`\u2714 Created section '${slug}'.`);
|
|
3510
|
+
console.log(` \u2022 src/sections/${pascal}.tsx`);
|
|
3511
|
+
console.log(` \u2022 schemas/sections/${slug}.json`);
|
|
3512
|
+
console.log(
|
|
3513
|
+
options.fromLibrary ? `
|
|
3514
|
+
From library: '${options.fromLibrary}'. Edit the files to customize.` : "\nEdit the files to add your design + behavior."
|
|
3515
|
+
);
|
|
3516
|
+
}
|
|
3517
|
+
);
|
|
3518
|
+
function toKebab(s) {
|
|
3519
|
+
return s.replace(/[_\s]+/g, "-").replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
3520
|
+
}
|
|
3521
|
+
function toPascal(s) {
|
|
3522
|
+
return toKebab(s).split("-").filter(Boolean).map((p) => p[0].toUpperCase() + p.slice(1)).join("");
|
|
3523
|
+
}
|
|
3524
|
+
function humanize(slug) {
|
|
3525
|
+
return slug.split("-").filter(Boolean).map((p) => p[0].toUpperCase() + p.slice(1)).join(" ");
|
|
3526
|
+
}
|
|
3527
|
+
function ensureDirOf(p) {
|
|
3528
|
+
fs12.mkdirSync(path11.dirname(p), { recursive: true });
|
|
3529
|
+
}
|
|
3530
|
+
function emptySectionStub(pascal) {
|
|
3531
|
+
return `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3532
|
+
|
|
3533
|
+
export default function ${pascal}({ settings }: SectionProps) {
|
|
3534
|
+
const headline = (settings.headline as string) || "${pascal}";
|
|
3535
|
+
return (
|
|
3536
|
+
<section className="py-16 px-6">
|
|
3537
|
+
<div className="max-w-4xl mx-auto">
|
|
3538
|
+
<h2 className="text-2xl font-semibold">{headline}</h2>
|
|
3539
|
+
</div>
|
|
3540
|
+
</section>
|
|
3541
|
+
);
|
|
3542
|
+
}
|
|
3543
|
+
`;
|
|
3544
|
+
}
|
|
3545
|
+
function tryWireMain(themeDir, slug, pascal) {
|
|
3546
|
+
const mainPath = path11.join(themeDir, "src/main.tsx");
|
|
3547
|
+
if (!fs12.existsSync(mainPath)) return;
|
|
3548
|
+
const src = fs12.readFileSync(mainPath, "utf-8");
|
|
3549
|
+
if (src.includes(`./sections/${pascal}`)) return;
|
|
3550
|
+
const importLine = `import ${pascal} from "./sections/${pascal}";
|
|
3551
|
+
`;
|
|
3552
|
+
const lines = src.split("\n");
|
|
3553
|
+
let lastImportIdx = -1;
|
|
3554
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3555
|
+
if (lines[i].trimStart().startsWith("import ")) lastImportIdx = i;
|
|
3556
|
+
}
|
|
3557
|
+
if (lastImportIdx >= 0) {
|
|
3558
|
+
lines.splice(lastImportIdx + 1, 0, importLine.trimEnd());
|
|
3559
|
+
} else {
|
|
3560
|
+
lines.unshift(importLine.trimEnd());
|
|
3561
|
+
}
|
|
3562
|
+
let inserted = false;
|
|
3563
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3564
|
+
if (/section\.type\s*===\s*['"]/.test(lines[i])) {
|
|
3565
|
+
const indent = lines[i].match(/^\s*/)?.[0] ?? " ";
|
|
3566
|
+
lines.splice(
|
|
3567
|
+
i,
|
|
3568
|
+
0,
|
|
3569
|
+
`${indent}if (section.type === "${slug}") {`,
|
|
3570
|
+
`${indent} return <${pascal} key={sectionId} settings={section.settings} />;`,
|
|
3571
|
+
`${indent}}`
|
|
3572
|
+
);
|
|
3573
|
+
inserted = true;
|
|
3574
|
+
break;
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
if (!inserted) {
|
|
3578
|
+
lines.push(
|
|
3579
|
+
"",
|
|
3580
|
+
`// TODO: dispatch the new '${slug}' section type:`,
|
|
3581
|
+
`// if (section.type === "${slug}") return <${pascal} settings={section.settings} />;`
|
|
3582
|
+
);
|
|
3583
|
+
}
|
|
3584
|
+
fs12.writeFileSync(mainPath, lines.join("\n"));
|
|
3585
|
+
}
|
|
3586
|
+
function tryAddToHomePreset(themeDir, slug) {
|
|
3587
|
+
const themeJsonPath = path11.join(themeDir, "theme.json");
|
|
3588
|
+
if (!fs12.existsSync(themeJsonPath)) return;
|
|
3589
|
+
let parsed;
|
|
3590
|
+
try {
|
|
3591
|
+
parsed = JSON.parse(fs12.readFileSync(themeJsonPath, "utf-8"));
|
|
3592
|
+
} catch {
|
|
3593
|
+
return;
|
|
3594
|
+
}
|
|
3595
|
+
const presets = parsed.presets || {};
|
|
3596
|
+
const templates = presets.templates || {};
|
|
3597
|
+
const home = templates.home;
|
|
3598
|
+
if (!home) return;
|
|
3599
|
+
const sections = home.sections || {};
|
|
3600
|
+
const order = home.order || [];
|
|
3601
|
+
const newKey = `${slug.replace(/-/g, "_")}_1`;
|
|
3602
|
+
for (const existing of Object.values(sections)) {
|
|
3603
|
+
if (existing && typeof existing === "object" && existing.type === slug)
|
|
3604
|
+
return;
|
|
3605
|
+
}
|
|
3606
|
+
sections[newKey] = { type: slug, settings: {} };
|
|
3607
|
+
order.push(newKey);
|
|
3608
|
+
home.sections = sections;
|
|
3609
|
+
home.order = order;
|
|
3610
|
+
fs12.writeFileSync(themeJsonPath, JSON.stringify(parsed, null, 2));
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
// src/commands/add-block.ts
|
|
3614
|
+
var import_commander13 = require("commander");
|
|
3615
|
+
var fs13 = __toESM(require("fs"));
|
|
3616
|
+
var path12 = __toESM(require("path"));
|
|
3617
|
+
var addBlockCommand = new import_commander13.Command("add-block").description("Scaffold a new block inside an existing section schema").argument("<section>", "Section type, e.g. 'product_grid'").argument("<name>", "Block type, e.g. 'feature'").option("-d, --dir <directory>", "Theme directory", ".").action((section, name, options) => {
|
|
3618
|
+
const themeDir = path12.resolve(options.dir);
|
|
3619
|
+
if (!fs13.existsSync(path12.join(themeDir, "theme.json"))) {
|
|
3620
|
+
console.error(`No theme.json in ${themeDir}.`);
|
|
3621
|
+
process.exit(1);
|
|
3622
|
+
}
|
|
3623
|
+
const sectionSnake = toSnakeCase(section);
|
|
3624
|
+
const blockSnake = toSnakeCase(name);
|
|
3625
|
+
const blockHuman = humanize2(toPascalCase(name));
|
|
3626
|
+
const schemaPath = path12.join(
|
|
3627
|
+
themeDir,
|
|
3628
|
+
"schemas",
|
|
3629
|
+
"sections",
|
|
3630
|
+
`${sectionSnake}.json`
|
|
3631
|
+
);
|
|
3632
|
+
if (!fs13.existsSync(schemaPath)) {
|
|
3633
|
+
console.error(
|
|
3634
|
+
`Section schema not found: schemas/sections/${sectionSnake}.json`
|
|
3635
|
+
);
|
|
3636
|
+
console.error(`Create the section first with \`numu-theme add-section ${sectionSnake}\`.`);
|
|
3637
|
+
process.exit(1);
|
|
3638
|
+
}
|
|
3639
|
+
const schema = JSON.parse(fs13.readFileSync(schemaPath, "utf-8"));
|
|
3640
|
+
schema.blocks = schema.blocks ?? [];
|
|
3641
|
+
if (Array.isArray(schema.blocks) && schema.blocks.some(
|
|
3642
|
+
(b) => typeof b === "object" && b !== null && b.type === blockSnake
|
|
3643
|
+
)) {
|
|
3644
|
+
console.error(`Block "${blockSnake}" already exists in this section.`);
|
|
3645
|
+
process.exit(1);
|
|
3646
|
+
}
|
|
3647
|
+
schema.blocks.push({
|
|
3648
|
+
type: blockSnake,
|
|
3649
|
+
name: blockHuman,
|
|
3650
|
+
locales: { ar: { name: blockHuman } },
|
|
3651
|
+
settings: [
|
|
3652
|
+
{
|
|
3653
|
+
type: "text",
|
|
3654
|
+
id: "label",
|
|
3655
|
+
label: "Label",
|
|
3656
|
+
default: blockHuman,
|
|
3657
|
+
locales: { ar: { label: "\u0627\u0644\u0646\u0635" } }
|
|
3658
|
+
}
|
|
3659
|
+
]
|
|
3660
|
+
});
|
|
3661
|
+
if (typeof schema.max_blocks !== "number") {
|
|
3662
|
+
schema.max_blocks = 12;
|
|
3663
|
+
}
|
|
3664
|
+
fs13.writeFileSync(schemaPath, JSON.stringify(schema, null, 2) + "\n");
|
|
3665
|
+
console.log(
|
|
3666
|
+
`
|
|
3667
|
+
\x1B[32m\u2713\x1B[0m Added block "${blockSnake}" to section "${sectionSnake}":`,
|
|
3668
|
+
`
|
|
3669
|
+
- ${path12.relative(themeDir, schemaPath)}`,
|
|
3670
|
+
`
|
|
3671
|
+
|
|
3672
|
+
\x1B[33m\u26A0\x1B[0m The section component should iterate \`blockOrder\` and render each block by type.`,
|
|
3673
|
+
`
|
|
3674
|
+
Pattern:`,
|
|
3675
|
+
`
|
|
3676
|
+
{(blockOrder ?? []).map((id) => {`,
|
|
3677
|
+
`
|
|
3678
|
+
const b = blocks?.[id];`,
|
|
3679
|
+
`
|
|
3680
|
+
if (!b || b.disabled) return null;`,
|
|
3681
|
+
`
|
|
3682
|
+
if (b.type === "${blockSnake}") return <Block key={id} id={id} type="${blockSnake}">\u2026</Block>;`,
|
|
3683
|
+
`
|
|
3684
|
+
return null;`,
|
|
3685
|
+
`
|
|
3686
|
+
})}
|
|
3687
|
+
`
|
|
3688
|
+
);
|
|
3689
|
+
});
|
|
3690
|
+
function toSnakeCase(name) {
|
|
3691
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toLowerCase();
|
|
3692
|
+
}
|
|
3693
|
+
function toPascalCase(name) {
|
|
3694
|
+
return name.replace(/[_\s-]+(.)?/g, (_m, c) => c ? c.toUpperCase() : "").replace(/^(.)/, (m) => m.toUpperCase());
|
|
3695
|
+
}
|
|
3696
|
+
function humanize2(name) {
|
|
3697
|
+
return name.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (m) => m.toUpperCase());
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
// src/commands/pull.ts
|
|
3701
|
+
var import_commander14 = require("commander");
|
|
3702
|
+
var fs14 = __toESM(require("fs"));
|
|
3703
|
+
var path13 = __toESM(require("path"));
|
|
3704
|
+
var https2 = __toESM(require("https"));
|
|
3705
|
+
var http2 = __toESM(require("http"));
|
|
3706
|
+
async function downloadToFile(url, dest) {
|
|
3707
|
+
return new Promise((resolve7, reject) => {
|
|
3708
|
+
const client = url.startsWith("https:") ? https2 : http2;
|
|
3709
|
+
const req = client.get(url, (res) => {
|
|
3710
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
3711
|
+
downloadToFile(res.headers.location, dest).then(resolve7, reject);
|
|
3712
|
+
return;
|
|
3713
|
+
}
|
|
3714
|
+
if (res.statusCode !== 200) {
|
|
3715
|
+
reject(new Error(`Download failed: HTTP ${res.statusCode}`));
|
|
3716
|
+
return;
|
|
3717
|
+
}
|
|
3718
|
+
const out = fs14.createWriteStream(dest);
|
|
3719
|
+
res.pipe(out);
|
|
3720
|
+
out.on("finish", () => out.close(() => resolve7()));
|
|
3721
|
+
out.on("error", reject);
|
|
3722
|
+
});
|
|
3723
|
+
req.on("error", reject);
|
|
3724
|
+
});
|
|
3725
|
+
}
|
|
3726
|
+
async function unzip(zipPath, targetDir) {
|
|
3727
|
+
const { execFileSync } = await import("child_process");
|
|
3728
|
+
fs14.mkdirSync(targetDir, { recursive: true });
|
|
3729
|
+
if (process.platform === "win32") {
|
|
3730
|
+
execFileSync("tar", ["-xf", zipPath, "-C", targetDir], {
|
|
3731
|
+
stdio: "inherit"
|
|
3732
|
+
});
|
|
3733
|
+
} else {
|
|
3734
|
+
execFileSync("unzip", ["-q", zipPath, "-d", targetDir], {
|
|
3735
|
+
stdio: "inherit"
|
|
3736
|
+
});
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
var pullCommand = new import_commander14.Command("pull").description("Download a published theme's source to a local directory").argument("<theme-id>", "Theme slug or UUID").option(
|
|
3740
|
+
"-d, --dir <directory>",
|
|
3741
|
+
"Target directory (default: ./<theme-id>)"
|
|
3742
|
+
).option(
|
|
3743
|
+
"-v, --version <version>",
|
|
3744
|
+
"Specific version_string (default: latest)"
|
|
3745
|
+
).option("--force", "Overwrite the target directory if it exists").action(
|
|
3746
|
+
async (themeId, options) => {
|
|
3747
|
+
const targetDir = path13.resolve(options.dir || themeId);
|
|
3748
|
+
if (fs14.existsSync(targetDir) && !options.force) {
|
|
3749
|
+
const isEmpty = fs14.readdirSync(targetDir).length === 0;
|
|
3750
|
+
if (!isEmpty) {
|
|
3751
|
+
console.error(
|
|
3752
|
+
`Target directory ${targetDir} is not empty. Re-run with --force to overwrite.`
|
|
3753
|
+
);
|
|
3754
|
+
process.exit(1);
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3757
|
+
console.log(`Resolving theme ${themeId}\u2026`);
|
|
3758
|
+
const themeRes = await apiRequest(
|
|
3759
|
+
"GET",
|
|
3760
|
+
`/api/v1/marketplace/themes/${encodeURIComponent(themeId)}`
|
|
3761
|
+
);
|
|
3762
|
+
if (themeRes.status !== 200) {
|
|
3763
|
+
console.error(
|
|
3764
|
+
`Theme not found (HTTP ${themeRes.status}). Double-check the slug or UUID.`
|
|
3765
|
+
);
|
|
3766
|
+
process.exit(1);
|
|
3767
|
+
}
|
|
3768
|
+
const theme = themeRes.data;
|
|
3769
|
+
const versionsRes = await apiRequest(
|
|
3770
|
+
"GET",
|
|
3771
|
+
`/api/v1/marketplace/themes/${encodeURIComponent(themeId)}/versions`
|
|
3772
|
+
);
|
|
3773
|
+
if (versionsRes.status !== 200) {
|
|
3774
|
+
console.error(
|
|
3775
|
+
`Couldn't list versions (HTTP ${versionsRes.status}).`
|
|
3776
|
+
);
|
|
3777
|
+
process.exit(1);
|
|
3778
|
+
}
|
|
3779
|
+
const versions = versionsRes.data;
|
|
3780
|
+
const target = options.version ? versions.find((v) => v.version_string === options.version) : versions[0];
|
|
3781
|
+
if (!target) {
|
|
3782
|
+
console.error(
|
|
3783
|
+
options.version ? `No version "${options.version}" found for this theme.` : "No versions published for this theme yet."
|
|
3784
|
+
);
|
|
3785
|
+
process.exit(1);
|
|
3786
|
+
}
|
|
3787
|
+
if (!target.source_zip_path) {
|
|
3788
|
+
console.error(
|
|
3789
|
+
`Version ${target.version_string} has no source ZIP \u2014 pull is only available for versions submitted with a ZIP upload (post-Phase 7).`
|
|
3790
|
+
);
|
|
3791
|
+
process.exit(1);
|
|
3792
|
+
}
|
|
3793
|
+
console.log(`Downloading ${theme.name || themeId} ${target.version_string}\u2026`);
|
|
3794
|
+
const tmpZip = path13.join(
|
|
3795
|
+
require("os").tmpdir(),
|
|
3796
|
+
`numu-pull-${process.pid}-${Date.now()}.zip`
|
|
3797
|
+
);
|
|
3798
|
+
try {
|
|
3799
|
+
await downloadToFile(target.source_zip_path, tmpZip);
|
|
3800
|
+
console.log(`Unpacking into ${targetDir}\u2026`);
|
|
3801
|
+
fs14.rmSync(targetDir, { recursive: true, force: true });
|
|
3802
|
+
await unzip(tmpZip, targetDir);
|
|
3803
|
+
} finally {
|
|
3804
|
+
if (fs14.existsSync(tmpZip)) {
|
|
3805
|
+
fs14.unlinkSync(tmpZip);
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
console.log(`\u2714 Pulled ${theme.slug || themeId}@${target.version_string} into ${targetDir}`);
|
|
3809
|
+
console.log("");
|
|
3810
|
+
console.log("Next steps:");
|
|
3811
|
+
console.log(` cd ${path13.relative(process.cwd(), targetDir) || "."}`);
|
|
3812
|
+
console.log(" npm install");
|
|
3813
|
+
console.log(" numu-theme dev");
|
|
3814
|
+
}
|
|
3815
|
+
);
|
|
3816
|
+
|
|
3817
|
+
// src/commands/delete.ts
|
|
3818
|
+
var import_commander15 = require("commander");
|
|
3819
|
+
var readline = __toESM(require("readline"));
|
|
3820
|
+
async function confirm(prompt) {
|
|
3821
|
+
const rl = readline.createInterface({
|
|
3822
|
+
input: process.stdin,
|
|
3823
|
+
output: process.stdout
|
|
3824
|
+
});
|
|
3825
|
+
return new Promise((resolve7) => {
|
|
3826
|
+
rl.question(`${prompt} [y/N] `, (answer) => {
|
|
3827
|
+
rl.close();
|
|
3828
|
+
resolve7(/^y(es)?$/i.test(answer.trim()));
|
|
3829
|
+
});
|
|
3830
|
+
});
|
|
3831
|
+
}
|
|
3832
|
+
var deleteCommand = new import_commander15.Command("delete").description("Delete a theme or a specific version from the marketplace").argument("<theme-id>", "Theme slug or UUID").option("-v, --version <version>", "Delete this version only (default: whole theme)").option("-y, --yes", "Skip the confirmation prompt").action(
|
|
3833
|
+
async (themeId, options) => {
|
|
3834
|
+
const isVersion = Boolean(options.version);
|
|
3835
|
+
const scope = isVersion ? `version ${options.version} of ${themeId}` : `the entire theme ${themeId}`;
|
|
3836
|
+
if (!options.yes) {
|
|
3837
|
+
const ok = await confirm(
|
|
3838
|
+
`This will permanently delete ${scope}. Existing installations may break. Continue?`
|
|
3839
|
+
);
|
|
3840
|
+
if (!ok) {
|
|
3841
|
+
console.log("Cancelled.");
|
|
3842
|
+
return;
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
const path14 = isVersion ? `/api/v1/marketplace/themes/${encodeURIComponent(themeId)}/versions/${encodeURIComponent(options.version)}` : `/api/v1/marketplace/themes/${encodeURIComponent(themeId)}`;
|
|
3846
|
+
const res = await apiRequest("DELETE", path14);
|
|
3847
|
+
if (res.status === 200 || res.status === 204) {
|
|
3848
|
+
console.log(`\u2714 Deleted ${scope}.`);
|
|
3849
|
+
return;
|
|
3850
|
+
}
|
|
3851
|
+
if (res.status === 404) {
|
|
3852
|
+
console.error("Not found. Already deleted, or you don't have access.");
|
|
3853
|
+
process.exit(1);
|
|
3854
|
+
}
|
|
3855
|
+
if (res.status === 403) {
|
|
3856
|
+
console.error("Forbidden. You can only delete themes you own.");
|
|
3857
|
+
process.exit(1);
|
|
3858
|
+
}
|
|
3859
|
+
console.error(`Delete failed (HTTP ${res.status}).`);
|
|
3860
|
+
process.exit(1);
|
|
3861
|
+
}
|
|
3862
|
+
);
|
|
3863
|
+
|
|
3864
|
+
// src/index.ts
|
|
3865
|
+
var program = new import_commander16.Command();
|
|
3866
|
+
program.name("numu-theme").description("CLI for developing, validating, building, and publishing NUMU themes").version("0.1.0");
|
|
3867
|
+
program.addCommand(initCommand);
|
|
3868
|
+
program.addCommand(devCommand);
|
|
3869
|
+
program.addCommand(checkCommand);
|
|
3870
|
+
program.addCommand(lintCommand);
|
|
3871
|
+
program.addCommand(buildCommand);
|
|
3872
|
+
program.addCommand(pushCommand);
|
|
3873
|
+
program.addCommand(submitCommand);
|
|
3874
|
+
program.addCommand(installCommand);
|
|
3875
|
+
program.addCommand(loginCommand);
|
|
3876
|
+
program.addCommand(statusCommand);
|
|
3877
|
+
program.addCommand(doctorCommand);
|
|
3878
|
+
program.addCommand(addSectionCommand);
|
|
3879
|
+
program.addCommand(addBlockCommand);
|
|
3880
|
+
program.addCommand(pullCommand);
|
|
3881
|
+
program.addCommand(deleteCommand);
|
|
3882
|
+
program.parse();
|