@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.
- package/helpers/checks.ts +0 -22
- package/helpers/hydrate-editable-regions.ts +2 -1
- package/integrations/astro/react-renderer.mjs +94 -19
- package/integrations/astro/svelte-renderer.mjs +72 -15
- package/integrations/astro/vue-renderer.mjs +61 -0
- package/integrations/eleventy/browser/collect-config.mjs +69 -8
- package/integrations/eleventy/browser/inert.mjs +35 -0
- package/integrations/eleventy/browser/process-shim.mjs +32 -0
- package/integrations/eleventy/browser/stub-mode.mjs +61 -0
- package/integrations/eleventy/index.cjs +28 -1
- package/integrations/eleventy/index.mjs +82 -30
- package/integrations/liquid/README.md +92 -3
- package/integrations/liquid/errors.mjs +3 -1
- package/integrations/liquid/fs.mjs +11 -1
- package/integrations/liquid/globals.mjs +124 -25
- package/integrations/liquid/index.mjs +5 -2
- package/integrations/vue.mjs +28 -0
- package/nodes/editable-array-item.ts +6 -1
- package/nodes/editable-component.ts +2 -3
- package/nodes/editable-text.ts +6 -0
- package/nodes/editable.ts +6 -1
- package/package.json +120 -90
- package/types/astro.d.ts +4 -0
- package/types/eleventy.d.ts +11 -4
- package/types/liquid.d.ts +0 -1
- package/types/vue.d.ts +40 -0
|
@@ -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
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
628
|
+
imports += `\nimport ${id} from "./${file}";\n`;
|
|
629
|
+
body += `${registerFn}(${JSON.stringify(name)}, ${id});\n`;
|
|
578
630
|
}
|
|
579
631
|
}
|
|
580
632
|
|
|
581
|
-
return
|
|
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
|
|
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 |
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
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<
|
|
207
|
+
* @returns {Promise<Map<string, any>>}
|
|
165
208
|
*/
|
|
166
|
-
|
|
167
|
-
if (!
|
|
168
|
-
|
|
209
|
+
function loadCollectionIndex() {
|
|
210
|
+
if (!collectionIndexCache) {
|
|
211
|
+
collectionIndexCache = (async () => {
|
|
169
212
|
await apiLoadedPromise;
|
|
170
213
|
const allCollections = await CloudCannon?.collections?.();
|
|
171
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
77
|
-
*
|
|
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";
|