@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,677 @@
1
+ # Liquid live-editing runtime
2
+
3
+ Browser-side Liquid engine used by the CloudCannon Visual Editor. The Eleventy
4
+ plugin (`integrations/eleventy/index.mjs`) generates a `register-components.js` bundle at
5
+ build time; this directory is what that bundle pulls in.
6
+
7
+ ## Contents
8
+
9
+ - [What this is for](#what-this-is-for)
10
+ - [Install and configure](#install-and-configure)
11
+ - [Plugin options at a glance](#plugin-options-at-a-glance)
12
+ - [How it fits together](#how-it-fits-together)
13
+ - [Globals](#globals)
14
+ - [`page` properties](#page-properties)
15
+ - [Custom globals](#custom-globals)
16
+ - [Eleventy global](#eleventy-global)
17
+ - [`pkg` global](#pkg-global)
18
+ - [Filters](#filters)
19
+ - [What the auto-mirror actually does](#what-the-auto-mirror-actually-does)
20
+ - [Adding a custom filter](#adding-a-custom-filter)
21
+ - [Overriding a built-in](#overriding-a-built-in)
22
+ - [Shortcodes and paired shortcodes](#shortcodes-and-paired-shortcodes)
23
+ - [Adding a custom shortcode](#adding-a-custom-shortcode)
24
+ - [Tags](#tags)
25
+ - [Built-in tags](#built-in-tags)
26
+ - [RenderPlugin shims](#renderplugin-shims)
27
+ - [Component resolution](#component-resolution)
28
+ - [Virtual filesystem](#virtual-filesystem)
29
+ - [Error enhancement](#error-enhancement)
30
+ - [Limitations and fallbacks](#limitations-and-fallbacks)
31
+ - [Things that don't work in live editing](#things-that-dont-work-in-live-editing)
32
+ - [Patterns](#patterns)
33
+
34
+ ## What this is for
35
+
36
+ This integration powers **live-editing of components** inside the CloudCannon
37
+ Visual Editor. The "component" is the unit: a Liquid partial (e.g.
38
+ `_includes/card.liquid`) that the editor re-renders client-side as the user
39
+ edits its data, without round-tripping through Eleventy.
40
+
41
+ It is **not** a full client-side replacement for Eleventy. Pages are still
42
+ built by Eleventy and served as static HTML; this runtime only re-renders the
43
+ components the editor swaps in. Anything belonging to the page lifecycle
44
+ (permalinks, layouts, build filters, output paths) is shimmed approximately or
45
+ not implemented — see "Limitations and fallbacks".
46
+
47
+ ## Install and configure
48
+
49
+ ```sh
50
+ npm install @cloudcannon/editable-regions
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
+
67
+ Wire the plugin into your `eleventy.config.mjs`. The minimal case is one
68
+ line — Liquid is the plugin's default language and is enabled implicitly:
69
+
70
+ ```js
71
+ import editableRegions from "@cloudcannon/editable-regions/eleventy";
72
+
73
+ export default function (eleventyConfig) {
74
+ // your existing filters, shortcodes, collections, etc.
75
+
76
+ eleventyConfig.addPlugin(editableRegions);
77
+
78
+ return {
79
+ dir: { input: "src", includes: "_includes", output: "_site" },
80
+ };
81
+ }
82
+ ```
83
+
84
+ To customise — environment variables, output path, or Liquid-specific
85
+ options:
86
+
87
+ ```js
88
+ eleventyConfig.addPlugin(editableRegions, {
89
+ liquid: {
90
+ extensions: [".liquid"],
91
+ // see "Adding custom …" sections below for filter / shortcode / tag overrides
92
+ },
93
+ globals: { // optional — see "Custom globals"
94
+ env: { API_BASE: process.env.API_BASE },
95
+ },
96
+ });
97
+ ```
98
+
99
+ `liquid` accepts `true` (defaults), `false` (disable Liquid live editing),
100
+ or an options object. Future languages will follow the same shape but
101
+ default to off — users will opt in via e.g. `nunjucks: true`.
102
+
103
+ After every build, the plugin emits `register-components.js` into your
104
+ output directory. The filename and location are configurable via the
105
+ `output` plugin option (see the table below) — the default sits next to
106
+ the rest of your built assets so it's reachable as
107
+ `/register-components.js`.
108
+
109
+ Load it on every page the Visual Editor will render against, guarded on
110
+ the editor's runtime flag so production pages don't pay the cost outside
111
+ the editor:
112
+
113
+ ```html
114
+ <script>
115
+ if (window.inEditorMode) {
116
+ import("/register-components.js").catch((error) => {
117
+ console.warn("Failed to load CloudCannon component registration:", error);
118
+ });
119
+ }
120
+ </script>
121
+ ```
122
+
123
+ `window.inEditorMode` is set to `true` by the CloudCannon Visual Editor
124
+ before page scripts run; outside the editor it's `undefined`, so the
125
+ dynamic import never fires. If you'd rather always load the bundle
126
+ (useful while iterating locally), a plain `<script src="/register-components.js" defer>`
127
+ works too.
128
+
129
+ ### Plugin options at a glance
130
+
131
+ | Option | Purpose |
132
+ | --- | --- |
133
+ | `output` | Where to write the bundle. Defaults to `register-components.js` inside Eleventy's `dir.output`. |
134
+ | `verbose` | Enable verbose browser logging. |
135
+ | `globals` | Extra globals to expose to editor-rendered templates (JSON-serialisable). See "Custom globals". |
136
+ | `liquid.extensions` | Template file extensions to bundle. Defaults to `[".liquid", ".html"]`. |
137
+ | `liquid.componentDirs` | Directories to walk for component templates. Defaults to `[directories.includes, directories.input]`. |
138
+ | `liquid.ignoreDirectories` | Directory names to skip when walking. Defaults to `[directories.output, "node_modules"]`. |
139
+ | `liquid.components` | Map of component name → module path. Wins over the filesystem-resolution proxy. |
140
+ | `liquid.filters` | Map of filter name → module path. Browser-side override. See "Adding a custom filter". |
141
+ | `liquid.shortcodes` | Map of shortcode name → module path. Browser-side override. See "Adding a custom shortcode". |
142
+ | `liquid.pairedShortcodes` | Same as `shortcodes`, for paired shortcodes. |
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. |
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`. |
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". |
146
+
147
+ ## How it fits together
148
+
149
+ After every build, the plugin emits a single `register-components.js` bundle
150
+ that the Visual Editor loads. Two things are picked up at build time and wired
151
+ into that bundle:
152
+
153
+ - **Filters, shortcodes, and tags** — auto-mirrored from your Eleventy config
154
+ (see "Filters" below).
155
+ - **Components** — every template under the configured component directories
156
+ (`liquid.componentDirs`, defaulting to `dir.includes` and `dir.input`),
157
+ matching `liquid.extensions`.
158
+
159
+ In the browser, the bundle instantiates a shared Liquid engine, registers
160
+ everything, and resolves each component on demand via `{% include %}`. See
161
+ "Component resolution" for how component names map to templates.
162
+
163
+ ## Globals
164
+
165
+ Globals are passed to `new Liquid({ globals })` inside `createSharedLiquidEngine`:
166
+
167
+ | Global | Status | Notes |
168
+ | --- | --- | --- |
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. |
170
+ | `ENV_CLIENT` | Implemented | Always `true` in this bundle. Templates can branch on it to opt out of build-only logic. |
171
+ | `page` | Partial | `Proxy` backed by `CloudCannon.currentFile()`. See below for which properties are supported. |
172
+ | custom globals | Opt-in | Whatever you pass via `pluginOptions.globals` (e.g. an `env` object), embedded at build time. See "Custom globals" below. |
173
+ | `eleventy` | Partial | Static object built at build time. See "Eleventy global" below. |
174
+ | `pkg` | Implemented | Project `package.json`, mirrored verbatim. See "`pkg` global" below. |
175
+
176
+ ### `page` properties
177
+
178
+ Each property access returns a Promise; liquidjs awaits as part of normal
179
+ expression evaluation. Source is `CloudCannon.currentFile()` and its front
180
+ matter (`file.data.get()`).
181
+
182
+ | Property | Source | Notes |
183
+ | --- | --- | --- |
184
+ | `inputPath` | `currentFile().path` | Project-relative; may differ slightly from Eleventy's `./input-dir/...` form. |
185
+ | `fileSlug` | derived from `path` | Basename minus extension. |
186
+ | `filePathStem` | derived from `path` | Full path minus extension, with a leading `/`. |
187
+ | `outputFileExtension` | constant `"html"` | We don't model custom output extensions. |
188
+ | `date` | front matter `date` | Coerced to a `Date`. Returns `undefined` if absent or unparseable; we can't see file mtime / git history from the browser. |
189
+ | `url` | live `permalink`, else build-time page map, else folder-style derivation | Priority: a *literal* front-matter `permalink` (captures editor-time edits) → build-time page-map lookup → 11ty's folder-style default. A `permalink` containing template syntax (e.g. `"/{{ page.date \| date: '%Y/%m/%d' }}/"`) is skipped here and resolved via the page map, which holds 11ty's already-rendered value. |
190
+ | `outputPath` | live `permalink` joined with `directories.output`, else build-time page map, else folder-style default joined with `directories.output` | Same priority hierarchy as `url` (templated permalinks likewise fall through to the page map). Build-map lookup uses 11ty's exact `outputPath` (so `index.html` joining matches what 11ty wrote). Returns `undefined` only if neither the map nor `registerEleventyData` have run. |
191
+ | `templateSyntax` | — | Unimplemented. |
192
+ | `lang` | — | Unimplemented (would need the i18n plugin's runtime state). |
193
+
194
+ ### Custom globals
195
+
196
+ 11ty doesn't expose `process.env` to templates — global data reaches them by
197
+ name instead (a `_data/env.js` file becomes `{{ env.* }}`, or
198
+ `addGlobalData("env", …)`). The live-editing runtime doesn't auto-load your
199
+ global data, so anything a component reads that isn't `page` / `collections` /
200
+ `eleventy` / `pkg` has to be passed in explicitly via `pluginOptions.globals`.
201
+ Mirror whatever your build already exposes, so the editor and build agree:
202
+
203
+ ```js
204
+ const env = { API_BASE: process.env.API_BASE };
205
+
206
+ eleventyConfig.addGlobalData("env", env); // server-side build
207
+
208
+ eleventyConfig.addPlugin(editableRegions, {
209
+ globals: { env }, // live editing
210
+ });
211
+ ```
212
+
213
+ Templates then read it by name, identically in both places:
214
+
215
+ ```liquid
216
+ <a href="{{ env.API_BASE }}">…</a>
217
+ ```
218
+
219
+ The object is embedded into the bundle as a JSON literal at build time, so
220
+ values must be JSON-serialisable (no functions). The built-in globals
221
+ (`page`, `collections`, `eleventy`, `pkg`) are applied separately and win on a
222
+ name collision.
223
+
224
+ > ⚠️ **Never include secrets.** Anything in `globals` is embedded verbatim
225
+ > into the static JS bundle the browser downloads. Treat it like Vite's
226
+ > `PUBLIC_` or Next's `NEXT_PUBLIC_` convention: public-by-design only. Keep
227
+ > API keys, tokens, signing secrets, and database URLs out of it.
228
+
229
+ ### Eleventy global
230
+
231
+ A static `eleventy` object is registered alongside `collections` and `page`,
232
+ built once at build time:
233
+
234
+ | Property | Source | Notes |
235
+ | --- | --- | --- |
236
+ | `eleventy.version` | resolved from `@11ty/eleventy/package.json` | Falls back to `"unknown"` if Eleventy can't be resolved (so the bundle still builds). |
237
+ | `eleventy.generator` | `"Eleventy v" + version` | Useful in feed/sitemap templates. |
238
+ | `eleventy.env.runMode` | hardcoded `"serve"` | We're not in any of 11ty's real run modes; "serve" is the dev-mode analogue. Templates branching on `runMode` see this as the "live" path. |
239
+ | `eleventy.env.source` | hardcoded `"cli"` | Same idea — pick the most-common analogue so branches don't go down a build-only path. |
240
+ | `eleventy.env.config` / `env.root` | — | Deliberately omitted. These are absolute filesystem paths and have no place in client JS. |
241
+ | `eleventy.directories` | from the build's `directories` payload | `{ input, includes, data, output }`. |
242
+ | `eleventy.serverless` | — | Deprecated upstream, not shipped. |
243
+
244
+ ### `pkg` global
245
+
246
+ 11ty exposes the project's `package.json` as the `pkg` global by default
247
+ (`config.keys.package = "pkg"`). We mirror it verbatim — `pkg.name`,
248
+ `pkg.version`, `pkg.description`, `pkg.author`, `pkg.homepage`, and any other
249
+ top-level fields the consumer has set are available in editable templates the
250
+ same way they are server-side.
251
+
252
+ If `package.json` is missing or malformed at build time, the bundle skips
253
+ `registerPkg` entirely and `pkg` is `undefined` in templates.
254
+
255
+
256
+ ## Filters
257
+
258
+ Filters come from three sources, resolved in order so later sources win on
259
+ name collision: **built-ins**, **auto-mirrored**, then **overrides**.
260
+
261
+ 1. **Built-ins.** Browser-safe reimplementations of common Eleventy built-ins:
262
+ `slugify`/`slug`, `url`, the date filters, `getNewestCollectionItemDate`,
263
+ the four collection-item filters, and `log`. `inputPathToUrl` is backed by
264
+ the build-time page map, so it resolves the correct URL for any file in the
265
+ last build, including computed permalinks. `renderContent`
266
+ is a real shim (see "RenderPlugin shims"). Filters that depend on
267
+ build-time-only state we don't model (`htmlBaseUrl`, `serverlessUrl`) are
268
+ warn-once pass-throughs that return their input unchanged.
269
+
270
+ 2. **Auto-mirrored from your Eleventy config.** The bundle imports your real
271
+ config and replays it in the browser, capturing every `addFilter` /
272
+ `addAsyncFilter` / `addLiquidFilter` call. Because the config is bundled
273
+ (not serialized), each function keeps its closures and imports. A filter
274
+ that depends on Eleventy build-time state (`this.ctx`) or calls a Node API
275
+ at render time will throw when invoked in the browser — the signal to add
276
+ an override.
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
+
287
+ 3. **Overrides** (`pluginOptions.liquid.filters`). A map from filter name to
288
+ module path. Two reasons to use this:
289
+ - **A mirrored filter throws at render time** — supply a browser-safe
290
+ replacement here.
291
+ - **You're overriding a built-in name.** The auto-mirror skips built-in
292
+ names to protect our browser ports, so an
293
+ `eleventyConfig.addFilter("url", …)` won't reach live editing unless you
294
+ also register it here.
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
+
359
+ ### Adding a custom filter
360
+
361
+ For most filters you don't need to do anything — registering with Eleventy
362
+ the normal way is enough; the auto-mirror picks it up.
363
+
364
+ ```js
365
+ // eleventy.config.mjs
366
+ eleventyConfig.addFilter("shout", (s) => String(s).toUpperCase());
367
+ // → available in live editing as `{{ "hi" | shout }}` automatically
368
+ ```
369
+
370
+ If your filter touches `this.ctx`, `process`, `require`, `__dirname`, or a
371
+ closed-over Node module, the auto-mirror will ship it but it'll throw at
372
+ render time in the browser. Surface the actionable path by adding an
373
+ override:
374
+
375
+ ```js
376
+ // eleventy.config.mjs
377
+ import { readFileSync } from "node:fs";
378
+ eleventyConfig.addFilter("siteConfig", (key) => {
379
+ // Reads from disk — fine server-side, throws in the browser.
380
+ return JSON.parse(readFileSync("./site-config.json", "utf8"))[key];
381
+ });
382
+
383
+ eleventyConfig.addPlugin(editableRegions, {
384
+ liquid: {
385
+ filters: {
386
+ siteConfig: "./live-editing-overrides/site-config.mjs",
387
+ },
388
+ },
389
+ });
390
+ ```
391
+
392
+ ```js
393
+ // live-editing-overrides/site-config.mjs
394
+ import config from "../site-config.json"; // esbuild inlines this at build time
395
+ export default function siteConfig(key) {
396
+ return config[key];
397
+ }
398
+ ```
399
+
400
+ The override module's default export is registered against the live-editing
401
+ engine in place of the original. The Eleventy server-side filter is
402
+ untouched.
403
+
404
+ > Aside: if you have an existing filter that returns the current page's URL
405
+ > via `this.page.url`, your template can use the `page` global directly
406
+ > instead — `{{ page.url }}` works server-side and in the editor. Avoid
407
+ > writing a browser-side override that reads `location.pathname`; inside
408
+ > CloudCannon's Visual Editor that returns CC's editor-shell URL, not the site URL.
409
+
410
+ ### Overriding a built-in
411
+
412
+ If you replace a built-in name in your Eleventy config —
413
+ `eleventyConfig.addFilter("url", myCustomUrl)` — your replacement applies
414
+ server-side, but the live-editing bundle still uses our handwritten port
415
+ (the auto-mirror skips built-in names). To make the override apply in the
416
+ bundle too, register a second time via `pluginOptions.liquid.filters`:
417
+
418
+ ```js
419
+ // eleventy.config.mjs
420
+ eleventyConfig.addFilter("url", myCustomUrl); // server-side
421
+
422
+ eleventyConfig.addPlugin(editableRegions, {
423
+ liquid: {
424
+ filters: {
425
+ url: "./live-editing-overrides/url.mjs", // live editing
426
+ },
427
+ },
428
+ });
429
+ ```
430
+
431
+ This only applies to built-in names: the auto-mirror skips them to protect our
432
+ browser ports, so the second registration is what unlocks your override.
433
+
434
+ ## Shortcodes and paired shortcodes
435
+
436
+ Same built-ins / auto-mirrored / overrides model as filters: anything
437
+ registered via `addShortcode` / `addAsyncShortcode` / `addLiquidShortcode`
438
+ (and the paired equivalents) is mirrored with closures intact, and a
439
+ non-portable one throws at render time, prompting an override under
440
+ `pluginOptions.liquid.shortcodes` / `pluginOptions.liquid.pairedShortcodes`.
441
+
442
+ The only built-in shortcode is `renderFile`, one of the three RenderPlugin
443
+ shims — `renderContent` is a [filter](#filters) and `renderTemplate` is a
444
+ [tag](#built-in-tags). See [RenderPlugin shims](#renderplugin-shims).
445
+
446
+ ### Adding a custom shortcode
447
+
448
+ Like filters: register with Eleventy as normal and the auto-mirror handles
449
+ it.
450
+
451
+ ```js
452
+ // eleventy.config.mjs
453
+ eleventyConfig.addShortcode("year", () => new Date().getFullYear());
454
+ // → `{% year %}` works in live editing automatically
455
+ ```
456
+
457
+ For paired shortcodes, use `addPairedShortcode`:
458
+
459
+ ```js
460
+ eleventyConfig.addPairedShortcode("highlight", (content, color = "yellow") =>
461
+ `<mark style="background:${color}">${content}</mark>`,
462
+ );
463
+ // → {% highlight "lime" %}note{% endhighlight %}
464
+ ```
465
+
466
+ If a shortcode reads from Eleventy's runtime state (`this.page`,
467
+ `this.ctx`, etc.), provide a browser override via
468
+ `pluginOptions.liquid.shortcodes` or `pluginOptions.liquid.pairedShortcodes`,
469
+ same shape as the filter override above.
470
+
471
+ ## Tags
472
+
473
+ Same auto-mirror + override model as filters and shortcodes. A tag
474
+ registered with `addLiquidTag` is replayed from the bundled config, so the
475
+ factory and everything it closes over — including LiquidJS internals like
476
+ `Tokenizer` / `evalToken` / `toPromise` — survive into the browser with no
477
+ extra work:
478
+
479
+ ```js
480
+ // eleventy.config.mjs
481
+ eleventyConfig.addLiquidTag("echo", echoTagFactory);
482
+ // → `{% echo %}` works in live editing automatically
483
+ ```
484
+
485
+ The factory is the value `addLiquidTag` expects:
486
+ `(liquidEngine) => ({ parse, render })`.
487
+
488
+ Override only a tag that can't run in the browser as written, via
489
+ `pluginOptions.liquid.tags` (tag name → module path, default-exporting the
490
+ same factory shape). The override's name is skipped by the auto-mirror so
491
+ the override is the sole registration:
492
+
493
+ ```js
494
+ liquid: {
495
+ tags: {
496
+ myTag: "./src/live-editing/my-tag.mjs",
497
+ },
498
+ }
499
+ ```
500
+
501
+ If a template references an unregistered tag, `enhanceLiquidError` rewrites
502
+ LiquidJS's "tag X not found" into an actionable message pointing the user at
503
+ `pluginOptions.liquid.tags`.
504
+
505
+ ### Built-in tags
506
+
507
+ The runtime registers a few tags of its own at engine creation time. Users
508
+ don't have to do anything to get these.
509
+
510
+ **`includeWith`** — spreads an object into an include the way Astro's
511
+ `{...props}` does. Wired up in both the Eleventy build (so server-rendered
512
+ output works) and `createSharedLiquidEngine` (so live editing matches).
513
+ Pass a variable that references the object you want to spread — front
514
+ matter, an `assign`-ed name, or a global like `page`:
515
+
516
+ ```liquid
517
+ {% includeWith "components/card", cardProps %}
518
+ ```
519
+
520
+ The second argument must be a variable reference; inline object literals
521
+ (`{ key: value }`) aren't standard Liquid syntax and aren't supported.
522
+
523
+ **`renderTemplate`** — RenderPlugin shim. A paired tag that compiles the
524
+ body as a Liquid template and renders it against the supplied data. Same
525
+ constraint as `includeWith`: the data argument must be a variable
526
+ reference.
527
+
528
+ ```liquid
529
+ {% renderTemplate "liquid", templateData %}
530
+ Hello {{ name }}
531
+ {% endrenderTemplate %}
532
+ ```
533
+
534
+ Only `"liquid"` and `"html"` engines are supported in the browser (other
535
+ engines warn once and return the body unchanged). See `eleventy/browser/liquid-render.mjs`.
536
+
537
+ `renderFile` and `renderContent` are also part of the RenderPlugin shim —
538
+ documented in the next section since they're shortcode/filter rather than
539
+ tag-shaped.
540
+
541
+ ### RenderPlugin shims
542
+
543
+ Eleventy's `RenderPlugin` registers three template-side helpers. We
544
+ reimplement all three in the browser, scoped to the engines we actually
545
+ run there.
546
+
547
+ > **Server-side note:** 11ty 3.x ships `RenderPlugin` but doesn't auto-load
548
+ > it. If you want the helpers to work in your Eleventy build (in addition
549
+ > to live editing), explicitly add it in `eleventy.config.mjs`:
550
+ >
551
+ > ```js
552
+ > import { EleventyRenderPlugin } from "@11ty/eleventy";
553
+ > eleventyConfig.addPlugin(EleventyRenderPlugin);
554
+ > ```
555
+ >
556
+ > Our browser-side shims work either way.
557
+
558
+
559
+ | Helper | Shape | Usage |
560
+ | --- | --- | --- |
561
+ | `renderTemplate` | paired Liquid tag | `{% renderTemplate "liquid", data %}…{% endrenderTemplate %}` |
562
+ | `renderFile` | async shortcode | `{% renderFile "path/to/file.liquid", data %}` |
563
+ | `renderContent` | async filter | `{{ rawString \| renderContent: "liquid", data }}` |
564
+
565
+ All three share the same behaviour: `"liquid"` (or unspecified) → real
566
+ parse-and-render through the shared engine; `"html"` → identity
567
+ passthrough; any other engine → warn-once and return the body unchanged.
568
+ `renderFile` fetches the target via the CloudCannon Visual Editor API
569
+ (`CloudCannon.file(path).content.get()`), which returns the file body with
570
+ front matter stripped — matching how Eleventy feeds a template body to its
571
+ engine. Any file the editor can see is reachable, not just files inside a
572
+ configured `componentDir`. (`{% include %}` is the separate path: it goes
573
+ through LiquidJS's filesystem, which is the build-time `cc_liquid_files` map.)
574
+
575
+ ## Component resolution
576
+
577
+ Components are accessed as `window.cc_components[name](props)`. There are
578
+ two resolution paths; the proxy is the primary one and the explicit map is
579
+ the override.
580
+
581
+ 1. **Include-resolution proxy** (the primary path). For any unrecognised name,
582
+ the proxy returns a renderer that runs `{% include "<name>" %}` against the
583
+ shared engine, which resolves the file via the configured component
584
+ directories and `extensions`. This is how every auto-discovered component
585
+ becomes reachable with no explicit registration.
586
+ 2. **Explicit registrations** via `pluginOptions.liquid.components` — a map of
587
+ `name -> module path`. The module's default export is treated as Liquid
588
+ template source for that name, taking precedence over include resolution.
589
+ Use this to substitute a different template for a specific name.
590
+
591
+ Both paths render to a detached `<div>` and return it as an `HTMLElement`.
592
+
593
+ ## Virtual filesystem
594
+
595
+ Two data sources back the runtime:
596
+
597
+ - **`window.cc_liquid_files`** — a build-time snapshot of your templates,
598
+ serving `{% include %}` resolution synchronously.
599
+ - **CloudCannon Visual Editor API** (`CloudCannon.currentFile()`,
600
+ `CloudCannon.file(path)`, `CloudCannon.collection(key)`) — a live view of the
601
+ editor's file tree, backing the `page` / `collections` globals and
602
+ `renderFile`.
603
+
604
+ When in doubt, prefer the API: it sees everything the editor sees and stays
605
+ correct as the user edits.
606
+
607
+ ## Error enhancement
608
+
609
+ Three categories of LiquidJS error are rewritten into actionable messages with
610
+ the component/template name and a concrete next step:
611
+
612
+ - Unknown filter → "register it in the `filters` option"
613
+ - Missing template (`ENOENT …`) → "check the file is in your component dirs"
614
+ - Unknown tag → "register it in `tags`, `shortcodes`, or `pairedShortcodes`"
615
+
616
+ Anything else falls through with the component name prefixed.
617
+
618
+ ## Limitations and fallbacks
619
+
620
+ The runtime can't reproduce everything Eleventy does at build time. This
621
+ section catalogues the gaps and the patterns for working around them.
622
+
623
+ ### Things that don't work in live editing
624
+
625
+ | Area | What happens | Fallback |
626
+ | --- | --- | --- |
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. |
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. |
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. |
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. |
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. |
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". |
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`. |
636
+ | `page.templateSyntax`, `page.lang` | `undefined`. | If you need them, read from front matter / `_data/` instead, or skip the branch via `ENV_CLIENT`. |
637
+ | `page.date` from file mtime / git history | `undefined` if not in front matter. | Set `date:` in front matter. |
638
+ | `eleventy.env.config`, `eleventy.env.root` | Deliberately omitted (absolute filesystem paths). | Don't reference these from a component. |
639
+ | `eleventy.env.runMode`, `eleventy.env.source` | Hardcoded to `"serve"` / `"cli"`. | If you need a "we're in the editor" branch, use `ENV_CLIENT` instead. |
640
+ | `pagination`, `eleventy.serverless` | Not exposed. | Pagination is a build-time-only data cascade; serverless was removed upstream. |
641
+ | Layout files | Not rendered by the live runtime; the page's HTML stays as Eleventy built it. | Layout-dependent logic should live in the component, not the layout, if you want it editable. |
642
+
643
+ ### Patterns
644
+
645
+ **Branching on "are we in the editor?".** Use the `ENV_CLIENT` global, which
646
+ is `true` in the live-editing bundle and `false`/undefined during the
647
+ Eleventy build:
648
+
649
+ ```liquid
650
+ {% if ENV_CLIENT %}
651
+ <p>Editing — placeholder shown.</p>
652
+ {% else %}
653
+ {{ collections.posts | someBuildOnlyFilter }}
654
+ {% endif %}
655
+ ```
656
+
657
+ This is the right escape hatch for build-only logic that you don't want
658
+ running in the editor at all.
659
+
660
+ **Overriding a single filter / shortcode / tag with a browser version.**
661
+ Point the relevant `pluginOptions.liquid.{filters,shortcodes,pairedShortcodes,tags}`
662
+ entry at a module that default-exports a browser-safe replacement. The
663
+ override only applies to live editing — your Eleventy server-side
664
+ registration keeps working unchanged.
665
+
666
+ **Replacing an entire component for live editing.** If a single component
667
+ has too many incompatibilities to override piecemeal, register a
668
+ component-specific renderer via `pluginOptions.liquid.components`:
669
+ the module's default export is treated as Liquid template source for that
670
+ component name, fully replacing what's on disk.
671
+
672
+ **When you need data the shims don't have.** Pull from `_data/` (which
673
+ becomes the front matter / data cascade and is readable via the
674
+ `collections` proxy), or from the CloudCannon JS API directly in a custom
675
+ tag or filter override. The Visual Editor exposes `currentFile()`,
676
+ `collection(key)`, `dataset(key)`, and `file(path)` — see the existing
677
+ `page` proxy in `globals.mjs` for a reference implementation.