@cloudcannon/editable-regions 0.0.17 → 0.0.19

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 (42) hide show
  1. package/helpers/checks.ts +0 -22
  2. package/helpers/cloudcannon.mjs +7 -22
  3. package/helpers/hydrate-editable-regions.ts +2 -1
  4. package/integrations/astro/astro-integration.mjs +1 -4
  5. package/integrations/astro/index.mjs +15 -41
  6. package/integrations/astro/modules/assets.js +1 -4
  7. package/integrations/astro/modules/content.js +2 -11
  8. package/integrations/astro/react-renderer.mjs +98 -24
  9. package/integrations/astro/svelte-renderer.mjs +72 -15
  10. package/integrations/astro/vue-renderer.mjs +61 -0
  11. package/integrations/eleventy/browser/collect-config.mjs +249 -0
  12. package/integrations/eleventy/browser/index.mjs +9 -0
  13. package/integrations/eleventy/browser/inert.mjs +35 -0
  14. package/integrations/eleventy/browser/liquid-builtins.mjs +306 -0
  15. package/integrations/eleventy/browser/liquid-render.mjs +216 -0
  16. package/integrations/eleventy/browser/process-shim.mjs +32 -0
  17. package/integrations/eleventy/browser/stub-mode.mjs +61 -0
  18. package/integrations/eleventy/index.cjs +28 -0
  19. package/integrations/eleventy/index.mjs +634 -0
  20. package/integrations/liquid/README.md +677 -0
  21. package/integrations/liquid/errors.mjs +41 -0
  22. package/integrations/liquid/fs.mjs +36 -75
  23. package/integrations/liquid/globals.mjs +308 -0
  24. package/integrations/liquid/include-with-tag.mjs +84 -0
  25. package/integrations/liquid/index.mjs +166 -170
  26. package/integrations/liquid/logger.mjs +15 -78
  27. package/integrations/liquid/page-map.mjs +32 -0
  28. package/integrations/liquid/shortcodes.mjs +62 -99
  29. package/integrations/react.mjs +5 -9
  30. package/integrations/vue.mjs +28 -0
  31. package/nodes/editable-array-item.ts +6 -1
  32. package/nodes/editable-component.ts +2 -3
  33. package/nodes/editable-text.ts +6 -0
  34. package/nodes/editable.ts +6 -1
  35. package/package.json +120 -79
  36. package/types/astro.d.ts +4 -0
  37. package/types/eleventy.d.cts +20 -0
  38. package/types/eleventy.d.ts +88 -0
  39. package/types/liquid.d.ts +14 -12
  40. package/types/vue.d.ts +40 -0
  41. package/integrations/eleventy.mjs +0 -294
  42. package/integrations/liquid/11ty-filters.mjs +0 -69
@@ -0,0 +1,634 @@
1
+ import fs from "node:fs";
2
+ import { builtinModules, createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import esbuild from "esbuild";
6
+ import { createIncludeWithTag } from "../liquid/include-with-tag.mjs";
7
+
8
+ /** This package's `integrations/eleventy/browser` directory. */
9
+ const BROWSER_DIR = path.join(
10
+ path.dirname(fileURLToPath(import.meta.url)),
11
+ "browser",
12
+ );
13
+
14
+ /** Substituted for unbound `process` / `__dirname` / `__filename`. */
15
+ const PROCESS_SHIM_PATH = path.join(BROWSER_DIR, "process-shim.mjs");
16
+
17
+ /** Backs the generated module stubs; see `createBrowserStubPlugin`. */
18
+ const STUB_MODE_PATH = path.join(BROWSER_DIR, "stub-mode.mjs");
19
+
20
+ /**
21
+ * @typedef {import("../../types/eleventy").LiquidOptions} LiquidOptions
22
+ * @typedef {import("../../types/eleventy").PluginOptions} PluginOptions
23
+ * @typedef {import("../../types/eleventy").NormalizedPluginOptions} NormalizedPluginOptions
24
+ */
25
+
26
+ /**
27
+ * @typedef {Object} EleventyDirectories
28
+ * @property {string} input
29
+ * @property {string} includes - Normalized, relative to project root
30
+ * @property {string} data
31
+ * @property {string} output
32
+ */
33
+
34
+ /**
35
+ * Payload Eleventy passes to `eleventy.after`. `directories` is the 3.x shape;
36
+ * `dir` the older fallback still passed in 3.x.
37
+ *
38
+ * @typedef {Object} EleventyEventPayload
39
+ * @property {EleventyDirectories} [directories]
40
+ * @property {EleventyDirectories} [dir]
41
+ * @property {Array<{inputPath?: string, outputPath?: string, url?: string}>} [results]
42
+ */
43
+
44
+ /**
45
+ * @typedef {(liquidEngine: import("liquidjs").Liquid) => { parse: (...args: any[]) => void, render: (...args: any[]) => unknown }} LiquidTagFactory
46
+ */
47
+
48
+ /**
49
+ * @typedef {"eleventy.before" | "eleventy.after" | "eleventy.beforeWatch" | "eleventy.beforeConfig"} EleventyEventName
50
+ */
51
+
52
+ /**
53
+ * The subset of Eleventy's (untyped) user config we touch.
54
+ *
55
+ * @typedef {Object} EleventyConfig
56
+ * @property {(name: string, factory: LiquidTagFactory) => void} addLiquidTag
57
+ * @property {(event: EleventyEventName, handler: (payload: EleventyEventPayload) => Promise<void> | void) => void} on
58
+ * @property {EleventyDirectories} dir
59
+ */
60
+
61
+ /**
62
+ * Eleventy plugin for CloudCannon editable regions. Registers Liquid tags
63
+ * and builds the live-editing client bundle.
64
+ *
65
+ * @param {EleventyConfig} eleventyConfig
66
+ * @param {PluginOptions} [pluginOptions]
67
+ */
68
+ export default function editableRegionsPlugin(eleventyConfig, pluginOptions) {
69
+ const options = normalizePluginOptions(pluginOptions);
70
+
71
+ // No supported languages enabled — nothing to register or bundle.
72
+ if (!options.liquid) return;
73
+ const liquidOptions = options.liquid;
74
+
75
+ eleventyConfig.addLiquidTag("includeWith", createIncludeWithTag);
76
+
77
+ eleventyConfig.on("eleventy.after", async ({ directories, dir, results }) => {
78
+ // 3.x `directories`, legacy `dir`, then a closure fallback.
79
+ const dirs = directories ?? dir ?? eleventyConfig.dir;
80
+
81
+ const rawExtensions = liquidOptions.extensions ?? [".liquid", ".html"];
82
+ const normalizedExtensions = rawExtensions.map((ext) =>
83
+ ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`,
84
+ );
85
+
86
+ const liveEditingSource = await generateLiveEditingSource(
87
+ options,
88
+ dirs,
89
+ normalizedExtensions,
90
+ results,
91
+ );
92
+
93
+ // esbuild only matches the final extension, so .bookshop.liquid -> .liquid
94
+ /** @type {Record<string, import('esbuild').Loader>} */
95
+ const loader = {};
96
+ for (const ext of normalizedExtensions) {
97
+ loader[ext.slice(ext.lastIndexOf("."))] = "text";
98
+ }
99
+
100
+ await esbuild.build({
101
+ stdin: {
102
+ contents: liveEditingSource,
103
+ resolveDir: process.cwd(),
104
+ },
105
+ loader,
106
+ bundle: true,
107
+ platform: "browser",
108
+ // The bundle imports the user's real Eleventy config (see
109
+ // `emitConfigMirror`), dragging in Node/build-time imports — stub them.
110
+ plugins: [createBrowserStubPlugin(liquidOptions.browserStub)],
111
+ // Node *globals* aren't imports, so the stub plugin can't reach them.
112
+ // esbuild defines `process.env.NODE_ENV` only; everything else is left
113
+ // unbound and throws at load.
114
+ inject: [PROCESS_SHIM_PATH],
115
+ outfile: options.output ?? `${dirs.output}/register-components.js`,
116
+ });
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Liquid is enabled implicitly; only `liquid: false` opts out.
122
+ *
123
+ * @param {PluginOptions | undefined} pluginOptions
124
+ * @returns {NormalizedPluginOptions}
125
+ */
126
+ function normalizePluginOptions(pluginOptions) {
127
+ const opts = pluginOptions ?? {};
128
+ return {
129
+ ...opts,
130
+ liquid: normalizeLiquidOption(opts.liquid),
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Resolves the `liquid` option to either an options object (enabled) or
136
+ * `false` (disabled). Liquid is on by default, so anything but an explicit
137
+ * `false` enables it; `true` and an omitted value mean "on with defaults".
138
+ *
139
+ * @param {LiquidOptions | boolean | undefined} liquid
140
+ * @returns {LiquidOptions | false}
141
+ */
142
+ function normalizeLiquidOption(liquid) {
143
+ // Explicit opt-out is the only way to disable Liquid.
144
+ if (liquid === false) return false;
145
+
146
+ // An options object is used as-is.
147
+ if (liquid && typeof liquid === "object") return liquid;
148
+
149
+ // `true`, `undefined`, or `null` → enabled with default options.
150
+ return {};
151
+ }
152
+
153
+ /**
154
+ * Specifiers stubbed by exact match (subpaths bundle normally — notably the
155
+ * Node plugin's own `/browser` and `/liquid` runtime). `@11ty/eleventy` is
156
+ * matched separately in `shouldStub` because its subpaths must be stubbed too.
157
+ */
158
+ const ALWAYS_STUBBED = ["@cloudcannon/editable-regions/eleventy"];
159
+
160
+ /**
161
+ * esbuild plugin resolving Node built-ins and build-time-only packages to a
162
+ * Proxy that survives `import` and property access, so the user's config
163
+ * bundles for the browser. What happens when one is *called* depends on the
164
+ * phase — see `browser/stub-mode.mjs`.
165
+ *
166
+ * `process` is the exception: it resolves to the real shim, so an explicit
167
+ * `import … from "node:process"` reads the same values as the bare global
168
+ * that `inject` substitutes.
169
+ *
170
+ * @param {string[]} [extraStubs] - Extra specifiers to stub
171
+ * (`pluginOptions.liquid.browserStub`), e.g. native deps like `sharp`.
172
+ * @returns {import('esbuild').Plugin}
173
+ */
174
+ function createBrowserStubPlugin(extraStubs = []) {
175
+ const nodeBuiltins = new Set([
176
+ ...builtinModules,
177
+ ...builtinModules.map((m) => `node:${m}`),
178
+ ]);
179
+ const exactStubs = new Set([...ALWAYS_STUBBED, ...extraStubs]);
180
+
181
+ const shouldStub = (/** @type {string} */ id) => {
182
+ if (nodeBuiltins.has(id)) return true;
183
+
184
+ if (id === "@11ty/eleventy" || id.startsWith("@11ty/eleventy/")) {
185
+ return true;
186
+ }
187
+
188
+ return exactStubs.has(id);
189
+ };
190
+
191
+ return {
192
+ name: "editable-regions-browser-stub",
193
+ setup(build) {
194
+ build.onResolve({ filter: /.*/ }, (args) => {
195
+ if (args.path === "process" || args.path === "node:process") {
196
+ return { path: args.path, namespace: "er-process" };
197
+ }
198
+ return shouldStub(args.path)
199
+ ? { path: args.path, namespace: "er-stub" }
200
+ : null;
201
+ });
202
+ // Re-exported as CommonJS rather than resolving straight to the shim:
203
+ // against an ES module, a named import esbuild can't match
204
+ // (`import { hrtime } from "node:process"`) is a hard build error,
205
+ // where CJS interop resolves it to `undefined` at runtime.
206
+ build.onLoad({ filter: /.*/, namespace: "er-process" }, () => ({
207
+ contents: `module.exports = require(${JSON.stringify(PROCESS_SHIM_PATH)}).process;`,
208
+ loader: "js",
209
+ resolveDir: BROWSER_DIR,
210
+ }));
211
+ build.onLoad({ filter: /.*/, namespace: "er-stub" }, (args) => ({
212
+ // CommonJS for the same reason as `er-process` above: a stub's
213
+ // export names aren't knowable, so named imports need CJS interop.
214
+ contents: `
215
+ const { onStubInvoked } = require(${JSON.stringify(STUB_MODE_PATH)});
216
+ const specifier = ${JSON.stringify(args.path)};
217
+ const handler = {
218
+ get: () => new Proxy(function () {}, handler),
219
+ apply: () => onStubInvoked(specifier, "called"),
220
+ construct: () => onStubInvoked(specifier, "constructed"),
221
+ };
222
+ module.exports = new Proxy(function () {}, handler);
223
+ `,
224
+ loader: "js",
225
+ // So the `require` above resolves out of the stub's namespace.
226
+ resolveDir: BROWSER_DIR,
227
+ }));
228
+ },
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Resolves the user's Eleventy config path (11ty doesn't expose it): an
234
+ * explicit `configPath`, else the first default config filename in the root.
235
+ *
236
+ * @param {LiquidOptions} liquidOptions
237
+ * @returns {string | null} Absolute path, or `null` if none found
238
+ */
239
+ function resolveEleventyConfigPath(liquidOptions) {
240
+ if (liquidOptions.configPath) {
241
+ return path.resolve(process.cwd(), liquidOptions.configPath);
242
+ }
243
+ // Matches 11ty's default resolution order (TemplateConfig.js).
244
+ const defaults = [
245
+ ".eleventy.js",
246
+ "eleventy.config.js",
247
+ "eleventy.config.mjs",
248
+ "eleventy.config.cjs",
249
+ ];
250
+ for (const name of defaults) {
251
+ const candidate = path.resolve(process.cwd(), name);
252
+ if (fs.existsSync(candidate)) return candidate;
253
+ }
254
+ return null;
255
+ }
256
+
257
+ /**
258
+ * Emits the import of the user's Eleventy config plus the call that replays it
259
+ * in the browser to auto-mirror its helpers (see
260
+ * `collectAndRegisterEleventyHelpers`). Passes the per-kind override names to
261
+ * skip; those are registered separately by `emitImportRegistrations` so the
262
+ * override wins.
263
+ *
264
+ * Split because the replay is awaited inside `initLiveEditing`, while an
265
+ * `import` can only live at module scope.
266
+ *
267
+ * @param {string} configPath - Absolute path to the Eleventy config
268
+ * @param {LiquidOptions | undefined} liquidOptions
269
+ * @returns {{imports: string, body: string}} JS source
270
+ */
271
+ function emitConfigMirror(configPath, liquidOptions) {
272
+ const skip = {
273
+ filters: Object.keys(liquidOptions?.filters ?? {}),
274
+ shortcodes: Object.keys(liquidOptions?.shortcodes ?? {}),
275
+ pairedShortcodes: Object.keys(liquidOptions?.pairedShortcodes ?? {}),
276
+ tags: Object.keys(liquidOptions?.tags ?? {}),
277
+ };
278
+
279
+ return {
280
+ imports: `\nimport userEleventyConfig from ${JSON.stringify(configPath)};\n`,
281
+ body: `await collectAndRegisterEleventyHelpers(userEleventyConfig, ${JSON.stringify({ skip })});\n`,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Builds the JS source for the live-editing client bundle: imports and
287
+ * `register*` calls for components, filters, shortcodes, and tags.
288
+ *
289
+ * @param {NormalizedPluginOptions} options
290
+ * @param {EleventyDirectories} directories
291
+ * @param {string[]} normalizedExtensions - Lowercase, leading-dot
292
+ * @param {Array<{inputPath?: string, outputPath?: string, url?: string}> | undefined} results - From `eleventy.after`
293
+ * @returns {Promise<string>}
294
+ */
295
+ async function generateLiveEditingSource(
296
+ options,
297
+ directories,
298
+ normalizedExtensions,
299
+ results,
300
+ ) {
301
+ let source = "";
302
+
303
+ if (options.liquid) {
304
+ const liquidOptions = options.liquid;
305
+ // `input` alongside `includes` so `{% include %}` reaches sibling files.
306
+ const componentDirs = liquidOptions.componentDirs ?? [
307
+ directories.includes,
308
+ directories.input,
309
+ ];
310
+ const ignoreDirectories = liquidOptions.ignoreDirectories ?? [
311
+ directories.output,
312
+ "node_modules",
313
+ ];
314
+ const normalizedIgnoreDirs = ignoreDirectories.map((dir) =>
315
+ dir.toLowerCase(),
316
+ );
317
+
318
+ source += `
319
+ import { createSharedLiquidEngine, registerLiquidComponent, registerFilter, registerShortcode, registerPairedShortcode, registerCustomTag, registerGlobals, registerEleventyData, registerPkg, registerPageMap, initComponentProxy, setVerbose } from '@cloudcannon/editable-regions/liquid';
320
+ import { registerEleventyBuiltins, collectAndRegisterEleventyHelpers } from '@cloudcannon/editable-regions/eleventy/browser';
321
+
322
+ setVerbose(${Boolean(options.verbose)});
323
+
324
+ const liquidEngine = createSharedLiquidEngine({
325
+ root: ${JSON.stringify(componentDirs)},
326
+ extname: ".liquid",
327
+ strictFilters: true,
328
+ });
329
+
330
+ // Wires on Eleventy's built-in filters/shortcodes (browser ports)
331
+ // and RenderPlugin shims onto the host-agnostic engine.
332
+ registerEleventyBuiltins(liquidEngine);
333
+
334
+ window.cc_liquid_files = {};
335
+ `;
336
+
337
+ // User-supplied globals, embedded as a literal so editor-rendered
338
+ // templates read the same values the build exposes.
339
+ if (options.globals && Object.keys(options.globals).length > 0) {
340
+ source += `\nregisterGlobals(${JSON.stringify(options.globals)});\n`;
341
+ }
342
+
343
+ // Static `eleventy` global so templates branching on `eleventy.version` /
344
+ // `eleventy.env.runMode` see something sensible.
345
+ const eleventyData = buildEleventyData(directories);
346
+ source += `\nregisterEleventyData(${JSON.stringify(eleventyData)});\n`;
347
+
348
+ // 11ty exposes the project's package.json as the `pkg` global by default.
349
+ const pkg = buildPkg();
350
+ if (pkg) {
351
+ source += `\nregisterPkg(${JSON.stringify(pkg)});\n`;
352
+ }
353
+
354
+ // Build-time page map from 11ty's `results`, so the page/collections
355
+ // proxies and `inputPathToUrl` resolve computed/templated permalinks.
356
+ const pageMap = buildPageMap(results);
357
+ if (Object.keys(pageMap).length > 0) {
358
+ source += `\nregisterPageMap(${JSON.stringify(pageMap)});\n`;
359
+ }
360
+
361
+ // Pre-populate `window.cc_liquid_files` with every includable template,
362
+ // the map LiquidJS's in-memory fs (`liquid/fs.mjs`) resolves against.
363
+ const allLiquidFiles = await findAllLiquidFiles(
364
+ componentDirs,
365
+ normalizedExtensions,
366
+ normalizedIgnoreDirs,
367
+ );
368
+
369
+ for (const [i, filePath] of allLiquidFiles.entries()) {
370
+ const id = `liquidFile_${i}`;
371
+ source += `import ${id} from "./${filePath}";
372
+
373
+ window.cc_liquid_files["${filePath}"] = ${id};
374
+ `;
375
+ }
376
+
377
+ // Auto-mirror the user's config helpers by importing and replaying the
378
+ // real config in the browser. See `emitConfigMirror`.
379
+ const configPath = resolveEleventyConfigPath(liquidOptions);
380
+ const configMirror = configPath
381
+ ? emitConfigMirror(configPath, liquidOptions)
382
+ : { imports: "", body: "" };
383
+
384
+ source += configMirror.imports;
385
+
386
+ if (!configPath) {
387
+ console.warn(
388
+ "[editable-regions] Could not locate an Eleventy config file to " +
389
+ "auto-mirror helpers from. Set `pluginOptions.liquid.configPath` " +
390
+ "if your config isn't at a default location. Filters/shortcodes " +
391
+ "defined in the config won't be available in live editing " +
392
+ "(overrides still work).",
393
+ );
394
+ }
395
+
396
+ // Register browser-side overrides and pinned components. Override names
397
+ // are excluded from the mirror, so each is its name's sole registration.
398
+ const registrations = emitImportRegistrations(liquidOptions);
399
+ source += registrations.imports;
400
+
401
+ // One awaited sequence, so nothing registers ahead of the async replay and
402
+ // `window.cc_components` is published only once it's complete.
403
+ source += `
404
+ async function initLiveEditing() {
405
+ ${configMirror.body}${registrations.body}
406
+ initComponentProxy();
407
+ }
408
+
409
+ initLiveEditing();
410
+ `;
411
+ }
412
+ return source;
413
+ }
414
+
415
+ /**
416
+ * Builds the static `eleventy` global, mirroring the browser-applicable parts
417
+ * of https://www.11ty.dev/docs/data-eleventy-supplied/. `env.config`/`env.root`
418
+ * (absolute paths) and `serverless` are omitted; `env.runMode`/`env.source` are
419
+ * hardcoded to `"serve"`/`"cli"` so branching templates take a sane path.
420
+ *
421
+ * @param {EleventyDirectories} directories
422
+ */
423
+ function buildEleventyData(directories) {
424
+ const version = readEleventyVersion();
425
+
426
+ return {
427
+ version,
428
+ generator: `Eleventy v${version}`,
429
+ env: {
430
+ runMode: "serve",
431
+ source: "cli",
432
+ },
433
+ directories: {
434
+ input: directories.input,
435
+ includes: directories.includes,
436
+ data: directories.data,
437
+ output: directories.output,
438
+ },
439
+ };
440
+ }
441
+
442
+ /**
443
+ * Reads the project `package.json` for the `pkg` global. Returns `null` on
444
+ * missing/malformed input so the bundle still builds.
445
+ */
446
+ function buildPkg() {
447
+ try {
448
+ const contents = fs.readFileSync(
449
+ path.join(process.cwd(), "package.json"),
450
+ "utf8",
451
+ );
452
+ return JSON.parse(contents);
453
+ } catch {
454
+ return null;
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Reads the installed Eleventy version, or `"unknown"` if unresolvable.
460
+ * `@11ty/eleventy` doesn't export `./package.json`, so we resolve its main
461
+ * entry and walk up to the package root instead.
462
+ */
463
+ function readEleventyVersion() {
464
+ try {
465
+ const require = createRequire(import.meta.url);
466
+ const entryPath = require.resolve("@11ty/eleventy");
467
+ let dir = path.dirname(entryPath);
468
+ while (true) {
469
+ const pkgPath = path.join(dir, "package.json");
470
+ if (fs.existsSync(pkgPath)) {
471
+ /** @type {{name?: string, version?: string}} */
472
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
473
+ if (pkg.name === "@11ty/eleventy" && pkg.version) return pkg.version;
474
+ }
475
+ const parent = path.dirname(dir);
476
+ if (parent === dir) break;
477
+ dir = parent;
478
+ }
479
+ return "unknown";
480
+ } catch {
481
+ return "unknown";
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Compacts 11ty's `results` into the page map, keyed by normalized input path
487
+ * (matching `normalizeInputPath` in `liquid/page-map.mjs`). Pagination yields
488
+ * duplicate `inputPath`s — we keep the first, since cursors aren't modelled in
489
+ * the editor. Returns `{}` if `results` is absent or malformed.
490
+ *
491
+ * @param {Array<{inputPath?: string, outputPath?: string, url?: string}> | undefined} results
492
+ */
493
+ function buildPageMap(results) {
494
+ if (!Array.isArray(results)) return {};
495
+
496
+ /** @type {Record<string, { url?: string, outputPath?: string }>} */
497
+ const map = {};
498
+
499
+ for (const entry of results) {
500
+ if (!entry || typeof entry.inputPath !== "string") continue;
501
+
502
+ const key = entry.inputPath.replace(/^\.\//, "").replace(/^\/+/, "");
503
+ if (!key || key in map) continue;
504
+
505
+ map[key] = {
506
+ url: typeof entry.url === "string" ? entry.url : undefined,
507
+ outputPath:
508
+ typeof entry.outputPath === "string" ? entry.outputPath : undefined,
509
+ };
510
+ }
511
+
512
+ return map;
513
+ }
514
+
515
+ /**
516
+ * @param {string[]} componentDirs
517
+ * @param {string[]} extensions
518
+ * @param {string[]} ignoreDirectories
519
+ */
520
+ async function findAllLiquidFiles(
521
+ componentDirs,
522
+ extensions,
523
+ ignoreDirectories,
524
+ ) {
525
+ const allFiles = [];
526
+
527
+ for (const dir of componentDirs) {
528
+ const files = await findFilesInDirectory({
529
+ directory: dir,
530
+ extensions,
531
+ ignoreDirectories,
532
+ });
533
+
534
+ allFiles.push(...files);
535
+ }
536
+
537
+ return allFiles;
538
+ }
539
+
540
+ /**
541
+ * @param {Object} options
542
+ * @param {string} options.directory
543
+ * @param {string[]} [options.extensions]
544
+ * @param {string[]} [options.ignoreDirectories]
545
+ * @returns {Promise<string[]>}
546
+ */
547
+ async function findFilesInDirectory({
548
+ directory,
549
+ extensions = [".html", ".liquid"],
550
+ ignoreDirectories = [],
551
+ }) {
552
+ const files = [];
553
+
554
+ try {
555
+ const entries = await fs.promises.readdir(directory, {
556
+ withFileTypes: true,
557
+ });
558
+
559
+ for (const entry of entries) {
560
+ const fullPath = path.join(directory, entry.name);
561
+
562
+ if (entry.isDirectory()) {
563
+ if (ignoreDirectories.includes(entry.name.toLowerCase())) {
564
+ continue;
565
+ }
566
+
567
+ const subFiles = await findFilesInDirectory({
568
+ directory: fullPath,
569
+ extensions,
570
+ ignoreDirectories,
571
+ });
572
+ files.push(...subFiles);
573
+ } else if (entry.isFile()) {
574
+ // Handles compound extensions too (e.g. `.bookshop.liquid`).
575
+ const filenameLower = entry.name.toLowerCase();
576
+ const hasValidExtension = extensions.some((ext) =>
577
+ filenameLower.endsWith(ext),
578
+ );
579
+ if (hasValidExtension) {
580
+ files.push(fullPath);
581
+ }
582
+ }
583
+ }
584
+ } catch (error) {
585
+ console.error("ERROR reading directory:", directory, error);
586
+ throw error;
587
+ }
588
+
589
+ return files;
590
+ }
591
+
592
+ /**
593
+ * Maps each `{ name: modulePath }` `pluginOptions.liquid` field to its runtime
594
+ * registration function. `components` pins a module ahead of the
595
+ * filesystem-resolution proxy via the same import-and-register shape.
596
+ */
597
+ const IMPORT_REGISTER_FNS = {
598
+ filters: "registerFilter",
599
+ shortcodes: "registerShortcode",
600
+ pairedShortcodes: "registerPairedShortcode",
601
+ tags: "registerCustomTag",
602
+ components: "registerLiquidComponent",
603
+ };
604
+
605
+ /**
606
+ * Emits an `import` + register-call pair for every `{ name: modulePath }` entry
607
+ * across the `IMPORT_REGISTER_FNS` maps, e.g.:
608
+ *
609
+ * import filters_0 from "./path/to/file"; // module scope
610
+ * registerFilter("name", filters_0); // inside `initLiveEditing`
611
+ *
612
+ * @param {LiquidOptions | undefined} liquidOptions
613
+ * @returns {{imports: string, body: string}} JS source
614
+ */
615
+ function emitImportRegistrations(liquidOptions) {
616
+ let imports = "";
617
+ let body = "";
618
+
619
+ for (const optionKey of /** @type {Array<keyof typeof IMPORT_REGISTER_FNS>} */ (
620
+ Object.keys(IMPORT_REGISTER_FNS)
621
+ )) {
622
+ const registerFn = IMPORT_REGISTER_FNS[optionKey];
623
+
624
+ for (const [i, [name, file]] of Object.entries(
625
+ liquidOptions?.[optionKey] ?? {},
626
+ ).entries()) {
627
+ const id = `${optionKey}_${i}`;
628
+ imports += `\nimport ${id} from "./${file}";\n`;
629
+ body += `${registerFn}(${JSON.stringify(name)}, ${id});\n`;
630
+ }
631
+ }
632
+
633
+ return { imports, body };
634
+ }