vite-plugin-react-shopify 1.0.1 → 2.0.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.
package/dist/index.js CHANGED
@@ -1,74 +1,116 @@
1
- // src/options.ts
1
+ // src/plugin/options.ts
2
2
  import path from "path";
3
- var defaultImportMap = {
4
- react: "https://esm.sh/react@19",
5
- reactDomClient: "https://esm.sh/react-dom@19/client"
6
- };
7
3
  var defaultPrefix = {
8
4
  template: "page.react-",
9
5
  section: "react-",
10
- block: "react-"
6
+ block: "react-",
7
+ snippet: "react-"
11
8
  };
9
+ function assetRef(buildDir, filename) {
10
+ if (buildDir === "assets") return filename;
11
+ const sub = buildDir.startsWith("assets/") ? buildDir.slice(7) : buildDir;
12
+ return `${sub}/${filename}`;
13
+ }
14
+ function liquidAssetUrl(ref) {
15
+ return `{{ '${ref}' | asset_url }}`;
16
+ }
12
17
  var resolveOptions = (options = {}) => {
13
18
  const themeRoot = options.themeRoot ?? "./";
14
19
  const sourceCodeDir = options.sourceCodeDir ?? "frontend";
15
20
  const snippetFile = options.snippetFile ?? "shopify-importmap.liquid";
16
21
  const buildDir = options.buildDir ?? "assets";
17
22
  const ssg = {
18
- directories: options.ssg?.directories ?? ["sections", "blocks", "templates"],
23
+ directories: options.ssg?.directories ?? ["sections", "blocks", "templates", "snippets"],
19
24
  prefix: {
20
25
  template: options.ssg?.prefix?.template ?? defaultPrefix.template,
21
26
  section: options.ssg?.prefix?.section ?? defaultPrefix.section,
22
- block: options.ssg?.prefix?.block ?? defaultPrefix.block
27
+ block: options.ssg?.prefix?.block ?? defaultPrefix.block,
28
+ snippet: options.ssg?.prefix?.snippet ?? defaultPrefix.snippet
23
29
  },
24
- outputName: options.ssg?.outputName ?? ""
30
+ outputName: options.ssg?.outputName ?? "",
31
+ cssPrefix: options.ssg?.cssPrefix ?? "css"
25
32
  };
26
33
  const importMap = {
27
- react: options.importMap?.react ?? defaultImportMap.react,
28
- reactDomClient: options.importMap?.reactDomClient ?? defaultImportMap.reactDomClient
34
+ react: options.importMap?.react ?? liquidAssetUrl(assetRef(buildDir, "react.js")),
35
+ reactDomClient: options.importMap?.reactDomClient ?? liquidAssetUrl(assetRef(buildDir, "react-dom.js"))
29
36
  };
30
37
  return {
31
38
  themeRoot: path.resolve(themeRoot),
32
39
  sourceCodeDir,
33
40
  snippetFile,
34
41
  buildDir,
42
+ debug: options.debug ?? false,
35
43
  ssg,
36
44
  importMap
37
45
  };
38
46
  };
39
47
 
40
- // src/config.ts
41
- import path2 from "path";
48
+ // src/plugin/logger.ts
42
49
  import createDebugger from "debug";
43
- var debug = createDebugger("vite-plugin-shopify:config");
50
+ var NAMESPACE = "vite-plugin-shopify";
51
+ var _debugEnabled = false;
52
+ function enableDebug() {
53
+ if (_debugEnabled) return;
54
+ _debugEnabled = true;
55
+ const existing = process.env.DEBUG;
56
+ createDebugger.enable(existing ? `${existing},${NAMESPACE}:*` : `${NAMESPACE}:*`);
57
+ }
58
+ function logger(ns) {
59
+ const dbg = createDebugger(`${NAMESPACE}:${ns}`);
60
+ return {
61
+ debug: (formatter, ...args) => {
62
+ if (_debugEnabled) dbg(formatter, ...args);
63
+ },
64
+ info: (msg, ...args) => console.log(`[${NAMESPACE}] ${msg}`, ...args),
65
+ warn: (msg, ...args) => console.warn(`[${NAMESPACE}] ${msg}`, ...args),
66
+ error: (msg, ...args) => console.error(`[${NAMESPACE}] ${msg}`, ...args)
67
+ };
68
+ }
69
+
70
+ // src/plugin/config.ts
71
+ import path2 from "path";
72
+ var log = logger("config");
73
+ function isWatchMode() {
74
+ return process.argv.includes("--watch") || process.env.SHOPIFY_DEV_WATCH === "1";
75
+ }
44
76
  function shopifyConfig(options) {
45
- const isDebug = process.env.VITE_SHOPIFY_DEBUG === "true";
46
77
  return {
47
78
  name: "vite-plugin-shopify:config",
48
79
  config(config) {
49
80
  const sourceDirAbs = path2.resolve(options.themeRoot, options.sourceCodeDir);
81
+ const watch = isWatchMode();
82
+ log.debug("watch=%s", watch);
50
83
  const generated = {
51
84
  base: config.base ?? "./",
52
85
  publicDir: config.publicDir ?? false,
53
86
  build: {
54
87
  outDir: config.build?.outDir ?? path2.join(options.themeRoot, options.buildDir),
55
88
  assetsDir: config.build?.assetsDir ?? "",
56
- emptyOutDir: config.build?.emptyOutDir ?? false,
89
+ emptyOutDir: config.build?.emptyOutDir ?? true,
57
90
  manifest: config.build?.manifest ?? true,
58
- minify: config.build?.minify ?? (isDebug ? false : void 0),
59
- sourcemap: config.build?.sourcemap ?? (isDebug ? true : void 0),
60
- rollupOptions: {
61
- ...config.build?.rollupOptions,
62
- external: [
63
- ...Array.isArray(config.build?.rollupOptions?.external) ? config.build.rollupOptions.external : [],
64
- "react",
65
- "react-dom/client"
66
- ],
91
+ minify: config.build?.minify ?? (watch || options.debug ? false : void 0),
92
+ sourcemap: config.build?.sourcemap ?? (watch || options.debug ? "inline" : void 0),
93
+ rolldownOptions: {
94
+ ...config.build?.rolldownOptions ?? config.build?.rollupOptions,
95
+ external: Array.isArray((config.build?.rolldownOptions ?? config.build?.rollupOptions)?.external) ? (config.build?.rolldownOptions ?? config.build?.rollupOptions).external : [],
67
96
  output: {
68
- ...config.build?.rollupOptions?.output,
97
+ ...(config.build?.rolldownOptions ?? config.build?.rollupOptions)?.output,
69
98
  entryFileNames: "[name]-[hash].js",
70
- chunkFileNames: "[name]-[hash].js",
71
- assetFileNames: "[name]-[hash][extname]"
99
+ chunkFileNames(chunkInfo) {
100
+ if (["react", "react-dom"].includes(chunkInfo.name)) {
101
+ return `${chunkInfo.name}.js`;
102
+ }
103
+ return "[name]-[hash].js";
104
+ },
105
+ assetFileNames: "[name]-[hash][extname]",
106
+ manualChunks(id) {
107
+ if (id.includes("/node_modules/react-dom/")) {
108
+ return "react-dom";
109
+ }
110
+ if (id.includes("/node_modules/react/") || id.includes("/node_modules/scheduler/")) {
111
+ return "react";
112
+ }
113
+ }
72
114
  }
73
115
  }
74
116
  },
@@ -98,24 +140,25 @@ function shopifyConfig(options) {
98
140
  modules: config.css?.modules
99
141
  }
100
142
  };
101
- debug(generated);
143
+ log.debug("generated config: %O", generated);
102
144
  return generated;
103
145
  }
104
146
  };
105
147
  }
106
148
 
107
- // src/entries.ts
149
+ // src/plugin/entries.ts
108
150
  import path4 from "path";
109
151
  import { normalizePath as normalizePath2 } from "vite";
110
152
 
111
- // src/ssg/scanner.ts
153
+ // src/plugin/ssg/scanner.ts
112
154
  import path3 from "path";
113
155
  import glob from "fast-glob";
114
156
  import { normalizePath } from "vite";
115
157
  var TYPE_BY_DIR = {
116
158
  templates: "template",
117
159
  sections: "section",
118
- blocks: "block"
160
+ blocks: "block",
161
+ snippets: "snippet"
119
162
  };
120
163
  function scanEntries(options) {
121
164
  const sourceDir = path3.resolve(options.themeRoot, options.sourceCodeDir);
@@ -144,13 +187,19 @@ function toKebabCase(str) {
144
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();
145
188
  }
146
189
 
147
- // src/entries.ts
190
+ // src/plugin/entries.ts
191
+ var log2 = logger("entries");
148
192
  function shopifyEntries(options) {
149
193
  let entries = [];
150
194
  return {
151
195
  name: "vite-plugin-shopify:entries",
152
196
  config(config) {
153
197
  entries = scanEntries(options);
198
+ const byType = {};
199
+ for (const e of entries) {
200
+ byType[e.targetType] = (byType[e.targetType] || 0) + 1;
201
+ }
202
+ log2.debug("scanned %d entries: %s", entries.length, JSON.stringify(byType));
154
203
  if (entries.length === 0) return {};
155
204
  const input = {};
156
205
  for (const entry of entries) {
@@ -178,20 +227,22 @@ function shopifyEntries(options) {
178
227
  `import { createElement } from 'react'`,
179
228
  `import Component from '~/${componentRel}'`,
180
229
  `import { hydrateRoot } from 'react-dom/client'`,
181
- `import { SettingsProvider } from 'vite-plugin-react-shopify/runtime/settings'`,
182
- `import { ParamsProvider } from 'vite-plugin-react-shopify/runtime/settings'`,
230
+ `import { LiquidDataProvider } from 'vite-plugin-react-shopify/runtime'`,
183
231
  ``,
184
232
  `const SELECTOR = '[data-ssg-component="${kebabName}"]'`,
185
233
  `const roots = new Map()`,
186
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
+ ``,
187
241
  `function hydrate(el) {`,
188
242
  ` const h = el.querySelector(':scope > [data-ssg-hydrate]') || (el.matches('[data-ssg-hydrate]') ? el : null)`,
189
243
  ` if (!h || roots.has(h)) return`,
190
- ` const propsEl = el.querySelector(':scope > script[data-ssg-props]')`,
191
- ` const props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {}`,
192
- ` const paramsEl = el.querySelector(':scope > script[data-ssg-params]')`,
193
- ` const params = paramsEl ? JSON.parse(paramsEl.textContent || '{}') : {}`,
194
- ` roots.set(h, hydrateRoot(h, createElement(SettingsProvider, { value: props }, createElement(ParamsProvider, { value: params }, createElement(Component)))))`,
244
+ ` const liquidData = readLiquidData(el)`,
245
+ ` roots.set(h, hydrateRoot(h, createElement(LiquidDataProvider, { value: liquidData }, createElement(Component))))`,
195
246
  `}`,
196
247
  ``,
197
248
  `function unmount(el) {`,
@@ -223,30 +274,45 @@ function shopifyEntries(options) {
223
274
  };
224
275
  }
225
276
 
226
- // src/ssg/index.ts
277
+ // src/plugin/ssg/index.ts
227
278
  import fs2 from "fs";
228
279
  import path7 from "path";
229
280
 
230
- // src/ssg/compiler.ts
281
+ // src/plugin/ssg/compiler.ts
231
282
  import fs from "fs";
232
283
  import path6 from "path";
233
284
  import { createRequire } from "module";
234
285
 
235
- // src/ssg/post-process.ts
236
- var REACT_LIQUID_REGEX = /<\/?react-liquid>/g;
237
- function stripReactLiquidTags(html) {
238
- return html.replace(REACT_LIQUID_REGEX, "");
286
+ // src/plugin/ssg/post-process.ts
287
+ var VOID_ELEMENTS = /<(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)([^>]*)\/>/g;
288
+ function normalizeVoidElements(html) {
289
+ return html.replace(VOID_ELEMENTS, "<$1$2>");
290
+ }
291
+ function normalizeStyleAttributes(html) {
292
+ return html.replace(/ style="([^"]+)"/g, (_match, content) => {
293
+ const normalized = content.replace(/:(\S)/g, ": $1").replace(/;\s*$/, "");
294
+ return ` style="${normalized};"`;
295
+ });
239
296
  }
240
297
  function unwrapHtmlEntities(html) {
241
298
  return html.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#x27;/g, "'");
242
299
  }
243
300
 
244
- // src/ssg/schema-gen.ts
301
+ // src/plugin/ssg/liquid.ts
302
+ import path5 from "path";
303
+
304
+ // src/plugin/ssg/schema-gen.ts
305
+ var log3 = logger("schema-gen");
245
306
  function serializeSetting(setting) {
246
307
  const s = { type: setting.type };
247
308
  if ("id" in setting) s.id = setting.id;
248
309
  if ("label" in setting) s.label = setting.label;
249
310
  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
+ }
250
316
  s.default = setting.default;
251
317
  }
252
318
  if ("info" in setting && setting.info) s.info = setting.info;
@@ -309,10 +375,9 @@ ${json}
309
375
  `;
310
376
  }
311
377
 
312
- // src/ssg/liquid.ts
313
- import path5 from "path";
378
+ // src/plugin/ssg/liquid.ts
314
379
  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";
315
- function assembleLiquidFile(html, entry, scriptAsset, cssContents, options) {
380
+ function assembleLiquidFile(html, entry, scriptAsset, cssContents, options, trackedExpressions = []) {
316
381
  const type = entry.meta.type ?? entry.targetType;
317
382
  const parts = [DISCLAIMER];
318
383
  switch (type) {
@@ -320,20 +385,26 @@ function assembleLiquidFile(html, entry, scriptAsset, cssContents, options) {
320
385
  parts.push(html);
321
386
  break;
322
387
  case "section":
323
- parts.push(...buildSection(html, entry));
388
+ parts.push(...buildSection(html, entry, trackedExpressions));
324
389
  break;
325
390
  case "block":
326
- parts.push(...buildBlock(html, entry));
391
+ parts.push(...buildBlock(html, entry, trackedExpressions));
392
+ break;
393
+ case "snippet":
394
+ parts.push(...buildSnippet(html, entry, trackedExpressions));
327
395
  break;
328
396
  default:
329
- parts.push(...buildSection(html, entry));
397
+ parts.push(...buildSection(html, entry, trackedExpressions));
330
398
  break;
331
399
  }
332
- if (cssContents.length > 0) {
400
+ for (const snippet of cssContents.snippets) {
401
+ parts.push("", `{% render '${snippet}' %}`);
402
+ }
403
+ if (cssContents.inline.length > 0) {
333
404
  parts.push(
334
405
  "",
335
406
  "{% stylesheet %}",
336
- ...cssContents.map((c) => c.trim()),
407
+ ...cssContents.inline.map((c) => c.trim()),
337
408
  "{% endstylesheet %}"
338
409
  );
339
410
  }
@@ -344,21 +415,27 @@ function assembleLiquidFile(html, entry, scriptAsset, cssContents, options) {
344
415
  `<script type="module" src="{{ '${assetPath}' | asset_url }}"></script>`
345
416
  );
346
417
  }
347
- parts.push(generateSchema(entry.meta));
418
+ if (type !== "snippet") {
419
+ parts.push(generateSchema(entry.meta));
420
+ }
348
421
  return parts.join("\n") + "\n";
349
422
  }
350
423
  var hasBlocks = (entry) => !!entry.meta.blocks && entry.meta.blocks.length > 0;
351
- var SETTINGS_SECTION = ` <script type="application/json" data-ssg-props>{{ section.settings | json }}</script>`;
352
- var SETTINGS_BLOCK = ` <script type="application/json" data-ssg-props>{{ block.settings | json }}</script>`;
353
- function buildParamsBridge(params) {
354
- const entries = params.map((p) => ` "${p}": {{ ${p} | json }}`).join(",\n");
355
- return ` <script type="application/json" data-ssg-params>
356
- {
357
- ${entries}
358
- }
359
- </script>`;
424
+ function buildLiquidBridge(trackedExpressions) {
425
+ if (trackedExpressions.length === 0) return "";
426
+ const entries = trackedExpressions.map((expr, i) => {
427
+ const comma = i < trackedExpressions.length - 1 ? "," : "";
428
+ return ` "${expr}": {{ ${expr} | json }}${comma}`;
429
+ });
430
+ return [
431
+ ' <script type="application/json" data-ssg-liquid>',
432
+ " {",
433
+ entries.join("\n"),
434
+ " }",
435
+ " </script>"
436
+ ].join("\n");
360
437
  }
361
- function buildSection(html, entry) {
438
+ function buildSection(html, entry, trackedExpressions) {
362
439
  const tag = entry.meta.tag ?? "div";
363
440
  const cls = entry.meta.class ?? "";
364
441
  const lines = [
@@ -369,23 +446,17 @@ function buildSection(html, entry) {
369
446
  ` data-ssg-component="${entry.kebabName}"`
370
447
  ];
371
448
  if (cls) lines.push(` class="${cls}"`);
449
+ lines.push(`>`);
450
+ const liquidBridge = buildLiquidBridge(trackedExpressions);
451
+ if (liquidBridge) lines.push(liquidBridge);
372
452
  lines.push(
373
- `>`,
374
- SETTINGS_SECTION
375
- );
376
- if (entry.meta.params?.length) {
377
- lines.push(buildParamsBridge(entry.meta.params));
378
- }
379
- lines.push(
380
- ` <div data-ssg-hydrate>`,
381
- ` ${html}`,
382
- ` </div>`
453
+ ` <div data-ssg-hydrate>${html}</div>`
383
454
  );
384
455
  if (hasBlocks(entry)) lines.push(` {% content_for 'blocks' %}`);
385
456
  lines.push(`</${tag}>`);
386
457
  return lines;
387
458
  }
388
- function buildBlock(html, entry) {
459
+ function buildBlock(html, entry, trackedExpressions) {
389
460
  const tag = entry.meta.tag ?? "div";
390
461
  const cls = entry.meta.class ?? "";
391
462
  const lines = [
@@ -403,27 +474,43 @@ function buildBlock(html, entry) {
403
474
  if (cls) lines.push(` class="${cls}"`);
404
475
  lines.push(
405
476
  ` {{ block.shopify_attributes }}`,
406
- `>`,
407
- SETTINGS_BLOCK
477
+ `>`
408
478
  );
409
- if (entry.meta.params?.length) {
410
- lines.push(buildParamsBridge(entry.meta.params));
411
- }
479
+ const liquidBridge = buildLiquidBridge(trackedExpressions);
480
+ if (liquidBridge) lines.push(liquidBridge);
412
481
  lines.push(
413
- ` <div data-ssg-hydrate>`,
414
- ` ${html}`,
415
- ` </div>`
482
+ ` <div data-ssg-hydrate>${html}</div>`
416
483
  );
417
484
  if (hasBlocks(entry)) lines.push(` {% content_for 'blocks' %}`);
418
485
  lines.push(`</${tag}>`);
419
486
  return lines;
420
487
  }
488
+ function buildSnippet(html, entry, trackedExpressions) {
489
+ const lines = [
490
+ "",
491
+ `<div data-ssg-component="${entry.kebabName}">`
492
+ ];
493
+ const liquidBridge = buildLiquidBridge(trackedExpressions);
494
+ if (liquidBridge) lines.push(liquidBridge);
495
+ lines.push(
496
+ ` <div data-ssg-hydrate>`,
497
+ ` ${html}`,
498
+ ` </div>`,
499
+ `</div>`
500
+ );
501
+ return lines;
502
+ }
421
503
  function getOutputPath(entry, options) {
422
504
  const type = entry.meta.type ?? entry.targetType;
423
- const dirName = type === "block" ? "blocks" : `${type}s`;
505
+ const dirName = typeToDir(type);
424
506
  const fileName = resolveFileName(entry, type, options);
425
507
  return path5.join(options.themeRoot, dirName, fileName);
426
508
  }
509
+ function typeToDir(type) {
510
+ if (type === "snippet") return "snippets";
511
+ if (type === "block") return "blocks";
512
+ return `${type}s`;
513
+ }
427
514
  function getAssetRelativePath(buildDir, filename) {
428
515
  if (!buildDir.startsWith("assets/")) return filename;
429
516
  const prefix = buildDir.slice("assets/".length);
@@ -437,22 +524,118 @@ function resolveFileName(entry, type, options) {
437
524
  return `${prefix}${entry.kebabName}.liquid`;
438
525
  }
439
526
 
440
- // src/ssg/compiler.ts
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
+ );
551
+ }
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;
561
+ }
562
+ if (!/\{/.test(trimmed)) return false;
563
+ if (/<[a-zA-Z]/.test(trimmed)) return false;
564
+ return true;
565
+ }
566
+
567
+ // src/plugin/ssg/compiler.ts
568
+ var log5 = logger("ssg:compiler");
441
569
  async function compileAllEntries(options, manifest) {
442
570
  const entries = scanEntries(options);
443
571
  if (entries.length === 0) return;
572
+ log5.debug("found %d entries to compile", entries.length);
444
573
  const projectRoot = path6.resolve(options.themeRoot);
445
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
+ }
446
613
  for (const entry of entries) {
447
614
  try {
448
- await compileEntry(entry, options, manifest, projectRoot, sourceDir);
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);
449
627
  } catch (err) {
450
- console.error(`[vite-plugin-shopify] Failed to compile ${entry.filePath}:`, err);
628
+ log5.error("Failed to compile %s:", entry.filePath, err);
451
629
  }
452
630
  }
453
- console.log(`[vite-plugin-shopify] Compiled ${entries.length} entries`);
631
+ log5.info("Compiled %d entries", entries.length);
632
+ const tmpDir = path6.join(sourceDir, ".ssg-tmp");
633
+ try {
634
+ fs.rmSync(tmpDir, { recursive: true, force: true });
635
+ } catch {
636
+ }
454
637
  }
455
- async function compileEntry(entry, options, manifest, projectRoot, sourceDir) {
638
+ async function compileEntry(entry, options, manifest, projectRoot, sourceDir, cssInline, cssSnippets) {
456
639
  const projectRequire = createRequire(path6.join(projectRoot, "package.json"));
457
640
  let createElement;
458
641
  let renderToStaticMarkup;
@@ -460,58 +643,112 @@ async function compileEntry(entry, options, manifest, projectRoot, sourceDir) {
460
643
  createElement = projectRequire("react").createElement;
461
644
  renderToStaticMarkup = projectRequire("react-dom/server").renderToStaticMarkup;
462
645
  } catch {
463
- console.warn(`[vite-plugin-shopify] react/react-dom not found, skipping SSR`);
646
+ log5.warn("react/react-dom not found, skipping SSR for %s", entry.kebabName);
464
647
  return;
465
648
  }
466
649
  const sourceCode = fs.readFileSync(entry.filePath, "utf-8");
467
- const ssgSource = sourceCode.replace(
468
- /import\s+(\w+)\s+from\s+["'][^"']*\.module\.css["'];?\s*/g,
469
- (_, name) => `const ${name} = new Proxy({},{get:(_,k)=>k});`
470
- ).replace(
471
- /import\s+["'][^"']*\.css["'];?\s*/g,
472
- ""
473
- );
650
+ const { result: fixedSource, fixCount } = autoFixAdjacentText(sourceCode, entry.filePath);
651
+ const finalSource = fixCount > 0 ? fixedSource : sourceCode;
474
652
  let esbuild;
475
653
  try {
476
654
  esbuild = projectRequire("esbuild");
477
655
  } catch {
478
- console.warn(`[vite-plugin-shopify] esbuild not found, skipping SSR`);
656
+ log5.warn("esbuild not found, skipping SSR for %s", entry.kebabName);
479
657
  return;
480
658
  }
481
- const result = await esbuild.transform(ssgSource, {
482
- loader: path6.extname(entry.filePath).slice(1),
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,
483
673
  format: "esm",
484
674
  jsx: "automatic",
485
- sourcefile: entry.filePath
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
+ ]
486
724
  });
487
- const ts = Date.now();
488
- const tmpFile = path6.join(sourceDir, ".ssg-tmp-" + ts + ".mjs");
489
- fs.writeFileSync(tmpFile, result.code);
725
+ log5.debug("esbuild bundle took %dms", Date.now() - startBundled);
490
726
  try {
491
727
  const mod = await import(pathToFileURL(tmpFile));
492
728
  const Component = mod.default;
493
729
  const shopifyMeta = mod.shopifyMeta;
494
730
  if (!Component) {
495
- console.warn(
496
- `[vite-plugin-shopify] No default export found in ${entry.filePath}, skipping`
497
- );
731
+ log5.warn("No default export found in %s, skipping", entry.filePath);
498
732
  return;
499
733
  }
500
734
  if (shopifyMeta) {
501
735
  entry.meta = { ...entry.meta, ...shopifyMeta };
502
736
  }
503
737
  globalThis.__shopify_ssg_target = entry.targetType;
738
+ const trackedExpressions = /* @__PURE__ */ new Set();
739
+ globalThis.__shopify_ssg_liquid_track = trackedExpressions;
504
740
  const element = createElement(Component);
505
741
  let html = renderToStaticMarkup(element);
506
- html = stripReactLiquidTags(html);
742
+ delete globalThis.__shopify_ssg_liquid_track;
743
+ html = normalizeVoidElements(html);
744
+ html = normalizeStyleAttributes(html);
507
745
  html = unwrapHtmlEntities(html);
508
746
  const scriptAsset = resolveScriptAsset(entry.kebabName, manifest);
509
- const cssContents = readCssAssets(entry.kebabName, manifest, options.buildDir, options.themeRoot);
510
- const liquidContent = assembleLiquidFile(html, entry, scriptAsset, cssContents, {
747
+ const liquidContent = assembleLiquidFile(html, entry, scriptAsset, { inline: cssInline, snippets: cssSnippets }, {
511
748
  prefix: options.ssg.prefix,
512
749
  outputName: options.ssg.outputName || void 0,
513
750
  buildDir: options.buildDir
514
- });
751
+ }, [...trackedExpressions]);
515
752
  const outputPath = getOutputPath(entry, {
516
753
  prefix: options.ssg.prefix,
517
754
  outputName: options.ssg.outputName || void 0,
@@ -537,22 +774,49 @@ function resolveScriptAsset(kebabName, manifest) {
537
774
  if (!file) return null;
538
775
  return path6.basename(file);
539
776
  }
540
- function readCssAssets(kebabName, manifest, buildDir, themeRoot) {
541
- const manifestKey = `shopify:entry:${kebabName}`;
542
- const entryChunk = manifest[manifestKey];
543
- if (!entryChunk) return [];
544
- const css = entryChunk.css;
545
- if (!css || !Array.isArray(css)) return [];
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) {
546
800
  const assetsDir = path6.resolve(themeRoot, buildDir);
547
- return css.map((file) => {
548
- const cssPath = path6.join(assetsDir, file);
801
+ return cssFiles.map((file) => {
549
802
  try {
550
- return fs.readFileSync(cssPath, "utf-8");
803
+ return fs.readFileSync(path6.join(assetsDir, file), "utf-8");
551
804
  } catch {
552
805
  return "";
553
806
  }
554
807
  }).filter(Boolean);
555
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
+ }
817
+ }
818
+ return name;
819
+ }
556
820
  function pathToFileURL(filePath) {
557
821
  const absPath = path6.resolve(filePath);
558
822
  if (process.platform === "win32") {
@@ -561,7 +825,8 @@ function pathToFileURL(filePath) {
561
825
  return "file://" + absPath;
562
826
  }
563
827
 
564
- // src/ssg/index.ts
828
+ // src/plugin/ssg/index.ts
829
+ var log6 = logger("ssg");
565
830
  function shopifySSG(options) {
566
831
  return {
567
832
  name: "vite-plugin-shopify:ssg",
@@ -577,14 +842,16 @@ function shopifySSG(options) {
577
842
  "manifest.json"
578
843
  );
579
844
  if (!fs2.existsSync(manifestPath)) {
580
- console.warn("[vite-plugin-shopify] No manifest.json found, skipping SSG");
845
+ log6.warn("No manifest.json found, skipping SSG");
581
846
  return;
582
847
  }
848
+ log6.debug("reading manifest from %s", manifestPath);
583
849
  const manifest = JSON.parse(fs2.readFileSync(manifestPath, "utf-8"));
584
- console.log("[vite-plugin-shopify] Starting SSG compilation...");
850
+ log6.info("Starting SSG compilation...");
585
851
  await compileAllEntries(options, manifest);
586
- console.log("[vite-plugin-shopify] SSG compilation complete");
852
+ log6.info("SSG compilation complete");
587
853
  writeImportMapSnippet(options);
854
+ log6.debug("wrote import map snippet");
588
855
  },
589
856
  resolveId(id) {
590
857
  if (id === "vite-plugin-shopify/runtime") {
@@ -593,7 +860,11 @@ function shopifySSG(options) {
593
860
  },
594
861
  load(id) {
595
862
  if (id === "\0vite-plugin-shopify:runtime") {
596
- return `export { Liquid } from 'vite-plugin-shopify/runtime/Liquid'`;
863
+ const exports = [
864
+ `export { LiquidDataProvider, LiquidDataContext } from 'vite-plugin-shopify/runtime'`,
865
+ `export { useLiquid, useLiquidValues, useSectionSettings, useBlockSettings, useSnippetParams, useBlockParams } from 'vite-plugin-shopify/runtime'`
866
+ ];
867
+ return exports.join("\n");
597
868
  }
598
869
  }
599
870
  };
@@ -626,6 +897,9 @@ function writeImportMapSnippet(options) {
626
897
  // src/index.ts
627
898
  var vitePluginShopify = (options = {}) => {
628
899
  const resolvedOptions = resolveOptions(options);
900
+ if (resolvedOptions.debug || process.env.DEBUG?.includes("vite-plugin-shopify")) {
901
+ enableDebug();
902
+ }
629
903
  return [
630
904
  shopifyConfig(resolvedOptions),
631
905
  shopifyEntries(resolvedOptions),