vite-plugin-react-shopify 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +14 -11
  2. package/dist/index.js +456 -370
  3. package/package.json +12 -12
package/dist/index.d.ts CHANGED
@@ -24,14 +24,14 @@ interface ImportMapOptions {
24
24
  react?: string;
25
25
  reactDomClient?: string;
26
26
  }
27
- type ShopifyBlockType = "template" | "section" | "block" | "snippet";
28
- type SettingValue = string | number | boolean;
29
- type InputSettings = Record<string, SettingValue>;
27
+
30
28
  interface BaseSettingSchema {
31
29
  id: string;
32
30
  label: string;
33
31
  info?: string;
34
32
  }
33
+ type SettingValue = string | number | boolean;
34
+ type InputSettings = Record<string, SettingValue>;
35
35
  interface CheckboxSetting extends BaseSettingSchema {
36
36
  type: "checkbox";
37
37
  default?: boolean;
@@ -230,9 +230,18 @@ type EmptyDefaultsExist<T extends readonly any[]> = true extends {
230
230
  [K in keyof T]: IsEmptyStringDefault<T[K]>;
231
231
  }[number] ? true : false;
232
232
  type AssertNoEmptyDefaults<T extends readonly SettingSchema[]> = EmptyDefaultsExist<T> extends true ? never : true;
233
+ type ValueForType<T extends string> = T extends "checkbox" ? boolean : T extends "number" | "range" ? number : string;
234
+ type InferSettings<T extends readonly {
235
+ type: string;
236
+ id: string;
237
+ }[]> = {
238
+ [K in T[number] as K["id"]]: ValueForType<K["type"]>;
239
+ };
240
+
241
+ type ShopifyBlockType = "template" | "section" | "block" | "snippet";
233
242
  interface ShopifyMeta {
234
243
  type?: ShopifyBlockType;
235
- name: string;
244
+ name?: string;
236
245
  tag?: string;
237
246
  class?: string;
238
247
  limit?: number;
@@ -262,6 +271,7 @@ interface PresetBlock {
262
271
  settings?: InputSettings;
263
272
  blocks?: PresetBlock[];
264
273
  }
274
+
265
275
  interface SSGEntry {
266
276
  filePath: string;
267
277
  componentName: string;
@@ -269,13 +279,6 @@ interface SSGEntry {
269
279
  targetType: ShopifyBlockType;
270
280
  meta: Required<Pick<ShopifyMeta, "name">> & ShopifyMeta;
271
281
  }
272
- type ValueForType<T extends string> = T extends "checkbox" ? boolean : T extends "number" | "range" ? number : string;
273
- type InferSettings<T extends readonly {
274
- type: string;
275
- id: string;
276
- }[]> = {
277
- [K in T[number] as K["id"]]: ValueForType<K["type"]>;
278
- };
279
282
 
280
283
  declare const vitePluginShopify: (options?: Options) => Plugin[];
281
284
 
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // src/plugin/options.ts
1
+ // src/core/options.ts
2
2
  import path from "path";
3
3
  var defaultPrefix = {
4
4
  template: "page.react-",
@@ -45,7 +45,7 @@ var resolveOptions = (options = {}) => {
45
45
  };
46
46
  };
47
47
 
48
- // src/plugin/logger.ts
48
+ // src/core/logger.ts
49
49
  import createDebugger from "debug";
50
50
  var NAMESPACE = "vite-plugin-shopify";
51
51
  var _debugEnabled = false;
@@ -67,7 +67,7 @@ function logger(ns) {
67
67
  };
68
68
  }
69
69
 
70
- // src/plugin/config.ts
70
+ // src/core/config.ts
71
71
  import path2 from "path";
72
72
  var log = logger("config");
73
73
  function isWatchMode() {
@@ -146,11 +146,11 @@ function shopifyConfig(options) {
146
146
  };
147
147
  }
148
148
 
149
- // src/plugin/entries.ts
149
+ // src/core/entries.ts
150
150
  import path4 from "path";
151
151
  import { normalizePath as normalizePath2 } from "vite";
152
152
 
153
- // src/plugin/ssg/scanner.ts
153
+ // src/ssg/scanner.ts
154
154
  import path3 from "path";
155
155
  import glob from "fast-glob";
156
156
  import { normalizePath } from "vite";
@@ -177,7 +177,7 @@ function scanEntries(options) {
177
177
  componentName,
178
178
  kebabName,
179
179
  targetType,
180
- meta: { name: componentName }
180
+ meta: { name: deriveName(fileName) }
181
181
  });
182
182
  }
183
183
  }
@@ -186,8 +186,64 @@ function scanEntries(options) {
186
186
  function toKebabCase(str) {
187
187
  return str.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
188
188
  }
189
+ function deriveName(fileName) {
190
+ const readable = fileName.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/([A-Z])([A-Z][a-z])/g, "$1 $2").replace(/[-_]/g, " ").replace(/\s+/g, " ").trim();
191
+ return readable.length > 25 ? readable.slice(0, 25) : readable;
192
+ }
193
+
194
+ // src/core/entry-template.ts
195
+ function generateEntryModule(entry, componentRel) {
196
+ const { kebabName } = entry;
197
+ return [
198
+ `import { createElement } from 'react'`,
199
+ `import Component from '~/${componentRel}'`,
200
+ `import { hydrateRoot } from 'react-dom/client'`,
201
+ `import { LiquidDataProvider } from 'vite-plugin-react-shopify/runtime'`,
202
+ ``,
203
+ `const SELECTOR = '[data-ssg-component="${kebabName}"]'`,
204
+ `const roots = new Map()`,
205
+ ``,
206
+ `function readLiquidData(el) {`,
207
+ ` const script = el.querySelector(':scope > script[data-ssg-liquid]')`,
208
+ ` if (!script) return {}`,
209
+ ` try { return JSON.parse(script.textContent || '{}') } catch { return {} }`,
210
+ `}`,
211
+ ``,
212
+ `function hydrate(el) {`,
213
+ ` const h = el.querySelector(':scope > [data-ssg-hydrate]') || (el.matches('[data-ssg-hydrate]') ? el : null)`,
214
+ ` if (!h || roots.has(h)) return`,
215
+ ` const liquidData = readLiquidData(el)`,
216
+ ` roots.set(h, hydrateRoot(h, createElement(LiquidDataProvider, { value: liquidData }, createElement(Component))))`,
217
+ `}`,
218
+ ``,
219
+ `function unmount(el) {`,
220
+ ` const h = el.querySelector(':scope > [data-ssg-hydrate]') || (el.matches('[data-ssg-hydrate]') ? el : null)`,
221
+ ` if (h && roots.has(h)) { roots.get(h).unmount(); roots.delete(h) }`,
222
+ `}`,
223
+ ``,
224
+ `function scan(target) {`,
225
+ ` if (target.matches?.(SELECTOR)) hydrate(target)`,
226
+ ` target.querySelectorAll(SELECTOR).forEach(hydrate)`,
227
+ `}`,
228
+ ``,
229
+ `function sweep(target) {`,
230
+ ` if (target.matches?.(SELECTOR)) unmount(target)`,
231
+ ` target.querySelectorAll(SELECTOR).forEach(unmount)`,
232
+ `}`,
233
+ ``,
234
+ `scan(document)`,
235
+ ``,
236
+ `document.addEventListener('shopify:section:load', (e) => {`,
237
+ ` scan(e.target)`,
238
+ `})`,
239
+ ``,
240
+ `document.addEventListener('shopify:section:unload', (e) => {`,
241
+ ` sweep(e.target)`,
242
+ `})`
243
+ ].join("\n");
244
+ }
189
245
 
190
- // src/plugin/entries.ts
246
+ // src/core/entries.ts
191
247
  var log2 = logger("entries");
192
248
  function shopifyEntries(options) {
193
249
  let entries = [];
@@ -223,67 +279,250 @@ function shopifyEntries(options) {
223
279
  if (!entry) return;
224
280
  const sourceDir = path4.resolve(options.themeRoot, options.sourceCodeDir);
225
281
  const componentRel = normalizePath2(path4.relative(sourceDir, entry.filePath));
226
- return [
227
- `import { createElement } from 'react'`,
228
- `import Component from '~/${componentRel}'`,
229
- `import { hydrateRoot } from 'react-dom/client'`,
230
- `import { LiquidDataProvider } from 'vite-plugin-react-shopify/runtime'`,
231
- ``,
232
- `const SELECTOR = '[data-ssg-component="${kebabName}"]'`,
233
- `const roots = new Map()`,
234
- ``,
235
- `function readLiquidData(el) {`,
236
- ` const script = el.querySelector(':scope > script[data-ssg-liquid]')`,
237
- ` if (!script) return {}`,
238
- ` try { return JSON.parse(script.textContent || '{}') } catch { return {} }`,
239
- `}`,
240
- ``,
241
- `function hydrate(el) {`,
242
- ` const h = el.querySelector(':scope > [data-ssg-hydrate]') || (el.matches('[data-ssg-hydrate]') ? el : null)`,
243
- ` if (!h || roots.has(h)) return`,
244
- ` const liquidData = readLiquidData(el)`,
245
- ` roots.set(h, hydrateRoot(h, createElement(LiquidDataProvider, { value: liquidData }, createElement(Component))))`,
246
- `}`,
247
- ``,
248
- `function unmount(el) {`,
249
- ` const h = el.querySelector(':scope > [data-ssg-hydrate]') || (el.matches('[data-ssg-hydrate]') ? el : null)`,
250
- ` if (h && roots.has(h)) { roots.get(h).unmount(); roots.delete(h) }`,
251
- `}`,
252
- ``,
253
- `function scan(target) {`,
254
- ` if (target.matches?.(SELECTOR)) hydrate(target)`,
255
- ` target.querySelectorAll(SELECTOR).forEach(hydrate)`,
256
- `}`,
257
- ``,
258
- `function sweep(target) {`,
259
- ` if (target.matches?.(SELECTOR)) unmount(target)`,
260
- ` target.querySelectorAll(SELECTOR).forEach(unmount)`,
261
- `}`,
262
- ``,
263
- `scan(document)`,
264
- ``,
265
- `document.addEventListener('shopify:section:load', (e) => {`,
266
- ` scan(e.target)`,
267
- `})`,
268
- ``,
269
- `document.addEventListener('shopify:section:unload', (e) => {`,
270
- ` sweep(e.target)`,
271
- `})`
272
- ].join("\n");
282
+ return generateEntryModule(entry, componentRel);
273
283
  }
274
284
  };
275
285
  }
276
286
 
277
- // src/plugin/ssg/index.ts
278
- import fs2 from "fs";
279
- import path7 from "path";
287
+ // src/ssg/index.ts
288
+ import fs4 from "fs";
289
+ import path10 from "path";
290
+
291
+ // src/ssg/compiler.ts
292
+ import fs3 from "fs";
293
+ import path9 from "path";
280
294
 
281
- // src/plugin/ssg/compiler.ts
295
+ // src/ssg/css-manager.ts
282
296
  import fs from "fs";
297
+ import path5 from "path";
298
+ var log3 = logger("ssg:css");
299
+ function collectCssFiles(manifestKey, manifest) {
300
+ const collected = /* @__PURE__ */ new Set();
301
+ const visited = /* @__PURE__ */ new Set();
302
+ collectCssFilesRecursive(manifestKey, manifest, collected, visited);
303
+ return [...collected];
304
+ }
305
+ function collectCssFilesRecursive(chunkKey, manifest, collected, visited) {
306
+ if (visited.has(chunkKey)) return;
307
+ visited.add(chunkKey);
308
+ const chunk = manifest[chunkKey];
309
+ if (!chunk) return;
310
+ if (chunk.css && Array.isArray(chunk.css)) {
311
+ for (const cssFile of chunk.css) {
312
+ collected.add(cssFile);
313
+ }
314
+ }
315
+ if (chunk.imports && Array.isArray(chunk.imports)) {
316
+ for (const imported of chunk.imports) {
317
+ collectCssFilesRecursive(imported, manifest, collected, visited);
318
+ }
319
+ }
320
+ }
321
+ function readCssFileContents(cssFiles, buildDir, themeRoot) {
322
+ const assetsDir = path5.resolve(themeRoot, buildDir);
323
+ return cssFiles.map((file) => {
324
+ try {
325
+ return fs.readFileSync(path5.join(assetsDir, file), "utf-8");
326
+ } catch {
327
+ return "";
328
+ }
329
+ }).filter(Boolean);
330
+ }
331
+ function getCssBaseName(cssFile) {
332
+ const name = cssFile.replace(/\.css$/, "");
333
+ const lastHyphen = name.lastIndexOf("-");
334
+ if (lastHyphen > 0) {
335
+ const possibleHash = name.slice(lastHyphen + 1);
336
+ if (/^[A-Za-z0-9_-]{8,}$/.test(possibleHash)) {
337
+ return name.slice(0, lastHyphen);
338
+ }
339
+ }
340
+ return name;
341
+ }
342
+ function analyzeCssDistribution(entries, manifest) {
343
+ const entryCssFiles = /* @__PURE__ */ new Map();
344
+ const cssRefCount = /* @__PURE__ */ new Map();
345
+ for (const entry of entries) {
346
+ const manifestKey = `shopify:entry:${entry.kebabName}`;
347
+ const files = collectCssFiles(manifestKey, manifest);
348
+ entryCssFiles.set(entry.kebabName, files);
349
+ for (const f of files) {
350
+ cssRefCount.set(f, (cssRefCount.get(f) || 0) + 1);
351
+ }
352
+ log3.debug("entry %s has %d CSS files", entry.kebabName, files.length);
353
+ }
354
+ return { entryCssFiles, cssRefCount };
355
+ }
356
+ function generateSharedCssSnippets(cssRefCount, options) {
357
+ const cssSnippetMap = /* @__PURE__ */ new Map();
358
+ for (const [cssFile, count] of cssRefCount) {
359
+ if (count > 1) {
360
+ const snippetName = `${options.ssg.cssPrefix}-${getCssBaseName(cssFile)}`;
361
+ cssSnippetMap.set(cssFile, snippetName);
362
+ const snippetPath = path5.join(
363
+ path5.resolve(options.themeRoot),
364
+ "snippets",
365
+ `${snippetName}.liquid`
366
+ );
367
+ const cssPath = path5.join(
368
+ path5.resolve(options.themeRoot, options.buildDir),
369
+ cssFile
370
+ );
371
+ try {
372
+ const cssContent = fs.readFileSync(cssPath, "utf-8");
373
+ fs.mkdirSync(path5.dirname(snippetPath), { recursive: true });
374
+ fs.writeFileSync(snippetPath, `{% stylesheet %}
375
+ ${cssContent.trim()}
376
+ {% endstylesheet %}
377
+ `);
378
+ log3.debug("generated shared CSS snippet %s (used by %d entries)", snippetName, count);
379
+ } catch {
380
+ log3.warn("failed to write CSS snippet for %s", cssFile);
381
+ }
382
+ }
383
+ }
384
+ return cssSnippetMap;
385
+ }
386
+ function categorizeCss(cssFiles, cssSnippetMap) {
387
+ const snippets = cssFiles.filter((f) => cssSnippetMap.has(f)).map((f) => cssSnippetMap.get(f));
388
+ const inlineFiles = cssFiles.filter((f) => !cssSnippetMap.has(f));
389
+ return { inline: inlineFiles, snippets };
390
+ }
391
+
392
+ // src/ssg/bundler.ts
393
+ import fs2 from "fs";
283
394
  import path6 from "path";
284
395
  import { createRequire } from "module";
285
396
 
286
- // src/plugin/ssg/post-process.ts
397
+ // src/ssg/hydration-fix.ts
398
+ var log4 = logger("hydration-fix");
399
+ function autoFixAdjacentText(source, filePath) {
400
+ let fixCount = 0;
401
+ const lines = source.split("\n");
402
+ const fixed = [];
403
+ for (let i = 0; i < lines.length; i++) {
404
+ const line = lines[i];
405
+ const replaced = line.replace(
406
+ /<(\w+)([^>]*?)>([^<]*?\{[^}]*\}[^<]*?)<\/\1>/g,
407
+ (match, tagName, attrs, content) => {
408
+ const trimmed = content.trim();
409
+ if (!needsFix(trimmed)) return match;
410
+ fixCount++;
411
+ const tpl = trimmed.replace(/\{([^}]+)\}/g, "${$1}");
412
+ return `<${tagName}${attrs}>{\`${tpl}\`}</${tagName}>`;
413
+ }
414
+ );
415
+ fixed.push(replaced);
416
+ }
417
+ if (fixCount > 0) {
418
+ log4.warn(
419
+ `auto-fixed ${fixCount} adjacent text+expression issue(s) in ${filePath}`
420
+ );
421
+ }
422
+ return { result: fixed.join("\n"), fixCount };
423
+ }
424
+ function needsFix(content) {
425
+ const trimmed = content.trim();
426
+ if (!trimmed) return false;
427
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
428
+ const inner = trimmed.slice(1, -1).trim();
429
+ if (inner.startsWith("`") && inner.endsWith("`")) return false;
430
+ if (inner.length > 0 && !/<[a-zA-Z]/.test(inner)) return false;
431
+ }
432
+ if (!/\{/.test(trimmed)) return false;
433
+ if (/<[a-zA-Z]/.test(trimmed)) return false;
434
+ return true;
435
+ }
436
+
437
+ // src/ssg/bundler.ts
438
+ var log5 = logger("ssg:bundler");
439
+ async function bundleEntry(entry, projectRoot, sourceDir) {
440
+ const projectRequire = createRequire(path6.join(projectRoot, "package.json"));
441
+ let esbuild;
442
+ try {
443
+ esbuild = projectRequire("esbuild");
444
+ } catch {
445
+ log5.warn("esbuild not found, skipping SSR for %s", entry.kebabName);
446
+ return null;
447
+ }
448
+ const sourceCode = fs2.readFileSync(entry.filePath, "utf-8");
449
+ const { result: fixedSource, fixCount } = autoFixAdjacentText(sourceCode, entry.filePath);
450
+ const finalSource = fixCount > 0 ? fixedSource : sourceCode;
451
+ const ts = Date.now();
452
+ const tmpDir = path6.join(sourceDir, ".ssg-tmp");
453
+ fs2.mkdirSync(tmpDir, { recursive: true });
454
+ const tmpFile = path6.join(tmpDir, `.ssg-entry-${ts}.mjs`);
455
+ log5.debug("bundling %s via esbuild", entry.kebabName);
456
+ const startBundled = Date.now();
457
+ await esbuild.build({
458
+ stdin: {
459
+ contents: finalSource,
460
+ resolveDir: path6.dirname(entry.filePath),
461
+ loader: path6.extname(entry.filePath).slice(1)
462
+ },
463
+ outfile: tmpFile,
464
+ bundle: true,
465
+ format: "esm",
466
+ jsx: "automatic",
467
+ platform: "node",
468
+ external: [
469
+ "react",
470
+ "react-dom",
471
+ "react-dom/*",
472
+ "vite-plugin-react-shopify",
473
+ "vite-plugin-react-shopify/*"
474
+ ],
475
+ write: true,
476
+ allowOverwrite: true,
477
+ plugins: [
478
+ {
479
+ name: "ssg-hydration-fix",
480
+ setup(build) {
481
+ build.onLoad({ filter: /\.(tsx|jsx)$/ }, (args) => {
482
+ try {
483
+ const source = fs2.readFileSync(args.path, "utf-8");
484
+ const { result, fixCount: fixCount2 } = autoFixAdjacentText(source, args.path);
485
+ if (fixCount2 > 0) {
486
+ return { contents: result, loader: args.path.endsWith(".tsx") ? "tsx" : "jsx" };
487
+ }
488
+ } catch {
489
+ }
490
+ return void 0;
491
+ });
492
+ }
493
+ },
494
+ {
495
+ name: "ssg-strip-css",
496
+ setup(build) {
497
+ build.onResolve({ filter: /\.module\.css$/ }, (args) => ({
498
+ namespace: "ssg-css-module",
499
+ path: args.path
500
+ }));
501
+ build.onResolve({ filter: /\.css$/ }, (args) => ({
502
+ namespace: "ssg-css-plain",
503
+ path: args.path
504
+ }));
505
+ build.onLoad({ filter: /.*/, namespace: "ssg-css-module" }, () => ({
506
+ contents: "export default new Proxy({},{get:(_,k)=>k});",
507
+ loader: "js"
508
+ }));
509
+ build.onLoad({ filter: /.*/, namespace: "ssg-css-plain" }, () => ({
510
+ contents: "",
511
+ loader: "js"
512
+ }));
513
+ }
514
+ }
515
+ ]
516
+ });
517
+ log5.debug("esbuild bundle took %dms", Date.now() - startBundled);
518
+ return { tmpFile };
519
+ }
520
+
521
+ // src/ssg/renderer.ts
522
+ import path7 from "path";
523
+ import { createRequire as createRequire2 } from "module";
524
+
525
+ // src/ssg/post-process.ts
287
526
  var VOID_ELEMENTS = /<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)([^>]*)\/>/g;
288
527
  function normalizeVoidElements(html) {
289
528
  return html.replace(VOID_ELEMENTS, "<$1$2>");
@@ -298,21 +537,63 @@ function unwrapHtmlEntities(html) {
298
537
  return html.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#x27;/g, "'");
299
538
  }
300
539
 
301
- // src/plugin/ssg/liquid.ts
302
- import path5 from "path";
540
+ // src/ssg/renderer.ts
541
+ function pathToFileURL(filePath) {
542
+ const absPath = path7.resolve(filePath);
543
+ if (process.platform === "win32") {
544
+ return "file:///" + absPath.replace(/\\/g, "/");
545
+ }
546
+ return "file://" + absPath;
547
+ }
548
+ var log6 = logger("ssg:renderer");
549
+ function renderEntry(tmpFile, entry, projectRoot) {
550
+ return import(pathToFileURL(tmpFile)).then((mod) => {
551
+ const Component = mod.default;
552
+ const shopifyMeta = mod.shopifyMeta;
553
+ if (!Component) {
554
+ log6.warn("No default export found in %s, skipping", entry.filePath);
555
+ return null;
556
+ }
557
+ if (shopifyMeta) {
558
+ entry.meta = { ...entry.meta, ...shopifyMeta, name: shopifyMeta.name ?? entry.meta.name };
559
+ }
560
+ const projectRequire = createRequire2(path7.join(projectRoot, "package.json"));
561
+ let createElement;
562
+ let renderToStaticMarkup;
563
+ try {
564
+ createElement = projectRequire("react").createElement;
565
+ renderToStaticMarkup = projectRequire("react-dom/server").renderToStaticMarkup;
566
+ } catch {
567
+ log6.warn("react/react-dom not found, skipping SSR for %s", entry.kebabName);
568
+ return null;
569
+ }
570
+ globalThis.__shopify_ssg_target = entry.targetType;
571
+ const trackedExpressions = /* @__PURE__ */ new Set();
572
+ globalThis.__shopify_ssg_liquid_track = trackedExpressions;
573
+ const element = createElement(Component);
574
+ let html = renderToStaticMarkup(element);
575
+ delete globalThis.__shopify_ssg_liquid_track;
576
+ html = normalizeVoidElements(html);
577
+ html = normalizeStyleAttributes(html);
578
+ html = unwrapHtmlEntities(html);
579
+ return { html, trackedExpressions, entryMeta: entry.meta };
580
+ });
581
+ }
582
+ function resolveScriptAsset(kebabName, manifest) {
583
+ const manifestKey = `shopify:entry:${kebabName}`;
584
+ const entryChunk = manifest[manifestKey];
585
+ if (!entryChunk) return null;
586
+ const file = entryChunk.file;
587
+ if (!file) return null;
588
+ return path7.basename(file);
589
+ }
303
590
 
304
- // src/plugin/ssg/schema-gen.ts
305
- var log3 = logger("schema-gen");
591
+ // src/ssg/schema.ts
306
592
  function serializeSetting(setting) {
307
593
  const s = { type: setting.type };
308
594
  if ("id" in setting) s.id = setting.id;
309
595
  if ("label" in setting) s.label = setting.label;
310
596
  if ("default" in setting && setting.default !== void 0) {
311
- if (setting.default === "") {
312
- log3.warn(
313
- `Setting "${"id" in setting ? setting.id : "(no id)"}" has empty string default. Use a non-empty string or remove the default.`
314
- );
315
- }
316
597
  s.default = setting.default;
317
598
  }
318
599
  if ("info" in setting && setting.info) s.info = setting.info;
@@ -375,7 +656,33 @@ ${json}
375
656
  `;
376
657
  }
377
658
 
378
- // src/plugin/ssg/liquid.ts
659
+ // src/ssg/liquid-paths.ts
660
+ import path8 from "path";
661
+ function getOutputPath(entry, options) {
662
+ const type = entry.meta.type ?? entry.targetType;
663
+ const dirName = typeToDir(type);
664
+ const fileName = resolveFileName(entry, type, options);
665
+ return path8.join(options.themeRoot, dirName, fileName);
666
+ }
667
+ function typeToDir(type) {
668
+ if (type === "snippet") return "snippets";
669
+ if (type === "block") return "blocks";
670
+ return `${type}s`;
671
+ }
672
+ function getAssetRelativePath(buildDir, filename) {
673
+ if (!buildDir.startsWith("assets/")) return filename;
674
+ const prefix = buildDir.slice("assets/".length);
675
+ return prefix ? `${prefix}/${filename}` : filename;
676
+ }
677
+ function resolveFileName(entry, type, options) {
678
+ if (options.outputName) {
679
+ return options.outputName.replace(/\{type\}/g, type).replace(/\{kebab\}/g, entry.kebabName).replace(/\{pascal\}/g, entry.componentName).replace(/\{target\}/g, entry.targetType) + ".liquid";
680
+ }
681
+ const prefix = options.prefix[type] ?? "react-";
682
+ return `${prefix}${entry.kebabName}.liquid`;
683
+ }
684
+
685
+ // src/ssg/liquid-assembler.ts
379
686
  var DISCLAIMER = "{% comment %}\n IMPORTANT: This file is automatically generated by vite-plugin-shopify.\n Do not attempt to modify this file directly, as any changes will be overwritten by the next build.\n{% endcomment %}\n";
380
687
  function assembleLiquidFile(html, entry, scriptAsset, cssContents, options, trackedExpressions = []) {
381
688
  const type = entry.meta.type ?? entry.targetType;
@@ -500,251 +807,88 @@ function buildSnippet(html, entry, trackedExpressions) {
500
807
  );
501
808
  return lines;
502
809
  }
503
- function getOutputPath(entry, options) {
504
- const type = entry.meta.type ?? entry.targetType;
505
- const dirName = typeToDir(type);
506
- const fileName = resolveFileName(entry, type, options);
507
- return path5.join(options.themeRoot, dirName, fileName);
508
- }
509
- function typeToDir(type) {
510
- if (type === "snippet") return "snippets";
511
- if (type === "block") return "blocks";
512
- return `${type}s`;
513
- }
514
- function getAssetRelativePath(buildDir, filename) {
515
- if (!buildDir.startsWith("assets/")) return filename;
516
- const prefix = buildDir.slice("assets/".length);
517
- return prefix ? `${prefix}/${filename}` : filename;
810
+
811
+ // src/validate/rules.ts
812
+ var MAX_NAME_LENGTH = 25;
813
+ function checkNameLength(meta, kebabName) {
814
+ if (meta.name.length > MAX_NAME_LENGTH) {
815
+ return `[${kebabName}] shopifyMeta.name "${meta.name}" is ${meta.name.length} chars (Shopify limit: ${MAX_NAME_LENGTH})`;
816
+ }
817
+ return null;
518
818
  }
519
- function resolveFileName(entry, type, options) {
520
- if (options.outputName) {
521
- return options.outputName.replace(/\{type\}/g, type).replace(/\{kebab\}/g, entry.kebabName).replace(/\{pascal\}/g, entry.componentName).replace(/\{target\}/g, entry.targetType) + ".liquid";
819
+ function checkEmptyStringDefault(setting) {
820
+ if (setting.default === "") {
821
+ const label = "id" in setting && setting.id ? setting.id : "(no id)";
822
+ return `Setting "${label}" (type: ${setting.type}) has empty string default`;
522
823
  }
523
- const prefix = options.prefix[type] ?? "react-";
524
- return `${prefix}${entry.kebabName}.liquid`;
824
+ return null;
525
825
  }
526
826
 
527
- // src/plugin/ssg/hydration-fix.ts
528
- var log4 = logger("hydration-fix");
529
- function autoFixAdjacentText(source, filePath) {
530
- let fixCount = 0;
531
- const lines = source.split("\n");
532
- const fixed = [];
533
- for (let i = 0; i < lines.length; i++) {
534
- const line = lines[i];
535
- const replaced = line.replace(
536
- /<(\w+)([^>]*?)>([^<]*?\{[^}]*\}[^<]*?)<\/\1>/g,
537
- (match, tagName, attrs, content) => {
538
- const trimmed = content.trim();
539
- if (!needsFix(trimmed)) return match;
540
- fixCount++;
541
- const tpl = trimmed.replace(/\{([^}]+)\}/g, "${$1}");
542
- return `<${tagName}${attrs}>{\`${tpl}\`}</${tagName}>`;
543
- }
544
- );
545
- fixed.push(replaced);
546
- }
547
- if (fixCount > 0) {
548
- log4.warn(
549
- `auto-fixed ${fixCount} adjacent text+expression issue(s) in ${filePath}`
550
- );
827
+ // src/validate/index.ts
828
+ var log7 = logger("validate");
829
+ function validateShopifyMeta(meta, context) {
830
+ const warnings = [];
831
+ const nameWarning = checkNameLength(meta, context.kebabName);
832
+ if (nameWarning) warnings.push(nameWarning);
833
+ if (meta.settings) {
834
+ for (const s of meta.settings) {
835
+ const w = checkEmptyStringDefault(s);
836
+ if (w) warnings.push(w);
837
+ }
551
838
  }
552
- return { result: fixed.join("\n"), fixCount };
553
- }
554
- function needsFix(content) {
555
- const trimmed = content.trim();
556
- if (!trimmed) return false;
557
- if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
558
- const inner = trimmed.slice(1, -1).trim();
559
- if (inner.startsWith("`") && inner.endsWith("`")) return false;
560
- if (inner.length > 0 && !/<[a-zA-Z]/.test(inner)) return false;
839
+ for (const w of warnings) {
840
+ log7.warn(w);
561
841
  }
562
- if (!/\{/.test(trimmed)) return false;
563
- if (/<[a-zA-Z]/.test(trimmed)) return false;
564
- return true;
842
+ return warnings;
565
843
  }
566
844
 
567
- // src/plugin/ssg/compiler.ts
568
- var log5 = logger("ssg:compiler");
845
+ // src/ssg/compiler.ts
846
+ var log8 = logger("ssg:compiler");
569
847
  async function compileAllEntries(options, manifest) {
570
848
  const entries = scanEntries(options);
571
849
  if (entries.length === 0) return;
572
- log5.debug("found %d entries to compile", entries.length);
573
- const projectRoot = path6.resolve(options.themeRoot);
574
- const sourceDir = path6.resolve(options.themeRoot, options.sourceCodeDir);
575
- const entryCssFiles = /* @__PURE__ */ new Map();
576
- const cssRefCount = /* @__PURE__ */ new Map();
577
- for (const entry of entries) {
578
- const manifestKey = `shopify:entry:${entry.kebabName}`;
579
- const files = collectCssFiles(manifestKey, manifest);
580
- entryCssFiles.set(entry.kebabName, files);
581
- for (const f of files) {
582
- cssRefCount.set(f, (cssRefCount.get(f) || 0) + 1);
583
- }
584
- log5.debug("entry %s has %d CSS files", entry.kebabName, files.length);
585
- }
586
- const cssSnippetMap = /* @__PURE__ */ new Map();
587
- for (const [cssFile, count] of cssRefCount) {
588
- if (count > 1) {
589
- const snippetName = `${options.ssg.cssPrefix}-${getCssBaseName(cssFile)}`;
590
- cssSnippetMap.set(cssFile, snippetName);
591
- const snippetPath = path6.join(
592
- path6.resolve(options.themeRoot),
593
- "snippets",
594
- `${snippetName}.liquid`
595
- );
596
- const cssPath = path6.join(
597
- path6.resolve(options.themeRoot, options.buildDir),
598
- cssFile
599
- );
600
- try {
601
- const cssContent = fs.readFileSync(cssPath, "utf-8");
602
- fs.mkdirSync(path6.dirname(snippetPath), { recursive: true });
603
- fs.writeFileSync(snippetPath, `{% stylesheet %}
604
- ${cssContent.trim()}
605
- {% endstylesheet %}
606
- `);
607
- log5.debug("generated shared CSS snippet %s (used by %d entries)", snippetName, count);
608
- } catch {
609
- log5.warn("failed to write CSS snippet for %s", cssFile);
610
- }
611
- }
612
- }
850
+ log8.debug("found %d entries to compile", entries.length);
851
+ const projectRoot = path9.resolve(options.themeRoot);
852
+ const sourceDir = path9.resolve(options.themeRoot, options.sourceCodeDir);
853
+ const { entryCssFiles, cssRefCount } = analyzeCssDistribution(entries, manifest);
854
+ const cssSnippetMap = generateSharedCssSnippets(cssRefCount, options);
613
855
  for (const entry of entries) {
614
856
  try {
615
- const cssFiles = entryCssFiles.get(entry.kebabName) || [];
616
- const cssSnippets = cssFiles.filter((f) => cssSnippetMap.has(f)).map((f) => cssSnippetMap.get(f));
617
- const cssInlineFiles = cssFiles.filter((f) => !cssSnippetMap.has(f));
618
- const cssInline = readCssFileContents(cssInlineFiles, options.buildDir, options.themeRoot);
619
- log5.debug(
620
- "compiling %s (type=%s, css inline=%d, css snippets=%d)",
621
- entry.kebabName,
622
- entry.targetType,
623
- cssInline.length,
624
- cssSnippets.length
625
- );
626
- await compileEntry(entry, options, manifest, projectRoot, sourceDir, cssInline, cssSnippets);
857
+ await compileEntry(entry, options, manifest, projectRoot, sourceDir, entryCssFiles, cssSnippetMap);
627
858
  } catch (err) {
628
- log5.error("Failed to compile %s:", entry.filePath, err);
859
+ log8.error("Failed to compile %s:", entry.filePath, err);
629
860
  }
630
861
  }
631
- log5.info("Compiled %d entries", entries.length);
632
- const tmpDir = path6.join(sourceDir, ".ssg-tmp");
862
+ log8.info("Compiled %d entries", entries.length);
863
+ const tmpDir = path9.join(sourceDir, ".ssg-tmp");
633
864
  try {
634
- fs.rmSync(tmpDir, { recursive: true, force: true });
865
+ fs3.rmSync(tmpDir, { recursive: true, force: true });
635
866
  } catch {
636
867
  }
637
868
  }
638
- async function compileEntry(entry, options, manifest, projectRoot, sourceDir, cssInline, cssSnippets) {
639
- const projectRequire = createRequire(path6.join(projectRoot, "package.json"));
640
- let createElement;
641
- let renderToStaticMarkup;
642
- try {
643
- createElement = projectRequire("react").createElement;
644
- renderToStaticMarkup = projectRequire("react-dom/server").renderToStaticMarkup;
645
- } catch {
646
- log5.warn("react/react-dom not found, skipping SSR for %s", entry.kebabName);
647
- return;
648
- }
649
- const sourceCode = fs.readFileSync(entry.filePath, "utf-8");
650
- const { result: fixedSource, fixCount } = autoFixAdjacentText(sourceCode, entry.filePath);
651
- const finalSource = fixCount > 0 ? fixedSource : sourceCode;
652
- let esbuild;
653
- try {
654
- esbuild = projectRequire("esbuild");
655
- } catch {
656
- log5.warn("esbuild not found, skipping SSR for %s", entry.kebabName);
657
- return;
658
- }
659
- const ts = Date.now();
660
- const tmpDir = path6.join(sourceDir, ".ssg-tmp");
661
- fs.mkdirSync(tmpDir, { recursive: true });
662
- const tmpFile = path6.join(tmpDir, `.ssg-entry-${ts}.mjs`);
663
- log5.debug("bundling %s via esbuild", entry.kebabName);
664
- const startBundled = Date.now();
665
- await esbuild.build({
666
- stdin: {
667
- contents: finalSource,
668
- resolveDir: path6.dirname(entry.filePath),
669
- loader: path6.extname(entry.filePath).slice(1)
670
- },
671
- outfile: tmpFile,
672
- bundle: true,
673
- format: "esm",
674
- jsx: "automatic",
675
- platform: "node",
676
- external: [
677
- "react",
678
- "react-dom",
679
- "react-dom/*",
680
- "vite-plugin-react-shopify",
681
- "vite-plugin-react-shopify/*"
682
- ],
683
- write: true,
684
- allowOverwrite: true,
685
- plugins: [
686
- {
687
- name: "ssg-hydration-fix",
688
- setup(build) {
689
- build.onLoad({ filter: /\.(tsx|jsx)$/ }, (args) => {
690
- try {
691
- const source = fs.readFileSync(args.path, "utf-8");
692
- const { result, fixCount: fixCount2 } = autoFixAdjacentText(source, args.path);
693
- if (fixCount2 > 0) {
694
- return { contents: result, loader: args.path.endsWith(".tsx") ? "tsx" : "jsx" };
695
- }
696
- } catch {
697
- }
698
- return void 0;
699
- });
700
- }
701
- },
702
- {
703
- name: "ssg-strip-css",
704
- setup(build) {
705
- build.onResolve({ filter: /\.module\.css$/ }, (args) => ({
706
- namespace: "ssg-css-module",
707
- path: args.path
708
- }));
709
- build.onResolve({ filter: /\.css$/ }, (args) => ({
710
- namespace: "ssg-css-plain",
711
- path: args.path
712
- }));
713
- build.onLoad({ filter: /.*/, namespace: "ssg-css-module" }, () => ({
714
- contents: "export default new Proxy({},{get:(_,k)=>k});",
715
- loader: "js"
716
- }));
717
- build.onLoad({ filter: /.*/, namespace: "ssg-css-plain" }, () => ({
718
- contents: "",
719
- loader: "js"
720
- }));
721
- }
722
- }
723
- ]
724
- });
725
- log5.debug("esbuild bundle took %dms", Date.now() - startBundled);
869
+ async function compileEntry(entry, options, manifest, projectRoot, sourceDir, entryCssFiles, cssSnippetMap) {
870
+ const bundleResult = await bundleEntry(entry, projectRoot, sourceDir);
871
+ if (!bundleResult) return;
726
872
  try {
727
- const mod = await import(pathToFileURL(tmpFile));
728
- const Component = mod.default;
729
- const shopifyMeta = mod.shopifyMeta;
730
- if (!Component) {
731
- log5.warn("No default export found in %s, skipping", entry.filePath);
732
- return;
733
- }
734
- if (shopifyMeta) {
735
- entry.meta = { ...entry.meta, ...shopifyMeta };
736
- }
737
- globalThis.__shopify_ssg_target = entry.targetType;
738
- const trackedExpressions = /* @__PURE__ */ new Set();
739
- globalThis.__shopify_ssg_liquid_track = trackedExpressions;
740
- const element = createElement(Component);
741
- let html = renderToStaticMarkup(element);
742
- delete globalThis.__shopify_ssg_liquid_track;
743
- html = normalizeVoidElements(html);
744
- html = normalizeStyleAttributes(html);
745
- html = unwrapHtmlEntities(html);
873
+ const renderResult = await renderEntry(bundleResult.tmpFile, entry, projectRoot);
874
+ if (!renderResult) return;
875
+ const { html, trackedExpressions } = renderResult;
876
+ validateShopifyMeta(entry.meta, { kebabName: entry.kebabName, filePath: entry.filePath });
877
+ const cssFiles = entryCssFiles.get(entry.kebabName) || [];
878
+ const { inline: cssInlineFiles, snippets: cssSnippets } = categorizeCss(cssFiles, cssSnippetMap);
879
+ const cssInline = readCssFileContents(cssInlineFiles, options.buildDir, options.themeRoot);
880
+ log8.debug(
881
+ "compiling %s (type=%s, css inline=%d, css snippets=%d)",
882
+ entry.kebabName,
883
+ entry.targetType,
884
+ cssInline.length,
885
+ cssSnippets.length
886
+ );
746
887
  const scriptAsset = resolveScriptAsset(entry.kebabName, manifest);
747
- const liquidContent = assembleLiquidFile(html, entry, scriptAsset, { inline: cssInline, snippets: cssSnippets }, {
888
+ const liquidContent = assembleLiquidFile(html, entry, scriptAsset, {
889
+ inline: cssInline,
890
+ snippets: cssSnippets
891
+ }, {
748
892
  prefix: options.ssg.prefix,
749
893
  outputName: options.ssg.outputName || void 0,
750
894
  buildDir: options.buildDir
@@ -754,79 +898,21 @@ async function compileEntry(entry, options, manifest, projectRoot, sourceDir, cs
754
898
  outputName: options.ssg.outputName || void 0,
755
899
  themeRoot: options.themeRoot
756
900
  });
757
- const dir = path6.dirname(outputPath);
758
- if (!fs.existsSync(dir)) {
759
- fs.mkdirSync(dir, { recursive: true });
901
+ const dir = path9.dirname(outputPath);
902
+ if (!fs3.existsSync(dir)) {
903
+ fs3.mkdirSync(dir, { recursive: true });
760
904
  }
761
- fs.writeFileSync(outputPath, liquidContent);
905
+ fs3.writeFileSync(outputPath, liquidContent);
762
906
  } finally {
763
907
  try {
764
- fs.unlinkSync(tmpFile);
765
- } catch {
766
- }
767
- }
768
- }
769
- function resolveScriptAsset(kebabName, manifest) {
770
- const manifestKey = `shopify:entry:${kebabName}`;
771
- const entryChunk = manifest[manifestKey];
772
- if (!entryChunk) return null;
773
- const file = entryChunk.file;
774
- if (!file) return null;
775
- return path6.basename(file);
776
- }
777
- function collectCssFiles(manifestKey, manifest) {
778
- const collected = /* @__PURE__ */ new Set();
779
- const visited = /* @__PURE__ */ new Set();
780
- collectCssFilesRecursive(manifestKey, manifest, collected, visited);
781
- return [...collected];
782
- }
783
- function collectCssFilesRecursive(chunkKey, manifest, collected, visited) {
784
- if (visited.has(chunkKey)) return;
785
- visited.add(chunkKey);
786
- const chunk = manifest[chunkKey];
787
- if (!chunk) return;
788
- if (chunk.css && Array.isArray(chunk.css)) {
789
- for (const cssFile of chunk.css) {
790
- collected.add(cssFile);
791
- }
792
- }
793
- if (chunk.imports && Array.isArray(chunk.imports)) {
794
- for (const imported of chunk.imports) {
795
- collectCssFilesRecursive(imported, manifest, collected, visited);
796
- }
797
- }
798
- }
799
- function readCssFileContents(cssFiles, buildDir, themeRoot) {
800
- const assetsDir = path6.resolve(themeRoot, buildDir);
801
- return cssFiles.map((file) => {
802
- try {
803
- return fs.readFileSync(path6.join(assetsDir, file), "utf-8");
908
+ fs3.unlinkSync(bundleResult.tmpFile);
804
909
  } catch {
805
- return "";
806
- }
807
- }).filter(Boolean);
808
- }
809
- function getCssBaseName(cssFile) {
810
- const name = cssFile.replace(/\.css$/, "");
811
- const lastHyphen = name.lastIndexOf("-");
812
- if (lastHyphen > 0) {
813
- const possibleHash = name.slice(lastHyphen + 1);
814
- if (/^[A-Za-z0-9_-]{8,}$/.test(possibleHash)) {
815
- return name.slice(0, lastHyphen);
816
910
  }
817
911
  }
818
- return name;
819
- }
820
- function pathToFileURL(filePath) {
821
- const absPath = path6.resolve(filePath);
822
- if (process.platform === "win32") {
823
- return "file:///" + absPath.replace(/\\/g, "/");
824
- }
825
- return "file://" + absPath;
826
912
  }
827
913
 
828
- // src/plugin/ssg/index.ts
829
- var log6 = logger("ssg");
914
+ // src/ssg/index.ts
915
+ var log9 = logger("ssg");
830
916
  function shopifySSG(options) {
831
917
  return {
832
918
  name: "vite-plugin-shopify:ssg",
@@ -835,23 +921,23 @@ function shopifySSG(options) {
835
921
  return {};
836
922
  },
837
923
  async closeBundle() {
838
- const manifestPath = path7.resolve(
924
+ const manifestPath = path10.resolve(
839
925
  options.themeRoot,
840
926
  options.buildDir,
841
927
  ".vite",
842
928
  "manifest.json"
843
929
  );
844
- if (!fs2.existsSync(manifestPath)) {
845
- log6.warn("No manifest.json found, skipping SSG");
930
+ if (!fs4.existsSync(manifestPath)) {
931
+ log9.warn("No manifest.json found, skipping SSG");
846
932
  return;
847
933
  }
848
- log6.debug("reading manifest from %s", manifestPath);
849
- const manifest = JSON.parse(fs2.readFileSync(manifestPath, "utf-8"));
850
- log6.info("Starting SSG compilation...");
934
+ log9.debug("reading manifest from %s", manifestPath);
935
+ const manifest = JSON.parse(fs4.readFileSync(manifestPath, "utf-8"));
936
+ log9.info("Starting SSG compilation...");
851
937
  await compileAllEntries(options, manifest);
852
- log6.info("SSG compilation complete");
938
+ log9.info("SSG compilation complete");
853
939
  writeImportMapSnippet(options);
854
- log6.debug("wrote import map snippet");
940
+ log9.debug("wrote import map snippet");
855
941
  },
856
942
  resolveId(id) {
857
943
  if (id === "vite-plugin-shopify/runtime") {
@@ -870,7 +956,7 @@ function shopifySSG(options) {
870
956
  };
871
957
  }
872
958
  function writeImportMapSnippet(options) {
873
- const snippetPath = path7.resolve(
959
+ const snippetPath = path10.resolve(
874
960
  options.themeRoot,
875
961
  "snippets",
876
962
  options.snippetFile
@@ -887,11 +973,11 @@ function writeImportMapSnippet(options) {
887
973
  "</script>",
888
974
  ""
889
975
  ].join("\n");
890
- const dir = path7.dirname(snippetPath);
891
- if (!fs2.existsSync(dir)) {
892
- fs2.mkdirSync(dir, { recursive: true });
976
+ const dir = path10.dirname(snippetPath);
977
+ if (!fs4.existsSync(dir)) {
978
+ fs4.mkdirSync(dir, { recursive: true });
893
979
  }
894
- fs2.writeFileSync(snippetPath, content);
980
+ fs4.writeFileSync(snippetPath, content);
895
981
  }
896
982
 
897
983
  // src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vite-plugin-react-shopify",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Vite plugin for React Shopify themes",
5
5
  "files": [
6
6
  "dist"
@@ -17,16 +17,6 @@
17
17
  "default": "./dist/runtime/index.js"
18
18
  }
19
19
  },
20
- "scripts": {
21
- "dev": "tsup --watch",
22
- "build": "tsup && cp dev-server-index.html dist/",
23
- "typecheck": "tsc --noEmit",
24
- "test": "vitest run",
25
- "release": "bumpp && pnpm publish",
26
- "release:patch": "bumpp --patch",
27
- "release:minor": "bumpp --minor",
28
- "release:major": "bumpp --major"
29
- },
30
20
  "dependencies": {
31
21
  "debug": "^4.4.0",
32
22
  "fast-glob": "^3.3.0"
@@ -42,5 +32,15 @@
42
32
  },
43
33
  "peerDependencies": {
44
34
  "vite": "^8.0.0"
35
+ },
36
+ "scripts": {
37
+ "dev": "tsup --watch",
38
+ "build": "tsup && cp dev-server-index.html dist/",
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "vitest run",
41
+ "release": "bumpp && pnpm publish",
42
+ "release:patch": "bumpp --patch",
43
+ "release:minor": "bumpp --minor",
44
+ "release:major": "bumpp --major"
45
45
  }
46
- }
46
+ }