@cloudcannon/editable-regions 0.0.18 → 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.
@@ -1,9 +1,22 @@
1
1
  import fs from "node:fs";
2
2
  import { builtinModules, createRequire } from "node:module";
3
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import esbuild from "esbuild";
5
6
  import { createIncludeWithTag } from "../liquid/include-with-tag.mjs";
6
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
+
7
20
  /**
8
21
  * @typedef {import("../../types/eleventy").LiquidOptions} LiquidOptions
9
22
  * @typedef {import("../../types/eleventy").PluginOptions} PluginOptions
@@ -95,6 +108,10 @@ export default function editableRegionsPlugin(eleventyConfig, pluginOptions) {
95
108
  // The bundle imports the user's real Eleventy config (see
96
109
  // `emitConfigMirror`), dragging in Node/build-time imports — stub them.
97
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],
98
115
  outfile: options.output ?? `${dirs.output}/register-components.js`,
99
116
  });
100
117
  });
@@ -142,9 +159,13 @@ const ALWAYS_STUBBED = ["@cloudcannon/editable-regions/eleventy"];
142
159
 
143
160
  /**
144
161
  * esbuild plugin resolving Node built-ins and build-time-only packages to a
145
- * Proxy that survives `import` and property access but throws when called or
146
- * constructed so the user's config bundles, and only a helper that actually
147
- * invokes a Node API at render time fails.
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.
148
169
  *
149
170
  * @param {string[]} [extraStubs] - Extra specifiers to stub
150
171
  * (`pluginOptions.liquid.browserStub`), e.g. native deps like `sharp`.
@@ -170,25 +191,39 @@ function createBrowserStubPlugin(extraStubs = []) {
170
191
  return {
171
192
  name: "editable-regions-browser-stub",
172
193
  setup(build) {
173
- build.onResolve({ filter: /.*/ }, (args) =>
174
- shouldStub(args.path)
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)
175
199
  ? { path: args.path, namespace: "er-stub" }
176
- : null,
177
- );
178
- build.onLoad({ filter: /.*/, 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.
179
214
  contents: `
215
+ const { onStubInvoked } = require(${JSON.stringify(STUB_MODE_PATH)});
216
+ const specifier = ${JSON.stringify(args.path)};
180
217
  const handler = {
181
218
  get: () => new Proxy(function () {}, handler),
182
- apply: () => {
183
- throw new Error("editable-regions: a Node/build-time API was called in the browser live-editing bundle. Provide a browser-friendly override via pluginOptions.liquid.<kind>.");
184
- },
185
- construct: () => {
186
- throw new Error("editable-regions: a Node/build-time API was constructed in the browser live-editing bundle. Provide a browser-friendly override via pluginOptions.liquid.<kind>.");
187
- },
219
+ apply: () => onStubInvoked(specifier, "called"),
220
+ construct: () => onStubInvoked(specifier, "constructed"),
188
221
  };
189
222
  module.exports = new Proxy(function () {}, handler);
190
223
  `,
191
224
  loader: "js",
225
+ // So the `require` above resolves out of the stub's namespace.
226
+ resolveDir: BROWSER_DIR,
192
227
  }));
193
228
  },
194
229
  };
@@ -226,9 +261,12 @@ function resolveEleventyConfigPath(liquidOptions) {
226
261
  * skip; those are registered separately by `emitImportRegistrations` so the
227
262
  * override wins.
228
263
  *
264
+ * Split because the replay is awaited inside `initLiveEditing`, while an
265
+ * `import` can only live at module scope.
266
+ *
229
267
  * @param {string} configPath - Absolute path to the Eleventy config
230
268
  * @param {LiquidOptions | undefined} liquidOptions
231
- * @returns {string} JS source
269
+ * @returns {{imports: string, body: string}} JS source
232
270
  */
233
271
  function emitConfigMirror(configPath, liquidOptions) {
234
272
  const skip = {
@@ -238,10 +276,10 @@ function emitConfigMirror(configPath, liquidOptions) {
238
276
  tags: Object.keys(liquidOptions?.tags ?? {}),
239
277
  };
240
278
 
241
- return (
242
- `\nimport userEleventyConfig from ${JSON.stringify(configPath)};\n` +
243
- `collectAndRegisterEleventyHelpers(userEleventyConfig, ${JSON.stringify({ skip })});\n`
244
- );
279
+ return {
280
+ imports: `\nimport userEleventyConfig from ${JSON.stringify(configPath)};\n`,
281
+ body: `await collectAndRegisterEleventyHelpers(userEleventyConfig, ${JSON.stringify({ skip })});\n`,
282
+ };
245
283
  }
246
284
 
247
285
  /**
@@ -339,9 +377,13 @@ async function generateLiveEditingSource(
339
377
  // Auto-mirror the user's config helpers by importing and replaying the
340
378
  // real config in the browser. See `emitConfigMirror`.
341
379
  const configPath = resolveEleventyConfigPath(liquidOptions);
342
- if (configPath) {
343
- source += emitConfigMirror(configPath, liquidOptions);
344
- } else {
380
+ const configMirror = configPath
381
+ ? emitConfigMirror(configPath, liquidOptions)
382
+ : { imports: "", body: "" };
383
+
384
+ source += configMirror.imports;
385
+
386
+ if (!configPath) {
345
387
  console.warn(
346
388
  "[editable-regions] Could not locate an Eleventy config file to " +
347
389
  "auto-mirror helpers from. Set `pluginOptions.liquid.configPath` " +
@@ -353,10 +395,18 @@ async function generateLiveEditingSource(
353
395
 
354
396
  // Register browser-side overrides and pinned components. Override names
355
397
  // are excluded from the mirror, so each is its name's sole registration.
356
- source += emitImportRegistrations(liquidOptions);
398
+ const registrations = emitImportRegistrations(liquidOptions);
399
+ source += registrations.imports;
357
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.
358
403
  source += `
359
- initComponentProxy();
404
+ async function initLiveEditing() {
405
+ ${configMirror.body}${registrations.body}
406
+ initComponentProxy();
407
+ }
408
+
409
+ initLiveEditing();
360
410
  `;
361
411
  }
362
412
  return source;
@@ -556,14 +606,15 @@ const IMPORT_REGISTER_FNS = {
556
606
  * Emits an `import` + register-call pair for every `{ name: modulePath }` entry
557
607
  * across the `IMPORT_REGISTER_FNS` maps, e.g.:
558
608
  *
559
- * import filters_0 from "./path/to/file";
560
- * registerFilter("name", filters_0);
609
+ * import filters_0 from "./path/to/file"; // module scope
610
+ * registerFilter("name", filters_0); // inside `initLiveEditing`
561
611
  *
562
612
  * @param {LiquidOptions | undefined} liquidOptions
563
- * @returns {string} JS source
613
+ * @returns {{imports: string, body: string}} JS source
564
614
  */
565
615
  function emitImportRegistrations(liquidOptions) {
566
- let out = "";
616
+ let imports = "";
617
+ let body = "";
567
618
 
568
619
  for (const optionKey of /** @type {Array<keyof typeof IMPORT_REGISTER_FNS>} */ (
569
620
  Object.keys(IMPORT_REGISTER_FNS)
@@ -574,9 +625,10 @@ function emitImportRegistrations(liquidOptions) {
574
625
  liquidOptions?.[optionKey] ?? {},
575
626
  ).entries()) {
576
627
  const id = `${optionKey}_${i}`;
577
- out += `\nimport ${id} from "./${file}";\n${registerFn}(${JSON.stringify(name)}, ${id});\n`;
628
+ imports += `\nimport ${id} from "./${file}";\n`;
629
+ body += `${registerFn}(${JSON.stringify(name)}, ${id});\n`;
578
630
  }
579
631
  }
580
632
 
581
- return out;
633
+ return { imports, body };
582
634
  }
@@ -16,6 +16,7 @@ build time; this directory is what that bundle pulls in.
16
16
  - [Eleventy global](#eleventy-global)
17
17
  - [`pkg` global](#pkg-global)
18
18
  - [Filters](#filters)
19
+ - [What the auto-mirror actually does](#what-the-auto-mirror-actually-does)
19
20
  - [Adding a custom filter](#adding-a-custom-filter)
20
21
  - [Overriding a built-in](#overriding-a-built-in)
21
22
  - [Shortcodes and paired shortcodes](#shortcodes-and-paired-shortcodes)
@@ -49,6 +50,20 @@ not implemented — see "Limitations and fallbacks".
49
50
  npm install @cloudcannon/editable-regions
50
51
  ```
51
52
 
53
+ Requires **Node 20.19+ or 22.12+**. The plugin is an ES module; those are the
54
+ releases where Node can `require()` one, so a CommonJS config can pull it in
55
+ with a plain `require`. On anything older, use a dynamic import from an async
56
+ config instead:
57
+
58
+ ```js
59
+ module.exports = async function (eleventyConfig) {
60
+ const { default: editableRegions } = await import(
61
+ "@cloudcannon/editable-regions/eleventy"
62
+ );
63
+ eleventyConfig.addPlugin(editableRegions);
64
+ };
65
+ ```
66
+
52
67
  Wire the plugin into your `eleventy.config.mjs`. The minimal case is one
53
68
  line — Liquid is the plugin's default language and is enabled implicitly:
54
69
 
@@ -127,7 +142,7 @@ works too.
127
142
  | `liquid.pairedShortcodes` | Same as `shortcodes`, for paired shortcodes. |
128
143
  | `liquid.tags` | Map of tag name → factory module path. Browser-side override. Tags auto-mirror from the config like filters/shortcodes; use this only for a tag that can't run in the browser as written. |
129
144
  | `liquid.configPath` | Path to the Eleventy config file to import and replay for the auto-mirror, relative to the project root. Defaults to the first of 11ty's standard names that exists (`.eleventy.js`, `eleventy.config.{js,mjs,cjs}`). Set only if you run Eleventy with a non-default `--config`. |
130
- | `liquid.browserStub` | Extra bare module specifiers to stub out of the browser bundle, on top of the 11ty toolchain and Node built-ins (always stubbed). Use when the config imports a native/Node-only package (e.g. `sharp`) that no browser-bound helper actually calls. |
145
+ | `liquid.browserStub` | Extra bare module specifiers to stub out of the browser bundle, on top of the 11ty toolchain and Node built-ins (always stubbed). Use for a native/Node-only package (e.g. `sharp`, or a Node-only 11ty plugin) that would otherwise break bundling, or whose config-time calls would abort the auto-mirror. See "What the auto-mirror actually does". |
131
146
 
132
147
  ## How it fits together
133
148
 
@@ -151,7 +166,7 @@ Globals are passed to `new Liquid({ globals })` inside `createSharedLiquidEngine
151
166
 
152
167
  | Global | Status | Notes |
153
168
  | --- | --- | --- |
154
- | `collections` | Implemented | `Proxy` that lazily resolves `collections.foo` to an array of items via the Visual Editor API. Items shaped roughly like Eleventy's: `{ url, inputPath, data }`. |
169
+ | `collections` | Implemented | Object with one lazy getter per collection name, resolving `collections.foo` to an array of items via the Visual Editor API. Items shaped roughly like Eleventy's: `{ url, inputPath, data }`. Listing the collections is a single API call; a collection's files are fetched only when a template reads that key, with bounded concurrency, and cached until the collection changes. A component that never mentions `collections` issues no per-file requests. |
155
170
  | `ENV_CLIENT` | Implemented | Always `true` in this bundle. Templates can branch on it to opt out of build-only logic. |
156
171
  | `page` | Partial | `Proxy` backed by `CloudCannon.currentFile()`. See below for which properties are supported. |
157
172
  | custom globals | Opt-in | Whatever you pass via `pluginOptions.globals` (e.g. an `env` object), embedded at build time. See "Custom globals" below. |
@@ -260,6 +275,15 @@ name collision: **built-ins**, **auto-mirrored**, then **overrides**.
260
275
  at render time will throw when invoked in the browser — the signal to add
261
276
  an override.
262
277
 
278
+ `async` configs and `async` plugins are supported: the replay is awaited,
279
+ and component rendering is held until it finishes. This matters because
280
+ `await import("@11ty/eleventy")` — the usual way a CommonJS config reaches
281
+ the ESM-only `RenderPlugin` / `I18nPlugin` exports — makes the whole config
282
+ async, and none of its helpers exist until that import settles.
283
+
284
+ See "What the auto-mirror actually does" below before assuming a helper
285
+ will survive the trip.
286
+
263
287
  3. **Overrides** (`pluginOptions.liquid.filters`). A map from filter name to
264
288
  module path. Two reasons to use this:
265
289
  - **A mirrored filter throws at render time** — supply a browser-safe
@@ -269,6 +293,69 @@ name collision: **built-ins**, **auto-mirrored**, then **overrides**.
269
293
  `eleventyConfig.addFilter("url", …)` won't reach live editing unless you
270
294
  also register it here.
271
295
 
296
+ ### What the auto-mirror actually does
297
+
298
+ The mirror is **not** a static scan of your config. The bundle imports your
299
+ real config module and *runs it* in the browser, against a stand-in
300
+ `eleventyConfig` that records `addFilter` / `addShortcode` / `addLiquidTag`
301
+ calls and ignores everything else. That's what makes closures and imports
302
+ survive — and it means every line of your config executes in a browser.
303
+
304
+ Most of what that implies is handled for you:
305
+
306
+ - **Node built-ins and the 11ty toolchain are stubbed**, so importing them is
307
+ harmless. A stubbed module that gets *called* during the replay is skipped
308
+ with a console warning and the rest of the config still mirrors; the same
309
+ call from inside a rendered helper throws, because there it's a real
310
+ problem you need to fix.
311
+ - **Node globals are shimmed.** `process.env.X`, `process.cwd()`, `__dirname`
312
+ and `__filename` resolve to inert values rather than a `ReferenceError`.
313
+ `process.env.NODE_ENV` reads `"development"`, for the same reason
314
+ `eleventy.env.runMode` is `"serve"` — the editor isn't a production build,
315
+ and a config gated on `NODE_ENV === "production"` shouldn't drag build-only
316
+ plugins into the mirror. Real values belong in `pluginOptions.globals`.
317
+
318
+ What's left is code that runs at config time and needs something the browser
319
+ genuinely doesn't have. In rough order of what to reach for:
320
+
321
+ 1. **`pluginOptions.liquid.browserStub`** — the usual answer. Add the module
322
+ specifier and it resolves to a stub, so calls through it are skipped
323
+ instead of aborting the replay. This is what a Node-only plugin needs,
324
+ including the argument-side case that nothing else can intercept:
325
+
326
+ ```js
327
+ // `pluginBookshop({...})` is evaluated *before* `addPlugin` is called, so
328
+ // no amount of proxying `eleventyConfig` can catch it — the module itself
329
+ // has to be stubbed.
330
+ eleventyConfig.addPlugin(pluginBookshop({ /* Node-only */ }));
331
+
332
+ eleventyConfig.addPlugin(editableRegions, {
333
+ liquid: { browserStub: ["@bookshop/eleventy-bookshop"] },
334
+ });
335
+ ```
336
+
337
+ 2. **A per-helper override** (`pluginOptions.liquid.filters` / `.shortcodes` /
338
+ `.pairedShortcodes` / `.tags`) — for a helper that mirrors fine but can't
339
+ *run* in the browser. See "Adding a custom filter".
340
+
341
+ 3. **An early return** — last resort, for config-time code that sits behind no
342
+ import at all, so there's nothing to stub:
343
+
344
+ ```js
345
+ export default function (eleventyConfig) {
346
+ eleventyConfig.addFilter("shout", (s) => String(s).toUpperCase());
347
+
348
+ // Everything below is build-only; the browser mirror stops here.
349
+ if (typeof window !== "undefined") return;
350
+
351
+ const manifest = buildManifestFromDisk();
352
+ eleventyConfig.addGlobalData("manifest", manifest);
353
+ }
354
+ ```
355
+
356
+ Put it as late as you can: helpers registered *above* the return still
357
+ mirror, and anything below it won't be available in live editing.
358
+
272
359
  ### Adding a custom filter
273
360
 
274
361
  For most filters you don't need to do anything — registering with Eleventy
@@ -540,7 +627,9 @@ section catalogues the gaps and the patterns for working around them.
540
627
  | `htmlBaseUrl`, `serverlessUrl` filters | Registered as warn-once pass-throughs; return their input unchanged. `htmlBaseUrl` depends on the configured `pathPrefix` (we don't expose it yet); `serverlessUrl` is a build-time concept with no editor equivalent. | Override via `pluginOptions.liquid.filters` if you have a browser-safe equivalent. Otherwise wrap the template path in `{% if ENV_CLIENT %}` and skip it. |
541
628
  | `inputPathToUrl` filter when the source file wasn't in the last build | Falls back to warn-once and returns the input path unchanged. The build-time page map is what makes this filter work; for files added since the last build there's no URL to look up. | Re-build to pick up new pages. |
542
629
  | `renderTemplate` / `renderFile` / `renderContent` with a non-Liquid engine arg (e.g. `"njk"`, `"md"`) | Warn-once and return the body unchanged. We only ship LiquidJS in the bundle. | Switch the template to Liquid, or guard the call with `{% if ENV_CLIENT %}` so it only runs at build time. |
543
- | Mirrored filters/shortcodes that touch `this.ctx`, `process`, `require`, `__dirname`, or a closed-over Node import | Auto-mirror ships them verbatim; they throw at render time in the browser. The thrown error is wrapped by `enhanceLiquidError` with the filter/shortcode name. | Add a `pluginOptions.liquid.filters` (or `.shortcodes` / `.pairedShortcodes`) override pointing at a browser-safe replacement. |
630
+ | Mirrored filters/shortcodes that touch `this.ctx` or a closed-over Node import | Auto-mirror ships them verbatim; they throw at render time in the browser. The thrown error is wrapped by `enhanceLiquidError` with the filter/shortcode name. | Add a `pluginOptions.liquid.filters` (or `.shortcodes` / `.pairedShortcodes`) override pointing at a browser-safe replacement. |
631
+ | Mirrored helpers that read `process.env`, `process.cwd()`, `__dirname` or `__filename` | Don't throw — they read the shim (see "What the auto-mirror actually does"), so they render, but with placeholder values rather than the build's. | If the value matters, pass it through `pluginOptions.globals` and read it as a Liquid global, or override the helper. |
632
+ | `{{ collections \| json }}` — serialising the **whole** collections object | Renders `{"posts":{},"pages":{}}`. Each key is a lazy getter resolving to a Promise, and `JSON.stringify` can't await; every other access pattern is unaffected because LiquidJS *does* await during expression evaluation. Materialising for serialisation would mean fetching every file in every collection on any access, which is what the laziness exists to prevent. | Serialise one collection at a time — `{{ collections.posts \| json }}` works normally. |
544
633
  | Helpers from auto-loaded 11ty plugins used **inside a component** (e.g. `getBundle` / `getBundleFileUrl` / `renderTransforms` from `@11ty/eleventy-plugin-bundle`) | 11ty 3.x auto-loads several plugins that register universal helpers; the auto-mirror ships them verbatim and they'll throw if invoked from a template the editor re-renders. Layouts and pages aren't affected — the live runtime only renders components. | If you reference one of these in an editable component, add a browser-safe override via `pluginOptions.liquid.shortcodes` / `.filters`. Most users won't hit this because bundle helpers typically live in layouts. |
545
634
  | User overrides of a **built-in** filter name via `eleventyConfig.addFilter` | The auto-mirror skips built-in names, so the override doesn't reach the bundle — live editing keeps using our handwritten port. | Also register the override in `pluginOptions.liquid.filters`. See "Overriding a built-in". |
546
635
  | Custom Liquid tags | Not auto-mirrored. Templates referencing an unregistered custom tag will fail with an enhanced "tag X not found" error. | Register every tag you want available via `pluginOptions.liquid.tags`. |
@@ -8,7 +8,9 @@
8
8
  export function enhanceLiquidError(err, componentName) {
9
9
  const message = err instanceof Error ? err.message : String(err);
10
10
 
11
- const unknownFilter = message.match(/undefined filter[:.]?\s*(\S+)/i);
11
+ // LiquidJS appends its own position suffix ("undefined filter: foo, line:2,
12
+ // col:1"), so stop at the comma rather than at whitespace.
13
+ const unknownFilter = message.match(/undefined filter[:.]?\s*([^\s,]+)/i);
12
14
  if (unknownFilter) {
13
15
  const filterName = unknownFilter[1];
14
16
  return new Error(
@@ -1,5 +1,13 @@
1
1
  import { log, warn } from "./logger.mjs";
2
2
 
3
+ /**
4
+ * Matches `path.extname`: last dot of the final segment, leading dot excluded.
5
+ */
6
+ function hasExtension(/** @type {string} */ filePath) {
7
+ const basename = filePath.slice(filePath.lastIndexOf("/") + 1);
8
+ return basename.lastIndexOf(".") > 0;
9
+ }
10
+
3
11
  /**
4
12
  * In-memory filesystem for LiquidJS, reading from `window.cc_liquid_files`.
5
13
  * @type {any}
@@ -60,7 +68,9 @@ export const inMemoryFs = {
60
68
  /** @type {string} */ ext,
61
69
  ) {
62
70
  const extension = ext || ".liquid";
63
- const fileWithExt = file.endsWith(extension) ? file : `${file}${extension}`;
71
+ // Only append when the file has none, as LiquidJS's Node fs does —
72
+ // otherwise `include "card.html"` becomes `card.html.liquid`.
73
+ const fileWithExt = hasExtension(file) ? file : `${file}${extension}`;
64
74
  const normalizedRoot = root.replace(/^\.\//, "").replace(/\/*$/, "/");
65
75
  const resolved = `${normalizedRoot}${fileWithExt}`;
66
76
  log("resolve:", { root, file, ext }, "->", resolved);
@@ -1,6 +1,7 @@
1
1
  // Builders for the `page` and `collections` globals on the shared Liquid
2
- // engine. Both return Promises that LiquidJS awaits at the globals level;
3
- // property access in templates is then synchronous on the resolved objects.
2
+ // engine. Both return Promises that LiquidJS awaits at the globals level.
3
+ // `page` resolves to a plain object; `collections` resolves to an object whose
4
+ // keys are lazy getters, so a template only pays for the collections it reads.
4
5
 
5
6
  import { apiLoadedPromise, CloudCannon } from "../../helpers/cloudcannon.mjs";
6
7
  import { getPageMap, normalizeInputPath } from "./page-map.mjs";
@@ -150,27 +151,74 @@ export async function buildPageData() {
150
151
  };
151
152
  }
152
153
 
153
- /** @type {Promise<Record<string, Array<any>>> | null} */
154
+ /**
155
+ * Ceiling on concurrent `file.data.get()` calls. One call per file over a
156
+ * collection of thousands fails with `ERR_INSUFFICIENT_RESOURCES` — a net-stack
157
+ * error, so each resolves to a request somewhere behind the editor API.
158
+ */
159
+ const MATERIALISE_CONCURRENCY = 24;
160
+
161
+ /**
162
+ * `Promise.all(items.map(fn))` with at most `limit` calls in flight. Results
163
+ * keep their input order.
164
+ *
165
+ * @template T, R
166
+ * @param {T[]} items
167
+ * @param {(item: T) => Promise<R>} fn
168
+ * @param {number} limit
169
+ * @returns {Promise<R[]>}
170
+ */
171
+ async function mapWithConcurrency(items, fn, limit) {
172
+ /** @type {R[]} */
173
+ const results = new Array(items.length);
174
+ let cursor = 0;
175
+
176
+ const worker = async () => {
177
+ while (cursor < items.length) {
178
+ const index = cursor++;
179
+ results[index] = await fn(items[index]);
180
+ }
181
+ };
182
+
183
+ await Promise.all(
184
+ Array.from({ length: Math.min(limit, items.length) }, worker),
185
+ );
186
+ return results;
187
+ }
188
+
189
+ /** One `CloudCannon.collections()` call, keyed by name. @type {Promise<Map<string, any>> | null} */
190
+ let collectionIndexCache = null;
191
+
192
+ /** Materialised items, per collection name. @type {Map<string, Promise<any[]>>} */
193
+ const collectionItemsCache = new Map();
194
+
195
+ /** @type {Promise<Record<string, any>> | null} */
154
196
  let collectionsCache = null;
155
197
 
156
198
  /** @type {Array<{ target: any, event: "change" | "delete", handler: () => void }>} */
157
199
  let collectionsSubscriptions = [];
158
200
 
159
201
  /**
160
- * Builds (or returns cached) the `collections` object, keyed by collection
161
- * name. Subscribes to `change`/`delete` on each collection and drops the cache
162
- * when either fires, so edits during a session are picked up on the next render.
202
+ * Enumerates the site's collections one API call, cached and subscribes to
203
+ * `change`/`delete` on each so an edit drops the caches. Never calls
204
+ * `collection.items()`: knowing the *names* is what lets the getters be
205
+ * enumerable without fetching behind them.
163
206
  *
164
- * @returns {Promise<Record<string, Array<any>>>}
207
+ * @returns {Promise<Map<string, any>>}
165
208
  */
166
- export function buildCollectionsData() {
167
- if (!collectionsCache) {
168
- collectionsCache = (async () => {
209
+ function loadCollectionIndex() {
210
+ if (!collectionIndexCache) {
211
+ collectionIndexCache = (async () => {
169
212
  await apiLoadedPromise;
170
213
  const allCollections = await CloudCannon?.collections?.();
171
- if (!allCollections?.length) return {};
214
+
215
+ /** @type {Map<string, any>} */
216
+ const index = new Map();
217
+ if (!allCollections?.length) return index;
172
218
 
173
219
  for (const collection of allCollections) {
220
+ index.set(collection.collectionKey, collection);
221
+
174
222
  const handler = () => resetCollectionsCache();
175
223
  collection.addEventListener("change", handler);
176
224
  collection.addEventListener("delete", handler);
@@ -179,31 +227,82 @@ export function buildCollectionsData() {
179
227
  { target: collection, event: "delete", handler },
180
228
  );
181
229
  }
230
+ return index;
231
+ })();
232
+ }
233
+ return collectionIndexCache;
234
+ }
235
+
236
+ /**
237
+ * Materialises one collection's files, memoised per name — the only place that
238
+ * issues per-file requests. An unknown name is `[]`, matching 11ty.
239
+ *
240
+ * @param {string} key
241
+ * @returns {Promise<any[]>}
242
+ */
243
+ function loadCollectionItems(key) {
244
+ let items = collectionItemsCache.get(key);
245
+ if (!items) {
246
+ items = (async () => {
247
+ const collection = (await loadCollectionIndex()).get(key);
248
+ if (!collection) return [];
182
249
 
183
- const entries = await Promise.all(
184
- allCollections.map(async (collection) => {
185
- const key = collection.collectionKey;
186
- let files;
187
- try {
188
- files = await collection.items();
189
- } catch {
190
- return /** @type {[string, any[]]} */ ([key, []]);
191
- }
192
- const items = await Promise.all(files.map(materialiseFile));
193
- return /** @type {[string, any[]]} */ ([key, items]);
194
- }),
250
+ let files;
251
+ try {
252
+ files = await collection.items();
253
+ } catch {
254
+ return [];
255
+ }
256
+ return mapWithConcurrency(
257
+ files,
258
+ materialiseFile,
259
+ MATERIALISE_CONCURRENCY,
195
260
  );
196
- return Object.fromEntries(entries);
261
+ })();
262
+ collectionItemsCache.set(key, items);
263
+ }
264
+ return items;
265
+ }
266
+
267
+ /**
268
+ * Builds (or returns cached) the `collections` object. Every key is a lazy
269
+ * getter returning a `Promise` of its items, which LiquidJS awaits during
270
+ * expression evaluation — so a component that never mentions `collections`
271
+ * issues no per-file requests.
272
+ *
273
+ * Getters not a Proxy: LiquidJS probes `next` and `toLiquid` on every object
274
+ * it resolves, and a blanket-getter Proxy answers those with a Promise, which
275
+ * breaks the lookup entirely.
276
+ *
277
+ * @returns {Promise<Record<string, any>>}
278
+ */
279
+ export function buildCollectionsData() {
280
+ if (!collectionsCache) {
281
+ collectionsCache = (async () => {
282
+ const index = await loadCollectionIndex();
283
+
284
+ /** @type {Record<string, any>} */
285
+ const collections = {};
286
+ for (const key of index.keys()) {
287
+ Object.defineProperty(collections, key, {
288
+ enumerable: true,
289
+ configurable: true,
290
+ get: () => loadCollectionItems(key),
291
+ });
292
+ }
293
+ return collections;
197
294
  })();
198
295
  }
199
296
  return collectionsCache;
200
297
  }
201
298
 
202
- /** Clears the collections cache and tears down its invalidation listeners. */
299
+ /** Clears every collections cache and tears down the invalidation listeners. */
203
300
  export function resetCollectionsCache() {
204
301
  for (const { target, event, handler } of collectionsSubscriptions) {
205
302
  target.removeEventListener(event, handler);
206
303
  }
207
304
  collectionsSubscriptions = [];
305
+ collectionIndexCache = null;
306
+ collectionItemsCache.clear();
208
307
  collectionsCache = null;
209
308
  }
@@ -73,8 +73,11 @@ export function registerLiquidComponent(key, contents) {
73
73
  /**
74
74
  * Wraps `window.cc_components` in a Proxy that resolves any component name on
75
75
  * demand via `{% include %}` — the primary resolution path. Names registered
76
- * via `registerLiquidComponent` take precedence. Call after
77
- * `createSharedLiquidEngine()`.
76
+ * via `registerLiquidComponent` take precedence.
77
+ *
78
+ * Call after `createSharedLiquidEngine()` and last of the `register*` calls:
79
+ * publishing `cc_components` is what tells the editor every helper is in
80
+ * place, and an empty one reads as a missing registration script.
78
81
  *
79
82
  * @returns {void}
80
83
  */
@@ -0,0 +1,28 @@
1
+ import { createApp, h } from "vue";
2
+ import { addEditableComponentRenderer } from "../helpers/cloudcannon.mjs";
3
+
4
+ /**
5
+ * Registers a Vue component with the CloudCannon component system.
6
+ * Creates a wrapper that renders the Vue component to an HTMLElement.
7
+ *
8
+ * @param {string} key - Unique identifier for the component
9
+ * @param {any} component - The Vue component to register
10
+ * @returns {void}
11
+ */
12
+ export const registerVueComponent = (key, component) => {
13
+ /**
14
+ * Wrapper function that renders the Vue component to an HTMLElement.
15
+ *
16
+ * @param {any} props - Props to pass to the Vue component
17
+ * @returns {HTMLElement} The rendered component as an HTMLElement
18
+ */
19
+ const wrappedComponent = (props) => {
20
+ const rootEl = document.createElement("div");
21
+ const app = createApp({ render: () => h(component, props) });
22
+ app.mount(rootEl);
23
+
24
+ return rootEl;
25
+ };
26
+
27
+ addEditableComponentRenderer(key, wrappedComponent);
28
+ };
@@ -1,7 +1,6 @@
1
1
  import "../components/ui/editable-array-item-controls.js";
2
2
  import type EditableArrayItemControls from "../components/ui/editable-array-item-controls.js";
3
3
  import {
4
- hasEditableArrayItem,
5
4
  isEditableArray,
6
5
  isEditableArrayItem,
7
6
  isEditableElement,
@@ -10,6 +9,12 @@ import { CloudCannon, realizeAPIValue } from "../helpers/cloudcannon.mjs";
10
9
  import type EditableArray from "./editable-array.js";
11
10
  import EditableComponent from "./editable-component.js";
12
11
 
12
+ export const hasEditableArrayItem = <T extends object>(
13
+ el: T,
14
+ ): el is T & { editable: EditableArrayItem } => {
15
+ return "editable" in el && el.editable instanceof EditableArrayItem;
16
+ };
17
+
13
18
  export default class EditableArrayItem extends EditableComponent {
14
19
  parent: EditableArray | null = null;
15
20
 
@@ -1,11 +1,10 @@
1
1
  import {
2
2
  areEqualEditables,
3
- hasEditable,
4
- hasEditableText,
5
3
  isEditableElement,
6
4
  isEditableText,
7
5
  } from "../helpers/checks.js";
8
- import Editable from "./editable.js";
6
+ import Editable, { hasEditable } from "./editable.js";
7
+ import { hasEditableText } from "./editable-text.js";
9
8
  import "../components/ui/editable-region-error-card.js";
10
9
  import "../components/ui/editable-component-controls.js";
11
10
  import type EditableComponentControls from "../components/ui/editable-component-controls.js";