@fluid-app/fluid-cli-theme-dev 0.1.15 → 0.1.17
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 +7 -5
- package/.turbo/turbo-typecheck.log +4 -0
- package/dist/index.mjs +436 -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,29 +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
121
|
const nestedError = typeof data.error === "object" && data.error !== null ? data.error.message : void 0;
|
|
102
|
-
throw new ApiError(data.message || data.error_message || (typeof nestedError === "string" ? nestedError : void 0) || `${method} request failed`, response.status, data.errors || data);
|
|
103
|
-
} else throw new ApiError(`${method} request failed with status ${response.status}`, response.status, null);
|
|
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);
|
|
104
124
|
}
|
|
105
125
|
if (response.status === 204 || response.headers.get("content-length") === "0") return null;
|
|
106
|
-
if (response.headers.get("content-type")?.includes("application/json"))
|
|
107
|
-
|
|
108
|
-
} catch {
|
|
126
|
+
if (response.headers.get("content-type")?.includes("application/json")) {
|
|
127
|
+
const responseText = await response.text();
|
|
109
128
|
try {
|
|
110
|
-
return
|
|
129
|
+
return JSON.parse(responseText);
|
|
111
130
|
} catch {
|
|
112
|
-
|
|
131
|
+
if (throwOnInvalidJson) throw new ApiError("Failed to parse response as JSON", response.status, null, headerRequestId);
|
|
132
|
+
return responseText ? responseText : null;
|
|
113
133
|
}
|
|
114
134
|
}
|
|
115
135
|
return null;
|
|
116
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
|
+
}
|
|
117
156
|
/**
|
|
118
157
|
* Main request function
|
|
119
158
|
*/
|
|
120
159
|
async function request(endpoint, options = {}) {
|
|
121
|
-
const { method = "GET", headers: customHeaders, params, body, signal } = options;
|
|
160
|
+
const { method = "GET", headers: customHeaders, params, body, signal, priority } = options;
|
|
122
161
|
const url = params ? buildUrl(endpoint, params) : joinUrl(endpoint);
|
|
123
162
|
const headers = await buildHeaders(customHeaders);
|
|
124
163
|
let response;
|
|
@@ -128,10 +167,12 @@ function createFetchClient(config) {
|
|
|
128
167
|
headers
|
|
129
168
|
};
|
|
130
169
|
if (credentials) fetchOptions.credentials = credentials;
|
|
170
|
+
if (cache) fetchOptions.cache = cache;
|
|
171
|
+
if (priority) fetchOptions.priority = priority;
|
|
131
172
|
const serializedBody = body && method !== "GET" ? JSON.stringify(body) : null;
|
|
132
173
|
if (serializedBody) fetchOptions.body = serializedBody;
|
|
133
174
|
if (signal) fetchOptions.signal = signal;
|
|
134
|
-
response = await
|
|
175
|
+
response = await fetchWithNetworkRetry(url, fetchOptions, signal);
|
|
135
176
|
} catch (networkError) {
|
|
136
177
|
throw new ApiError(`Network error: ${networkError instanceof Error ? networkError.message : "Unknown network error"}`, 0, null);
|
|
137
178
|
}
|
|
@@ -141,7 +182,7 @@ function createFetchClient(config) {
|
|
|
141
182
|
* Request with FormData (for file uploads)
|
|
142
183
|
*/
|
|
143
184
|
async function requestWithFormData(endpoint, formData, options = {}) {
|
|
144
|
-
const { method = "POST", headers: customHeaders, signal } = options;
|
|
185
|
+
const { method = "POST", headers: customHeaders, signal, priority } = options;
|
|
145
186
|
const url = joinUrl(endpoint);
|
|
146
187
|
const headers = await buildHeaders(customHeaders);
|
|
147
188
|
delete headers["Content-Type"];
|
|
@@ -153,8 +194,10 @@ function createFetchClient(config) {
|
|
|
153
194
|
body: formData
|
|
154
195
|
};
|
|
155
196
|
if (credentials) fetchOptions.credentials = credentials;
|
|
197
|
+
if (cache) fetchOptions.cache = cache;
|
|
198
|
+
if (priority) fetchOptions.priority = priority;
|
|
156
199
|
if (signal) fetchOptions.signal = signal;
|
|
157
|
-
response = await
|
|
200
|
+
response = await fetchWithNetworkRetry(url, fetchOptions, signal);
|
|
158
201
|
} catch (networkError) {
|
|
159
202
|
throw new ApiError(`Network error: ${networkError instanceof Error ? networkError.message : "Unknown network error"}`, 0, null);
|
|
160
203
|
}
|
|
@@ -233,20 +276,130 @@ function writeThemeConfig(themeRoot, config) {
|
|
|
233
276
|
//#endregion
|
|
234
277
|
//#region src/plugin-state.ts
|
|
235
278
|
const PLUGIN_KEY = "theme-dev";
|
|
236
|
-
function
|
|
279
|
+
function getState() {
|
|
237
280
|
return readConfig().plugins[PLUGIN_KEY] ?? {};
|
|
238
281
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
247
371
|
}
|
|
248
|
-
}
|
|
249
|
-
})
|
|
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;
|
|
250
403
|
}
|
|
251
404
|
//#endregion
|
|
252
405
|
//#region src/theme/mime-type.ts
|
|
@@ -300,6 +453,7 @@ function mimeTypeFor(ext) {
|
|
|
300
453
|
const VALID_SETTING_TYPES = Object.values({
|
|
301
454
|
"input": [
|
|
302
455
|
"text",
|
|
456
|
+
"plaintext",
|
|
303
457
|
"rich_text",
|
|
304
458
|
"richtext",
|
|
305
459
|
"textarea",
|
|
@@ -309,6 +463,7 @@ const VALID_SETTING_TYPES = Object.values({
|
|
|
309
463
|
],
|
|
310
464
|
"number_and_selection": [
|
|
311
465
|
"range",
|
|
466
|
+
"number",
|
|
312
467
|
"select",
|
|
313
468
|
"radio",
|
|
314
469
|
"checkbox"
|
|
@@ -316,6 +471,7 @@ const VALID_SETTING_TYPES = Object.values({
|
|
|
316
471
|
"visual_and_media": [
|
|
317
472
|
"color",
|
|
318
473
|
"color_background",
|
|
474
|
+
"font",
|
|
319
475
|
"font_picker",
|
|
320
476
|
"image",
|
|
321
477
|
"image_picker",
|
|
@@ -340,11 +496,13 @@ const VALID_SETTING_TYPES = Object.values({
|
|
|
340
496
|
"categories",
|
|
341
497
|
"blog",
|
|
342
498
|
"posts",
|
|
499
|
+
"post",
|
|
343
500
|
"enrollment",
|
|
344
501
|
"enrollments",
|
|
345
502
|
"enrollment_pack",
|
|
346
503
|
"forms",
|
|
347
504
|
"media",
|
|
505
|
+
"variant",
|
|
348
506
|
"link_list"
|
|
349
507
|
],
|
|
350
508
|
"resource_list": [
|
|
@@ -356,35 +514,71 @@ const VALID_SETTING_TYPES = Object.values({
|
|
|
356
514
|
"categories_list",
|
|
357
515
|
"posts_list",
|
|
358
516
|
"enrollment_list",
|
|
359
|
-
"enrollments_list"
|
|
517
|
+
"enrollments_list",
|
|
518
|
+
"blog_list",
|
|
519
|
+
"blogs_list",
|
|
520
|
+
"post_list",
|
|
521
|
+
"enrollment_packs_list"
|
|
360
522
|
]
|
|
361
523
|
}).flat();
|
|
362
524
|
//#endregion
|
|
363
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
|
+
}
|
|
364
534
|
function validateSettings(settings) {
|
|
365
535
|
const diagnostics = [];
|
|
366
536
|
const ids = /* @__PURE__ */ new Set();
|
|
367
537
|
for (let index = 0; index < settings.length; index++) {
|
|
368
538
|
const raw = settings[index];
|
|
369
539
|
const setting = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
370
|
-
const id = setting.id;
|
|
371
|
-
const type = setting.type;
|
|
372
|
-
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({
|
|
373
543
|
severity: "error",
|
|
374
|
-
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
|
+
}
|
|
375
551
|
});
|
|
376
552
|
else if (id && ids.has(id)) diagnostics.push({
|
|
377
553
|
severity: "error",
|
|
378
|
-
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
|
+
}
|
|
379
561
|
});
|
|
380
562
|
else if (id) ids.add(id);
|
|
381
563
|
if (!type) diagnostics.push({
|
|
382
564
|
severity: "error",
|
|
383
|
-
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
|
+
}
|
|
384
572
|
});
|
|
385
573
|
else if (!VALID_SETTING_TYPES.includes(type)) diagnostics.push({
|
|
386
574
|
severity: "error",
|
|
387
|
-
message:
|
|
575
|
+
message: invalidSettingTypeMessage(type),
|
|
576
|
+
target: {
|
|
577
|
+
kind: "setting",
|
|
578
|
+
index,
|
|
579
|
+
settingType: type,
|
|
580
|
+
field: "type"
|
|
581
|
+
}
|
|
388
582
|
});
|
|
389
583
|
}
|
|
390
584
|
return diagnostics;
|
|
@@ -397,23 +591,50 @@ function validateBlocks(blocks) {
|
|
|
397
591
|
for (let index = 0; index < blocks.length; index++) {
|
|
398
592
|
const raw = blocks[index];
|
|
399
593
|
const block = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
400
|
-
const type = block.type;
|
|
401
|
-
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;
|
|
402
596
|
const settings = block.settings;
|
|
403
597
|
if (!type) diagnostics.push({
|
|
404
598
|
severity: "error",
|
|
405
|
-
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
|
+
}
|
|
406
605
|
});
|
|
407
606
|
else if (types.has(type)) diagnostics.push({
|
|
408
607
|
severity: "warning",
|
|
409
|
-
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
|
+
}
|
|
410
615
|
});
|
|
411
616
|
else types.add(type);
|
|
412
617
|
if (!name && type !== "@app" && type !== "@theme" && !(!name && !settings)) diagnostics.push({
|
|
413
618
|
severity: "error",
|
|
414
|
-
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
|
+
}
|
|
415
636
|
});
|
|
416
|
-
|
|
637
|
+
else diagnostics.push(...validateSettings(settings));
|
|
417
638
|
if (Array.isArray(block.blocks)) diagnostics.push(...validateBlocks(block.blocks));
|
|
418
639
|
}
|
|
419
640
|
return diagnostics;
|
|
@@ -515,6 +736,68 @@ function validateSchemaText(text, options) {
|
|
|
515
736
|
return diagnostics;
|
|
516
737
|
}
|
|
517
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
|
|
518
801
|
//#region src/theme/file.ts
|
|
519
802
|
var ThemeFile = class {
|
|
520
803
|
absolutePath;
|
|
@@ -1384,24 +1667,35 @@ function resolveThemeRootFromCwd(workspace) {
|
|
|
1384
1667
|
}
|
|
1385
1668
|
//#endregion
|
|
1386
1669
|
//#region src/commands/dev.ts
|
|
1387
|
-
async function ensureDevTheme(api, identifier) {
|
|
1388
|
-
if (identifier)
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
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
|
+
}
|
|
1397
1691
|
const { hostname } = await import("node:os");
|
|
1398
1692
|
const theme = (await createApplicationTheme(api, { application_theme: {
|
|
1399
1693
|
name: `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50),
|
|
1400
1694
|
status: "development"
|
|
1401
1695
|
} })).application_theme;
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1696
|
+
setDevTheme(projectKey, {
|
|
1697
|
+
id: theme.id,
|
|
1698
|
+
name: theme.name
|
|
1405
1699
|
});
|
|
1406
1700
|
console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
|
|
1407
1701
|
return theme;
|
|
@@ -1436,7 +1730,8 @@ function createDevCommand() {
|
|
|
1436
1730
|
process.exit(1);
|
|
1437
1731
|
}
|
|
1438
1732
|
}
|
|
1439
|
-
const
|
|
1733
|
+
const projectKey = devThemeKey(company, themeRoot.root);
|
|
1734
|
+
const theme = opts.theme ? await ensureDevTheme(api, projectKey, opts.theme) : await ensureDevTheme(api, projectKey);
|
|
1440
1735
|
const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
|
|
1441
1736
|
let stop;
|
|
1442
1737
|
const cleanup = () => {
|
|
@@ -1736,6 +2031,90 @@ function createPullCommand() {
|
|
|
1736
2031
|
});
|
|
1737
2032
|
}
|
|
1738
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
|
|
1739
2118
|
//#region src/commands/init.ts
|
|
1740
2119
|
const DEFAULT_CLONE_URL = "git@github.com:fluid-commerce/base-theme.git";
|
|
1741
2120
|
const SAFE_NAME_RE = /^[a-zA-Z0-9_][a-zA-Z0-9._-]*$/;
|
|
@@ -1895,7 +2274,7 @@ async function selectTemplate(api, themeId, themeableType, onCancel) {
|
|
|
1895
2274
|
function createNavigateCommand() {
|
|
1896
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) => {
|
|
1897
2276
|
requireToken();
|
|
1898
|
-
const themeId = opts.theme ? Number(opts.theme) :
|
|
2277
|
+
const themeId = opts.theme ? Number(opts.theme) : getLastDevThemeId();
|
|
1899
2278
|
if (!themeId) {
|
|
1900
2279
|
console.error("No active dev theme. Run `fluid theme dev` first, or pass --theme <id>.");
|
|
1901
2280
|
process.exit(1);
|
|
@@ -1966,10 +2345,11 @@ function createNavigateCommand() {
|
|
|
1966
2345
|
//#endregion
|
|
1967
2346
|
//#region src/commands/theme.ts
|
|
1968
2347
|
function registerThemeCommand(ctx) {
|
|
1969
|
-
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");
|
|
1970
2349
|
cmd.addCommand(createDevCommand());
|
|
1971
2350
|
cmd.addCommand(createPushCommand());
|
|
1972
2351
|
cmd.addCommand(createPullCommand());
|
|
2352
|
+
cmd.addCommand(createLintCommand());
|
|
1973
2353
|
cmd.addCommand(createInitCommand());
|
|
1974
2354
|
cmd.addCommand(createNavigateCommand());
|
|
1975
2355
|
ctx.program.addCommand(cmd);
|