@fluid-app/fluid-cli-theme-dev 0.1.14 → 0.1.16
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/.turbo/turbo-build.log +5 -7
- package/.turbo/turbo-typecheck.log +4 -0
- package/dist/index.mjs +437 -56
- package/dist/index.mjs.map +1 -1
- package/jest.config.cjs +21 -0
- package/jest.mocks/fluid-cli.ts +33 -0
- package/package.json +10 -5
- package/src/__tests__/plugin-state.test.ts +186 -0
- package/src/commands/dev.ts +36 -16
- package/src/commands/lint.ts +175 -0
- package/src/commands/navigate.ts +2 -4
- package/src/commands/theme.ts +3 -1
- package/src/plugin-state.ts +156 -11
package/dist/index.mjs
CHANGED
|
@@ -17,11 +17,13 @@ import { execFileSync } from "node:child_process";
|
|
|
17
17
|
var ApiError = class ApiError extends Error {
|
|
18
18
|
status;
|
|
19
19
|
data;
|
|
20
|
-
|
|
20
|
+
requestId;
|
|
21
|
+
constructor(message, status, data, requestId) {
|
|
21
22
|
super(message);
|
|
22
23
|
this.name = "ApiError";
|
|
23
24
|
this.status = status;
|
|
24
25
|
this.data = data;
|
|
26
|
+
this.requestId = requestId;
|
|
25
27
|
if ("captureStackTrace" in Error) Error.captureStackTrace(this, ApiError);
|
|
26
28
|
}
|
|
27
29
|
toJSON() {
|
|
@@ -29,15 +31,32 @@ var ApiError = class ApiError extends Error {
|
|
|
29
31
|
name: this.name,
|
|
30
32
|
message: this.message,
|
|
31
33
|
status: this.status,
|
|
32
|
-
data: this.data
|
|
34
|
+
data: this.data,
|
|
35
|
+
requestId: this.requestId
|
|
33
36
|
};
|
|
34
37
|
}
|
|
35
38
|
};
|
|
39
|
+
function getStringRequestId(value) {
|
|
40
|
+
if (typeof value !== "string") return;
|
|
41
|
+
const trimmed = value.trim();
|
|
42
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
43
|
+
}
|
|
44
|
+
function getRequestIdFromHeaders(headers) {
|
|
45
|
+
return getStringRequestId(headers.get("x-request-id")) ?? getStringRequestId(headers.get("request-id")) ?? getStringRequestId(headers.get("X-Request-ID"));
|
|
46
|
+
}
|
|
47
|
+
function getRequestIdFromJsonBody(body) {
|
|
48
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return;
|
|
49
|
+
const record = body;
|
|
50
|
+
const meta = record.meta;
|
|
51
|
+
return getStringRequestId(record.request_id) ?? getStringRequestId(record.requestId) ?? (meta && typeof meta === "object" && !Array.isArray(meta) ? getStringRequestId(meta.request_id) ?? getStringRequestId(meta.requestId) : void 0);
|
|
52
|
+
}
|
|
36
53
|
/**
|
|
37
54
|
* Creates a configured fetch client instance
|
|
38
55
|
*/
|
|
39
56
|
function createFetchClient(config) {
|
|
40
|
-
const { baseUrl, getAuthToken, onAuthError, defaultHeaders = {}, credentials } = config;
|
|
57
|
+
const { baseUrl, getAuthToken, onAuthError, defaultHeaders = {}, credentials, cache, networkRetry, throwOnInvalidJson = false } = config;
|
|
58
|
+
const maxNetworkRetries = Math.max(0, networkRetry?.maxRetries ?? 0);
|
|
59
|
+
const baseNetworkRetryDelayMs = Math.max(0, networkRetry?.baseDelayMs ?? 0);
|
|
41
60
|
/**
|
|
42
61
|
* Build headers for a request
|
|
43
62
|
*/
|
|
@@ -88,6 +107,7 @@ function createFetchClient(config) {
|
|
|
88
107
|
* Handles auth errors, non-OK responses, 204 No Content, and JSON parsing.
|
|
89
108
|
*/
|
|
90
109
|
async function handleResponse(response, method, _url) {
|
|
110
|
+
const headerRequestId = getRequestIdFromHeaders(response.headers);
|
|
91
111
|
if (response.status === 401 && onAuthError) onAuthError();
|
|
92
112
|
if (!response.ok) {
|
|
93
113
|
const errorText = await response.text().catch(() => "");
|
|
@@ -96,28 +116,48 @@ function createFetchClient(config) {
|
|
|
96
116
|
try {
|
|
97
117
|
data = JSON.parse(errorText);
|
|
98
118
|
} catch {
|
|
99
|
-
throw new ApiError(errorText.slice(0, 200) || `${method} request failed with status ${response.status}`, response.status, null);
|
|
119
|
+
throw new ApiError(errorText.slice(0, 200) || `${method} request failed with status ${response.status}`, response.status, null, headerRequestId);
|
|
100
120
|
}
|
|
101
|
-
|
|
102
|
-
|
|
121
|
+
const nestedError = typeof data.error === "object" && data.error !== null ? data.error.message : void 0;
|
|
122
|
+
throw new ApiError(data.message || data.error_message || (typeof nestedError === "string" ? nestedError : void 0) || `${method} request failed`, response.status, data.errors || data, headerRequestId ?? getRequestIdFromJsonBody(data));
|
|
123
|
+
} else throw new ApiError(`${method} request failed with status ${response.status}`, response.status, null, headerRequestId);
|
|
103
124
|
}
|
|
104
125
|
if (response.status === 204 || response.headers.get("content-length") === "0") return null;
|
|
105
|
-
if (response.headers.get("content-type")?.includes("application/json"))
|
|
106
|
-
|
|
107
|
-
} catch {
|
|
126
|
+
if (response.headers.get("content-type")?.includes("application/json")) {
|
|
127
|
+
const responseText = await response.text();
|
|
108
128
|
try {
|
|
109
|
-
return
|
|
129
|
+
return JSON.parse(responseText);
|
|
110
130
|
} catch {
|
|
111
|
-
|
|
131
|
+
if (throwOnInvalidJson) throw new ApiError("Failed to parse response as JSON", response.status, null, headerRequestId);
|
|
132
|
+
return responseText ? responseText : null;
|
|
112
133
|
}
|
|
113
134
|
}
|
|
114
135
|
return null;
|
|
115
136
|
}
|
|
137
|
+
function getNetworkRetryDelayMs(retryAttempt) {
|
|
138
|
+
return baseNetworkRetryDelayMs * 2 ** (retryAttempt - 1);
|
|
139
|
+
}
|
|
140
|
+
async function waitForNetworkRetry(retryAttempt) {
|
|
141
|
+
const delayMs = getNetworkRetryDelayMs(retryAttempt);
|
|
142
|
+
if (delayMs <= 0) return;
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
144
|
+
}
|
|
145
|
+
async function fetchWithNetworkRetry(url, fetchOptions, signal) {
|
|
146
|
+
let retryCount = 0;
|
|
147
|
+
while (true) try {
|
|
148
|
+
return await fetch(url, fetchOptions);
|
|
149
|
+
} catch (networkError) {
|
|
150
|
+
if (signal?.aborted || retryCount >= maxNetworkRetries) throw networkError;
|
|
151
|
+
retryCount += 1;
|
|
152
|
+
await waitForNetworkRetry(retryCount);
|
|
153
|
+
if (signal?.aborted) throw networkError;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
116
156
|
/**
|
|
117
157
|
* Main request function
|
|
118
158
|
*/
|
|
119
159
|
async function request(endpoint, options = {}) {
|
|
120
|
-
const { method = "GET", headers: customHeaders, params, body, signal } = options;
|
|
160
|
+
const { method = "GET", headers: customHeaders, params, body, signal, priority } = options;
|
|
121
161
|
const url = params ? buildUrl(endpoint, params) : joinUrl(endpoint);
|
|
122
162
|
const headers = await buildHeaders(customHeaders);
|
|
123
163
|
let response;
|
|
@@ -127,10 +167,12 @@ function createFetchClient(config) {
|
|
|
127
167
|
headers
|
|
128
168
|
};
|
|
129
169
|
if (credentials) fetchOptions.credentials = credentials;
|
|
170
|
+
if (cache) fetchOptions.cache = cache;
|
|
171
|
+
if (priority) fetchOptions.priority = priority;
|
|
130
172
|
const serializedBody = body && method !== "GET" ? JSON.stringify(body) : null;
|
|
131
173
|
if (serializedBody) fetchOptions.body = serializedBody;
|
|
132
174
|
if (signal) fetchOptions.signal = signal;
|
|
133
|
-
response = await
|
|
175
|
+
response = await fetchWithNetworkRetry(url, fetchOptions, signal);
|
|
134
176
|
} catch (networkError) {
|
|
135
177
|
throw new ApiError(`Network error: ${networkError instanceof Error ? networkError.message : "Unknown network error"}`, 0, null);
|
|
136
178
|
}
|
|
@@ -140,7 +182,7 @@ function createFetchClient(config) {
|
|
|
140
182
|
* Request with FormData (for file uploads)
|
|
141
183
|
*/
|
|
142
184
|
async function requestWithFormData(endpoint, formData, options = {}) {
|
|
143
|
-
const { method = "POST", headers: customHeaders, signal } = options;
|
|
185
|
+
const { method = "POST", headers: customHeaders, signal, priority } = options;
|
|
144
186
|
const url = joinUrl(endpoint);
|
|
145
187
|
const headers = await buildHeaders(customHeaders);
|
|
146
188
|
delete headers["Content-Type"];
|
|
@@ -152,8 +194,10 @@ function createFetchClient(config) {
|
|
|
152
194
|
body: formData
|
|
153
195
|
};
|
|
154
196
|
if (credentials) fetchOptions.credentials = credentials;
|
|
197
|
+
if (cache) fetchOptions.cache = cache;
|
|
198
|
+
if (priority) fetchOptions.priority = priority;
|
|
155
199
|
if (signal) fetchOptions.signal = signal;
|
|
156
|
-
response = await
|
|
200
|
+
response = await fetchWithNetworkRetry(url, fetchOptions, signal);
|
|
157
201
|
} catch (networkError) {
|
|
158
202
|
throw new ApiError(`Network error: ${networkError instanceof Error ? networkError.message : "Unknown network error"}`, 0, null);
|
|
159
203
|
}
|
|
@@ -232,20 +276,130 @@ function writeThemeConfig(themeRoot, config) {
|
|
|
232
276
|
//#endregion
|
|
233
277
|
//#region src/plugin-state.ts
|
|
234
278
|
const PLUGIN_KEY = "theme-dev";
|
|
235
|
-
function
|
|
279
|
+
function getState() {
|
|
236
280
|
return readConfig().plugins[PLUGIN_KEY] ?? {};
|
|
237
281
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
282
|
+
/** Extract the absolute theme root from a `company:themeRoot` key. */
|
|
283
|
+
function themeRootFromKey(key) {
|
|
284
|
+
const sep = key.indexOf(":");
|
|
285
|
+
return sep === -1 ? key : key.slice(sep + 1);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Set `key` to `theme`, dropping any entries whose theme directory no longer
|
|
289
|
+
* exists. Tying an entry's lifetime to its directory keeps the map bounded —
|
|
290
|
+
* abandoned/deleted projects fall out the next time `theme dev` runs anywhere.
|
|
291
|
+
*/
|
|
292
|
+
function withDevTheme(existing, key, theme) {
|
|
293
|
+
const next = {};
|
|
294
|
+
for (const [k, v] of Object.entries(existing ?? {})) if (existsSync(themeRootFromKey(k))) next[k] = v;
|
|
295
|
+
next[key] = theme;
|
|
296
|
+
return next;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Stable key identifying a dev theme's owning project: the Fluid company
|
|
300
|
+
* (subdomains are globally unique) plus the absolute theme root. Two working
|
|
301
|
+
* copies — or the same copy pulled from two companies — get distinct keys.
|
|
302
|
+
*/
|
|
303
|
+
function devThemeKey(company, themeRoot) {
|
|
304
|
+
return `${company ?? "default"}:${themeRoot}`;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The dev theme stored for a project key, if any. Falls back once to the legacy
|
|
308
|
+
* global `devThemeId` (older CLI versions) and adopts it for this key — clearing
|
|
309
|
+
* the legacy fields so a second project can't adopt the same theme and collide.
|
|
310
|
+
*/
|
|
311
|
+
function getDevTheme(key) {
|
|
312
|
+
const state = getState();
|
|
313
|
+
const existing = state.devThemes?.[key];
|
|
314
|
+
if (existing) return existing;
|
|
315
|
+
if (state.devThemeId) {
|
|
316
|
+
const migrated = {
|
|
317
|
+
id: state.devThemeId,
|
|
318
|
+
name: state.devThemeName ?? `Development #${state.devThemeId}`
|
|
319
|
+
};
|
|
320
|
+
updateConfig((config) => {
|
|
321
|
+
const { devThemeId: _id, devThemeName: _name, ...rest } = config.plugins[PLUGIN_KEY] ?? {};
|
|
322
|
+
return {
|
|
323
|
+
...config,
|
|
324
|
+
plugins: {
|
|
325
|
+
...config.plugins,
|
|
326
|
+
[PLUGIN_KEY]: {
|
|
327
|
+
...rest,
|
|
328
|
+
devThemes: withDevTheme(rest.devThemes, key, migrated),
|
|
329
|
+
lastDevThemeId: migrated.id
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
return migrated;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** Store (or refresh) the dev theme for a project key and mark it most-recent. */
|
|
338
|
+
function setDevTheme(key, theme) {
|
|
339
|
+
updateConfig((config) => {
|
|
340
|
+
const current = config.plugins[PLUGIN_KEY] ?? {};
|
|
341
|
+
return {
|
|
342
|
+
...config,
|
|
343
|
+
plugins: {
|
|
344
|
+
...config.plugins,
|
|
345
|
+
[PLUGIN_KEY]: {
|
|
346
|
+
...current,
|
|
347
|
+
devThemes: withDevTheme(current.devThemes, key, theme),
|
|
348
|
+
lastDevThemeId: theme.id
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/** Forget a project's dev theme (it was deleted remotely or is no longer a dev theme). */
|
|
355
|
+
function clearDevTheme(key) {
|
|
356
|
+
updateConfig((config) => {
|
|
357
|
+
const current = config.plugins[PLUGIN_KEY] ?? {};
|
|
358
|
+
const removed = current.devThemes?.[key];
|
|
359
|
+
if (!removed) return config;
|
|
360
|
+
const { [key]: _removed, ...rest } = current.devThemes ?? {};
|
|
361
|
+
const next = {
|
|
362
|
+
...current,
|
|
363
|
+
devThemes: rest
|
|
364
|
+
};
|
|
365
|
+
if (current.lastDevThemeId === removed.id) next.lastDevThemeId = void 0;
|
|
366
|
+
return {
|
|
367
|
+
...config,
|
|
368
|
+
plugins: {
|
|
369
|
+
...config.plugins,
|
|
370
|
+
[PLUGIN_KEY]: next
|
|
246
371
|
}
|
|
247
|
-
}
|
|
248
|
-
})
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Mark a theme as the most recently started dev server (`navigate`'s default)
|
|
377
|
+
* without recording it as a project's dev theme — used for the `--theme`
|
|
378
|
+
* escape hatch, which may target an arbitrary (non-dev) theme.
|
|
379
|
+
*/
|
|
380
|
+
function setLastDevThemeId(id) {
|
|
381
|
+
updateConfig((config) => {
|
|
382
|
+
const current = config.plugins[PLUGIN_KEY] ?? {};
|
|
383
|
+
return {
|
|
384
|
+
...config,
|
|
385
|
+
plugins: {
|
|
386
|
+
...config.plugins,
|
|
387
|
+
[PLUGIN_KEY]: {
|
|
388
|
+
...current,
|
|
389
|
+
lastDevThemeId: id
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* The dev theme to target by default in `navigate` — the most recently started
|
|
397
|
+
* dev server. Falls back to the legacy global id for users who haven't yet run
|
|
398
|
+
* the per-project `theme dev`.
|
|
399
|
+
*/
|
|
400
|
+
function getLastDevThemeId() {
|
|
401
|
+
const state = getState();
|
|
402
|
+
return state.lastDevThemeId ?? state.devThemeId;
|
|
249
403
|
}
|
|
250
404
|
//#endregion
|
|
251
405
|
//#region src/theme/mime-type.ts
|
|
@@ -299,6 +453,7 @@ function mimeTypeFor(ext) {
|
|
|
299
453
|
const VALID_SETTING_TYPES = Object.values({
|
|
300
454
|
"input": [
|
|
301
455
|
"text",
|
|
456
|
+
"plaintext",
|
|
302
457
|
"rich_text",
|
|
303
458
|
"richtext",
|
|
304
459
|
"textarea",
|
|
@@ -308,6 +463,7 @@ const VALID_SETTING_TYPES = Object.values({
|
|
|
308
463
|
],
|
|
309
464
|
"number_and_selection": [
|
|
310
465
|
"range",
|
|
466
|
+
"number",
|
|
311
467
|
"select",
|
|
312
468
|
"radio",
|
|
313
469
|
"checkbox"
|
|
@@ -315,6 +471,7 @@ const VALID_SETTING_TYPES = Object.values({
|
|
|
315
471
|
"visual_and_media": [
|
|
316
472
|
"color",
|
|
317
473
|
"color_background",
|
|
474
|
+
"font",
|
|
318
475
|
"font_picker",
|
|
319
476
|
"image",
|
|
320
477
|
"image_picker",
|
|
@@ -339,11 +496,13 @@ const VALID_SETTING_TYPES = Object.values({
|
|
|
339
496
|
"categories",
|
|
340
497
|
"blog",
|
|
341
498
|
"posts",
|
|
499
|
+
"post",
|
|
342
500
|
"enrollment",
|
|
343
501
|
"enrollments",
|
|
344
502
|
"enrollment_pack",
|
|
345
503
|
"forms",
|
|
346
504
|
"media",
|
|
505
|
+
"variant",
|
|
347
506
|
"link_list"
|
|
348
507
|
],
|
|
349
508
|
"resource_list": [
|
|
@@ -355,35 +514,71 @@ const VALID_SETTING_TYPES = Object.values({
|
|
|
355
514
|
"categories_list",
|
|
356
515
|
"posts_list",
|
|
357
516
|
"enrollment_list",
|
|
358
|
-
"enrollments_list"
|
|
517
|
+
"enrollments_list",
|
|
518
|
+
"blog_list",
|
|
519
|
+
"blogs_list",
|
|
520
|
+
"post_list",
|
|
521
|
+
"enrollment_packs_list"
|
|
359
522
|
]
|
|
360
523
|
}).flat();
|
|
361
524
|
//#endregion
|
|
362
525
|
//#region ../../platform/theme-schema/src/validate-settings.ts
|
|
526
|
+
/**
|
|
527
|
+
* Message shown when a setting declares a `type` that is not one of the
|
|
528
|
+
* canonical `VALID_SETTING_TYPES`. Centralized here so the CLI and the editor
|
|
529
|
+
* render identical text (including the list of available types).
|
|
530
|
+
*/
|
|
531
|
+
function invalidSettingTypeMessage(type) {
|
|
532
|
+
return `Invalid settings type: '${type}'\nAvailable types:\n${VALID_SETTING_TYPES.join(", \n")}`;
|
|
533
|
+
}
|
|
363
534
|
function validateSettings(settings) {
|
|
364
535
|
const diagnostics = [];
|
|
365
536
|
const ids = /* @__PURE__ */ new Set();
|
|
366
537
|
for (let index = 0; index < settings.length; index++) {
|
|
367
538
|
const raw = settings[index];
|
|
368
539
|
const setting = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
369
|
-
const id = setting.id;
|
|
370
|
-
const type = setting.type;
|
|
371
|
-
if (
|
|
540
|
+
const id = typeof setting.id === "string" ? setting.id : void 0;
|
|
541
|
+
const type = typeof setting.type === "string" ? setting.type : void 0;
|
|
542
|
+
if (id !== void 0 && id.trim() === "") diagnostics.push({
|
|
372
543
|
severity: "error",
|
|
373
|
-
message: "Error in settings: id cannot be empty"
|
|
544
|
+
message: "Error in settings: id cannot be empty",
|
|
545
|
+
target: {
|
|
546
|
+
kind: "setting",
|
|
547
|
+
index,
|
|
548
|
+
settingType: type,
|
|
549
|
+
field: "id"
|
|
550
|
+
}
|
|
374
551
|
});
|
|
375
552
|
else if (id && ids.has(id)) diagnostics.push({
|
|
376
553
|
severity: "error",
|
|
377
|
-
message: `Error in settings: duplicate id '${id}' found
|
|
554
|
+
message: `Error in settings: duplicate id '${id}' found`,
|
|
555
|
+
target: {
|
|
556
|
+
kind: "setting",
|
|
557
|
+
index,
|
|
558
|
+
settingId: id,
|
|
559
|
+
field: "id"
|
|
560
|
+
}
|
|
378
561
|
});
|
|
379
562
|
else if (id) ids.add(id);
|
|
380
563
|
if (!type) diagnostics.push({
|
|
381
564
|
severity: "error",
|
|
382
|
-
message: `Error in setting '${id ?? index}': missing required field 'type'
|
|
565
|
+
message: `Error in setting '${id ?? index}': missing required field 'type'`,
|
|
566
|
+
target: {
|
|
567
|
+
kind: "setting",
|
|
568
|
+
index,
|
|
569
|
+
settingId: id,
|
|
570
|
+
field: "type"
|
|
571
|
+
}
|
|
383
572
|
});
|
|
384
573
|
else if (!VALID_SETTING_TYPES.includes(type)) diagnostics.push({
|
|
385
574
|
severity: "error",
|
|
386
|
-
message:
|
|
575
|
+
message: invalidSettingTypeMessage(type),
|
|
576
|
+
target: {
|
|
577
|
+
kind: "setting",
|
|
578
|
+
index,
|
|
579
|
+
settingType: type,
|
|
580
|
+
field: "type"
|
|
581
|
+
}
|
|
387
582
|
});
|
|
388
583
|
}
|
|
389
584
|
return diagnostics;
|
|
@@ -396,23 +591,50 @@ function validateBlocks(blocks) {
|
|
|
396
591
|
for (let index = 0; index < blocks.length; index++) {
|
|
397
592
|
const raw = blocks[index];
|
|
398
593
|
const block = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
399
|
-
const type = block.type;
|
|
400
|
-
const name = block.name;
|
|
594
|
+
const type = typeof block.type === "string" ? block.type : void 0;
|
|
595
|
+
const name = typeof block.name === "string" ? block.name : void 0;
|
|
401
596
|
const settings = block.settings;
|
|
402
597
|
if (!type) diagnostics.push({
|
|
403
598
|
severity: "error",
|
|
404
|
-
message: `Error in blocks at index ${index}: missing required field 'type'
|
|
599
|
+
message: `Error in blocks at index ${index}: missing required field 'type'`,
|
|
600
|
+
target: {
|
|
601
|
+
kind: "block",
|
|
602
|
+
index,
|
|
603
|
+
field: "type"
|
|
604
|
+
}
|
|
405
605
|
});
|
|
406
606
|
else if (types.has(type)) diagnostics.push({
|
|
407
607
|
severity: "warning",
|
|
408
|
-
message: `Warning in blocks: duplicate type '${type}' found
|
|
608
|
+
message: `Warning in blocks: duplicate type '${type}' found`,
|
|
609
|
+
target: {
|
|
610
|
+
kind: "block",
|
|
611
|
+
index,
|
|
612
|
+
blockType: type,
|
|
613
|
+
field: "type"
|
|
614
|
+
}
|
|
409
615
|
});
|
|
410
616
|
else types.add(type);
|
|
411
617
|
if (!name && type !== "@app" && type !== "@theme" && !(!name && !settings)) diagnostics.push({
|
|
412
618
|
severity: "error",
|
|
413
|
-
message: `Error in block '${type ?? index}': missing required field 'name'
|
|
619
|
+
message: `Error in block '${type ?? index}': missing required field 'name'`,
|
|
620
|
+
target: {
|
|
621
|
+
kind: "block",
|
|
622
|
+
index,
|
|
623
|
+
blockType: type,
|
|
624
|
+
field: "name"
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
if (settings) if (!Array.isArray(settings)) diagnostics.push({
|
|
628
|
+
severity: "error",
|
|
629
|
+
message: `Error in block '${type ?? index}': 'settings' must be an array ([])`,
|
|
630
|
+
target: {
|
|
631
|
+
kind: "block",
|
|
632
|
+
index,
|
|
633
|
+
blockType: type,
|
|
634
|
+
field: "settings"
|
|
635
|
+
}
|
|
414
636
|
});
|
|
415
|
-
|
|
637
|
+
else diagnostics.push(...validateSettings(settings));
|
|
416
638
|
if (Array.isArray(block.blocks)) diagnostics.push(...validateBlocks(block.blocks));
|
|
417
639
|
}
|
|
418
640
|
return diagnostics;
|
|
@@ -514,6 +736,68 @@ function validateSchemaText(text, options) {
|
|
|
514
736
|
return diagnostics;
|
|
515
737
|
}
|
|
516
738
|
//#endregion
|
|
739
|
+
//#region ../../platform/theme-schema/src/sections.ts
|
|
740
|
+
const LIQUID_COMMENT_REGEX = /\{%-?\s*comment\s*-?%\}[\s\S]*?\{%-?\s*endcomment\s*-?%\}/g;
|
|
741
|
+
const SCHEMA_BLOCK_REGEX = /\{%-?\s*schema\s*-?%\}[\s\S]*?\{%-?\s*endschema\s*-?%\}/;
|
|
742
|
+
const SECTION_TAG_PATTERN = "\\{%-?\\s*section\\s+['\"]([^'\"]+)['\"](?:\\s*,\\s*id:\\s*['\"]([^'\"]+)['\"])?\\s*-?%\\}";
|
|
743
|
+
function templateBody(liquid) {
|
|
744
|
+
return liquid.replace(LIQUID_COMMENT_REGEX, "").replace(SCHEMA_BLOCK_REGEX, "");
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Extract every `{% section %}` reference from a liquid template, ignoring
|
|
748
|
+
* tags inside comments or the `{% schema %}` block. Shared by the editor's
|
|
749
|
+
* section-usage detection and the CLI linter so both parse references
|
|
750
|
+
* identically.
|
|
751
|
+
*/
|
|
752
|
+
function extractSectionReferences(liquid) {
|
|
753
|
+
const body = templateBody(liquid);
|
|
754
|
+
const pattern = new RegExp(SECTION_TAG_PATTERN, "g");
|
|
755
|
+
const references = [];
|
|
756
|
+
let order = 0;
|
|
757
|
+
let match;
|
|
758
|
+
while ((match = pattern.exec(body)) !== null) {
|
|
759
|
+
const type = match[1];
|
|
760
|
+
if (!type) continue;
|
|
761
|
+
references.push({
|
|
762
|
+
type,
|
|
763
|
+
id: match[2],
|
|
764
|
+
fullTag: match[0],
|
|
765
|
+
order: order++
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
return references;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Find `{% section %}` references that point to a section that does not exist
|
|
772
|
+
* in `existingSectionNames` — the static equivalent of "an in-use section was
|
|
773
|
+
* deleted". Emits one `error` diagnostic per missing section type per template.
|
|
774
|
+
*/
|
|
775
|
+
function findMissingSectionReferences(templates, existingSectionNames) {
|
|
776
|
+
const missing = [];
|
|
777
|
+
for (const template of templates) {
|
|
778
|
+
const reported = /* @__PURE__ */ new Set();
|
|
779
|
+
for (const reference of extractSectionReferences(template.content)) {
|
|
780
|
+
if (existingSectionNames.has(reference.type)) continue;
|
|
781
|
+
if (reported.has(reference.type)) continue;
|
|
782
|
+
reported.add(reference.type);
|
|
783
|
+
missing.push({
|
|
784
|
+
templatePath: template.path,
|
|
785
|
+
sectionType: reference.type,
|
|
786
|
+
diagnostic: {
|
|
787
|
+
severity: "error",
|
|
788
|
+
message: `references missing section '${reference.type}'`,
|
|
789
|
+
target: {
|
|
790
|
+
kind: "section",
|
|
791
|
+
sectionType: reference.type,
|
|
792
|
+
tagId: reference.id
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return missing;
|
|
799
|
+
}
|
|
800
|
+
//#endregion
|
|
517
801
|
//#region src/theme/file.ts
|
|
518
802
|
var ThemeFile = class {
|
|
519
803
|
absolutePath;
|
|
@@ -1383,24 +1667,35 @@ function resolveThemeRootFromCwd(workspace) {
|
|
|
1383
1667
|
}
|
|
1384
1668
|
//#endregion
|
|
1385
1669
|
//#region src/commands/dev.ts
|
|
1386
|
-
async function ensureDevTheme(api, identifier) {
|
|
1387
|
-
if (identifier)
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1670
|
+
async function ensureDevTheme(api, projectKey, identifier) {
|
|
1671
|
+
if (identifier) {
|
|
1672
|
+
const theme = await findTheme(api, identifier);
|
|
1673
|
+
setLastDevThemeId(theme.id);
|
|
1674
|
+
return theme;
|
|
1675
|
+
}
|
|
1676
|
+
const stored = getDevTheme(projectKey);
|
|
1677
|
+
if (stored) {
|
|
1678
|
+
try {
|
|
1679
|
+
const existing = (await getApplicationTheme(api, stored.id)).application_theme;
|
|
1680
|
+
if (existing && existing.status === "development") {
|
|
1681
|
+
console.log(`Using existing dev theme #${existing.id}`);
|
|
1682
|
+
setDevTheme(projectKey, {
|
|
1683
|
+
id: existing.id,
|
|
1684
|
+
name: existing.name
|
|
1685
|
+
});
|
|
1686
|
+
return existing;
|
|
1687
|
+
}
|
|
1688
|
+
} catch {}
|
|
1689
|
+
clearDevTheme(projectKey);
|
|
1690
|
+
}
|
|
1396
1691
|
const { hostname } = await import("node:os");
|
|
1397
1692
|
const theme = (await createApplicationTheme(api, { application_theme: {
|
|
1398
1693
|
name: `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50),
|
|
1399
1694
|
status: "development"
|
|
1400
1695
|
} })).application_theme;
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1696
|
+
setDevTheme(projectKey, {
|
|
1697
|
+
id: theme.id,
|
|
1698
|
+
name: theme.name
|
|
1404
1699
|
});
|
|
1405
1700
|
console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
|
|
1406
1701
|
return theme;
|
|
@@ -1435,7 +1730,8 @@ function createDevCommand() {
|
|
|
1435
1730
|
process.exit(1);
|
|
1436
1731
|
}
|
|
1437
1732
|
}
|
|
1438
|
-
const
|
|
1733
|
+
const projectKey = devThemeKey(company, themeRoot.root);
|
|
1734
|
+
const theme = opts.theme ? await ensureDevTheme(api, projectKey, opts.theme) : await ensureDevTheme(api, projectKey);
|
|
1439
1735
|
const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
|
|
1440
1736
|
let stop;
|
|
1441
1737
|
const cleanup = () => {
|
|
@@ -1735,6 +2031,90 @@ function createPullCommand() {
|
|
|
1735
2031
|
});
|
|
1736
2032
|
}
|
|
1737
2033
|
//#endregion
|
|
2034
|
+
//#region src/commands/lint.ts
|
|
2035
|
+
function sectionNameOf(relativePath) {
|
|
2036
|
+
const parts = relativePath.split(/[/\\]/);
|
|
2037
|
+
if (parts[0] === "templates" && parts[1] === "sections" && parts.length >= 3) return parts[2].replace(/\.liquid$/, "");
|
|
2038
|
+
return null;
|
|
2039
|
+
}
|
|
2040
|
+
function createLintCommand() {
|
|
2041
|
+
return new Command("lint").description("Validate theme files locally (read-only — no upload)").option("--root <path>", "Theme root directory", ".").option("--json", "Output results as compact JSON").action(async (opts) => {
|
|
2042
|
+
let rootPath = opts.root;
|
|
2043
|
+
if (rootPath === ".") {
|
|
2044
|
+
const workspace = findWorkspace();
|
|
2045
|
+
if (workspace) rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
|
|
2046
|
+
}
|
|
2047
|
+
const themeRoot = new ThemeRoot(rootPath);
|
|
2048
|
+
if (!themeRoot.isValid()) {
|
|
2049
|
+
const message = `'${rootPath}' does not look like a theme directory.`;
|
|
2050
|
+
if (opts.json) console.log(JSON.stringify({
|
|
2051
|
+
ok: false,
|
|
2052
|
+
error: message
|
|
2053
|
+
}));
|
|
2054
|
+
else console.error(message);
|
|
2055
|
+
process.exit(1);
|
|
2056
|
+
}
|
|
2057
|
+
const liquidFiles = themeRoot.files().filter((f) => f.isLiquid).map((f) => ({
|
|
2058
|
+
file: f,
|
|
2059
|
+
content: f.read()
|
|
2060
|
+
}));
|
|
2061
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
2062
|
+
const record = (path, diagnostic) => {
|
|
2063
|
+
const existing = byFile.get(path);
|
|
2064
|
+
if (existing) existing.push(diagnostic);
|
|
2065
|
+
else byFile.set(path, [diagnostic]);
|
|
2066
|
+
};
|
|
2067
|
+
for (const { file, content } of liquidFiles) {
|
|
2068
|
+
const blocksSchemaType = file.isTemplate ? "object" : "array";
|
|
2069
|
+
for (const diagnostic of validateSchemaText(content, { blocksSchemaType })) record(file.relativePath, diagnostic);
|
|
2070
|
+
}
|
|
2071
|
+
const existingSectionNames = /* @__PURE__ */ new Set();
|
|
2072
|
+
for (const { file } of liquidFiles) {
|
|
2073
|
+
const name = sectionNameOf(file.relativePath);
|
|
2074
|
+
if (name) existingSectionNames.add(name);
|
|
2075
|
+
}
|
|
2076
|
+
const referrers = liquidFiles.filter(({ file }) => sectionNameOf(file.relativePath) === null).map(({ file, content }) => ({
|
|
2077
|
+
path: file.relativePath,
|
|
2078
|
+
content
|
|
2079
|
+
}));
|
|
2080
|
+
for (const missing of findMissingSectionReferences(referrers, existingSectionNames)) record(missing.templatePath, missing.diagnostic);
|
|
2081
|
+
const results = [...byFile.entries()].map(([path, diagnostics]) => ({
|
|
2082
|
+
path,
|
|
2083
|
+
diagnostics
|
|
2084
|
+
})).sort((a, b) => a.path.localeCompare(b.path));
|
|
2085
|
+
let errors = 0;
|
|
2086
|
+
let warnings = 0;
|
|
2087
|
+
for (const { diagnostics } of results) for (const d of diagnostics) if (d.severity === "error") errors++;
|
|
2088
|
+
else warnings++;
|
|
2089
|
+
if (opts.json) console.log(JSON.stringify({
|
|
2090
|
+
ok: errors === 0,
|
|
2091
|
+
errors,
|
|
2092
|
+
warnings,
|
|
2093
|
+
filesChecked: liquidFiles.length,
|
|
2094
|
+
files: results
|
|
2095
|
+
}));
|
|
2096
|
+
else printText(results, errors, warnings, liquidFiles.length);
|
|
2097
|
+
process.exit(errors > 0 ? 1 : 0);
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
function plural(count, noun) {
|
|
2101
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
2102
|
+
}
|
|
2103
|
+
function printText(results, errors, warnings, filesChecked) {
|
|
2104
|
+
for (const { path, diagnostics } of results) {
|
|
2105
|
+
console.log(chalk.bold(path));
|
|
2106
|
+
for (const d of diagnostics) {
|
|
2107
|
+
const label = d.severity === "error" ? chalk.red("error".padEnd(7)) : chalk.yellow("warning".padEnd(7));
|
|
2108
|
+
const message = d.message.split("\n")[0];
|
|
2109
|
+
console.log(` ${label} ${message}`);
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
const suffix = `(${plural(filesChecked, "file")} checked)`;
|
|
2113
|
+
if (errors > 0) console.log(`\n${chalk.red(`✖ ${plural(errors, "error")}, ${plural(warnings, "warning")}`)} ${suffix}`);
|
|
2114
|
+
else if (warnings > 0) console.log(`\n${chalk.yellow(`⚠ ${plural(warnings, "warning")}`)} ${suffix}`);
|
|
2115
|
+
else console.log(`${chalk.green("✓ No problems found")} ${suffix}`);
|
|
2116
|
+
}
|
|
2117
|
+
//#endregion
|
|
1738
2118
|
//#region src/commands/init.ts
|
|
1739
2119
|
const DEFAULT_CLONE_URL = "git@github.com:fluid-commerce/base-theme.git";
|
|
1740
2120
|
const SAFE_NAME_RE = /^[a-zA-Z0-9_][a-zA-Z0-9._-]*$/;
|
|
@@ -1894,7 +2274,7 @@ async function selectTemplate(api, themeId, themeableType, onCancel) {
|
|
|
1894
2274
|
function createNavigateCommand() {
|
|
1895
2275
|
return new Command("navigate").description("Interactively navigate to a route in the dev server browser").option("--host <host>", "Dev server host", "127.0.0.1").option("--port <port>", "Dev server port", "9292").option("-t, --theme <id>", "Theme ID (defaults to active dev theme)").action(async (opts) => {
|
|
1896
2276
|
requireToken();
|
|
1897
|
-
const themeId = opts.theme ? Number(opts.theme) :
|
|
2277
|
+
const themeId = opts.theme ? Number(opts.theme) : getLastDevThemeId();
|
|
1898
2278
|
if (!themeId) {
|
|
1899
2279
|
console.error("No active dev theme. Run `fluid theme dev` first, or pass --theme <id>.");
|
|
1900
2280
|
process.exit(1);
|
|
@@ -1965,10 +2345,11 @@ function createNavigateCommand() {
|
|
|
1965
2345
|
//#endregion
|
|
1966
2346
|
//#region src/commands/theme.ts
|
|
1967
2347
|
function registerThemeCommand(ctx) {
|
|
1968
|
-
const cmd = new Command("theme").description("Theme developer workflow — dev server, push, pull, init");
|
|
2348
|
+
const cmd = new Command("theme").description("Theme developer workflow — dev server, push, pull, lint, init");
|
|
1969
2349
|
cmd.addCommand(createDevCommand());
|
|
1970
2350
|
cmd.addCommand(createPushCommand());
|
|
1971
2351
|
cmd.addCommand(createPullCommand());
|
|
2352
|
+
cmd.addCommand(createLintCommand());
|
|
1972
2353
|
cmd.addCommand(createInitCommand());
|
|
1973
2354
|
cmd.addCommand(createNavigateCommand());
|
|
1974
2355
|
ctx.program.addCommand(cmd);
|