@cloudcannon/editable-regions 0.0.16 → 0.0.18

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