@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/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
- constructor(message, status, data) {
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
- throw new ApiError(data.message || data.error_message || `${method} request failed`, response.status, data.errors || data);
102
- } else throw new ApiError(`${method} request failed with status ${response.status}`, response.status, null);
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")) try {
106
- return await response.json();
107
- } catch {
126
+ if (response.headers.get("content-type")?.includes("application/json")) {
127
+ const responseText = await response.text();
108
128
  try {
109
- return await response.text();
129
+ return JSON.parse(responseText);
110
130
  } catch {
111
- return null;
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 fetch(url, fetchOptions);
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 fetch(url, fetchOptions);
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 getPluginState() {
279
+ function getState() {
236
280
  return readConfig().plugins[PLUGIN_KEY] ?? {};
237
281
  }
238
- function setPluginState(updates) {
239
- updateConfig((config) => ({
240
- ...config,
241
- plugins: {
242
- ...config.plugins,
243
- [PLUGIN_KEY]: {
244
- ...config.plugins[PLUGIN_KEY] ?? {},
245
- ...updates
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 (typeof id === "string" && id.trim() === "") diagnostics.push({
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: `Invalid settings type: '${type}'`
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
- if (Array.isArray(settings)) diagnostics.push(...validateSettings(settings));
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) return findTheme(api, identifier);
1388
- const { devThemeId } = getPluginState();
1389
- if (devThemeId) try {
1390
- const body = await getApplicationTheme(api, devThemeId);
1391
- if (body.application_theme) {
1392
- console.log(`Using existing dev theme #${devThemeId}`);
1393
- return body.application_theme;
1394
- }
1395
- } catch {}
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
- setPluginState({
1402
- devThemeId: theme.id,
1403
- devThemeName: theme.name
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 theme = opts.theme ? await ensureDevTheme(api, opts.theme) : config ? await ensureDevTheme(api, String(config.themeId)) : await ensureDevTheme(api);
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) : getPluginState().devThemeId;
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);