@fluid-app/fluid-cli-theme-dev 0.1.15 → 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,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")) try {
107
- return await response.json();
108
- } catch {
126
+ if (response.headers.get("content-type")?.includes("application/json")) {
127
+ const responseText = await response.text();
109
128
  try {
110
- return await response.text();
129
+ return JSON.parse(responseText);
111
130
  } catch {
112
- return null;
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 fetch(url, fetchOptions);
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 fetch(url, fetchOptions);
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 getPluginState() {
279
+ function getState() {
237
280
  return readConfig().plugins[PLUGIN_KEY] ?? {};
238
281
  }
239
- function setPluginState(updates) {
240
- updateConfig((config) => ({
241
- ...config,
242
- plugins: {
243
- ...config.plugins,
244
- [PLUGIN_KEY]: {
245
- ...config.plugins[PLUGIN_KEY] ?? {},
246
- ...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
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 (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({
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: `Invalid settings type: '${type}'`
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
- if (Array.isArray(settings)) diagnostics.push(...validateSettings(settings));
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) return findTheme(api, identifier);
1389
- const { devThemeId } = getPluginState();
1390
- if (devThemeId) try {
1391
- const body = await getApplicationTheme(api, devThemeId);
1392
- if (body.application_theme) {
1393
- console.log(`Using existing dev theme #${devThemeId}`);
1394
- return body.application_theme;
1395
- }
1396
- } 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
+ }
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
- setPluginState({
1403
- devThemeId: theme.id,
1404
- devThemeName: theme.name
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 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);
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) : getPluginState().devThemeId;
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);