@cloudcannon/editable-regions 0.0.18 → 0.0.20-rc.1

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 (45) hide show
  1. package/helpers/checks.ts +0 -22
  2. package/helpers/hydrate-editable-regions.ts +2 -1
  3. package/integrations/astro/react-renderer.mjs +94 -19
  4. package/integrations/astro/svelte-renderer.mjs +72 -15
  5. package/integrations/astro/vue-renderer.mjs +61 -0
  6. package/integrations/eleventy/browser/collect-config.mjs +69 -8
  7. package/integrations/eleventy/browser/inert.mjs +35 -0
  8. package/integrations/eleventy/browser/process-shim.mjs +32 -0
  9. package/integrations/eleventy/browser/stub-mode.mjs +61 -0
  10. package/integrations/eleventy/index.cjs +28 -1
  11. package/integrations/eleventy/index.mjs +82 -30
  12. package/integrations/hugo/browser/entry.js +13 -0
  13. package/integrations/hugo/browser/errors.ts +41 -0
  14. package/integrations/hugo/browser/index.ts +344 -0
  15. package/integrations/hugo/browser/logger.ts +42 -0
  16. package/integrations/hugo/browser/wasm_exec.js +575 -0
  17. package/integrations/hugo/hugo-module/layouts/partials/editable-regions/find-config-files.html +14 -0
  18. package/integrations/hugo/hugo-module/layouts/partials/editable-regions/find-dep-template-files.html +66 -0
  19. package/integrations/hugo/hugo-module/layouts/partials/editable-regions/find-files-with-extension.html +27 -0
  20. package/integrations/hugo/hugo-module/layouts/partials/editable-regions/find-template-files.html +51 -0
  21. package/integrations/hugo/hugo-module/layouts/partials/editable-regions/load-deps.html +5 -0
  22. package/integrations/hugo/hugo-module/layouts/partials/editable-regions/normalize-extensions.html +9 -0
  23. package/integrations/hugo/hugo-module/layouts/partials/editable-regions/resources.html +87 -0
  24. package/integrations/hugo/hugo-module/layouts/partials/editable-regions/wasm-url.html +28 -0
  25. package/integrations/hugo/hugo-module/layouts/partials/editable-regions.html +6 -0
  26. package/integrations/hugo/renderer/build.sh +24 -0
  27. package/integrations/hugo/renderer/go.mod +100 -0
  28. package/integrations/hugo/renderer/go.sum +241 -0
  29. package/integrations/hugo/renderer/main.go +412 -0
  30. package/integrations/liquid/README.md +92 -3
  31. package/integrations/liquid/errors.mjs +3 -1
  32. package/integrations/liquid/fs.mjs +11 -1
  33. package/integrations/liquid/globals.mjs +131 -26
  34. package/integrations/liquid/index.mjs +5 -2
  35. package/integrations/vue.mjs +28 -0
  36. package/nodes/editable-array-item.ts +6 -1
  37. package/nodes/editable-component.ts +2 -3
  38. package/nodes/editable-text.ts +6 -0
  39. package/nodes/editable.ts +6 -1
  40. package/package.json +132 -90
  41. package/types/astro.d.ts +4 -0
  42. package/types/eleventy.d.ts +11 -4
  43. package/types/hugo.d.ts +69 -0
  44. package/types/liquid.d.ts +0 -1
  45. package/types/vue.d.ts +40 -0
@@ -0,0 +1,412 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "log"
9
+ "path/filepath"
10
+ "strings"
11
+ "syscall/js"
12
+ _ "time/tzdata"
13
+
14
+ "github.com/fsnotify/fsnotify"
15
+ "github.com/gohugoio/hugo/common/hmaps"
16
+ "github.com/gohugoio/hugo/config"
17
+ "github.com/gohugoio/hugo/config/allconfig"
18
+ "github.com/gohugoio/hugo/deps"
19
+ "github.com/gohugoio/hugo/hugofs"
20
+ "github.com/gohugoio/hugo/hugolib"
21
+ "github.com/gohugoio/hugo/parser/metadecoders"
22
+ "github.com/gohugoio/hugo/resources/page"
23
+ "github.com/spf13/afero"
24
+ )
25
+
26
+ // dispatchView is the view renderBatch executes for each batch: the batch's
27
+ // requests arrive as template data (keyed by render id), and each request's
28
+ // partial renders with `page` bound through the execution context, keying
29
+ // every result with its request id for the browser to demultiplex.
30
+ const dispatchView = `{{- with .cc_requests -}}
31
+ {{- /* cc_requests is a MAP keyed by render id, prepared on the Go side with
32
+ Hugo's params preparation: it recurses into nested maps (not arrays),
33
+ keeping case-insensitive Params semantics for props. Range order is
34
+ key-sorted; the browser demuxes by id, not position. */ -}}
35
+ {{- range $id, $req := . -}}
36
+ {{- $partial := $req.partial -}}
37
+ {{- $found := templates.Exists (printf "partials/%s" $partial) -}}
38
+ {{- $found = or $found (templates.Exists (printf "partials/%s.html" $partial)) -}}
39
+ {{- $found = or $found (templates.Exists (printf "partials/%s.htm" $partial)) -}}
40
+ {{- if not $found -}}
41
+ <div data-cc-render="{{ $id }}"><cc-missing-partial data-name="{{ $partial }}"></cc-missing-partial></div>
42
+ {{- else -}}
43
+ <div data-cc-render="{{ $id }}">{{- $result := try (partial $partial $req.props) -}}
44
+ {{- if $result.Err -}}
45
+ <cc-failed-partial data-name="{{ $partial }}" data-message="{{ $result.Err }}"></cc-failed-partial>
46
+ {{- else -}}
47
+ {{- $result.Value -}}
48
+ {{- end -}}</div>
49
+ {{- end -}}
50
+ {{- end -}}
51
+ {{- end -}}`
52
+
53
+ const dispatchViewPath = "/_default/__cc-dispatch.html"
54
+
55
+ type editorSiteBuilder struct {
56
+ Cfg *allconfig.Configs
57
+ Afs afero.Fs
58
+ Fs *hugofs.Fs
59
+ Sites *hugolib.HugoSites
60
+ changedFiles []string
61
+ removedFiles []string
62
+ }
63
+
64
+ func editorFlags() config.Provider {
65
+ flags := config.New()
66
+ flags.Set("disableKinds", []string{"taxonomy", "term", "RSS", "sitemap", "robotsTXT", "404"})
67
+ flags.Set("cascade", []interface{}{
68
+ map[string]interface{}{
69
+ "build": map[string]interface{}{"render": "link"},
70
+ },
71
+ })
72
+ return flags
73
+ }
74
+
75
+ func (builder *editorSiteBuilder) loadConfig() error {
76
+ env := "production"
77
+ if contents, err := builder.readFile("cc-env"); err == nil {
78
+ env = strings.TrimSpace(contents)
79
+ }
80
+ cfg, err := allconfig.LoadConfig(allconfig.ConfigSourceDescriptor{
81
+ Fs: builder.Afs,
82
+ Flags: editorFlags(),
83
+ ConfigDir: "config",
84
+ Environment: env,
85
+ // Only mirrored modules (theme, vendored) can resolve in the in-memory fs;
86
+ // skip imports whose replacement points at the repo on disk.
87
+ IgnoreModuleDoesNotExist: true,
88
+ })
89
+ if err != nil {
90
+ return err
91
+ }
92
+
93
+ // Rebuilds run through Hugo's incremental change-event pipeline, so enable
94
+ // Running/Watch and propagate to per-language configs (read by the `hugo.*`
95
+ // template namespace).
96
+ cfg.Base.WorkingDir = ""
97
+ cfg.Base.Internal.Running = true
98
+ cfg.Base.Internal.Watch = true
99
+
100
+ for _, languageConfig := range cfg.LanguageConfigMap {
101
+ languageConfig.Internal.Running = true
102
+ languageConfig.Internal.Watch = true
103
+ languageConfig.Build.BuildStats.Enable = false
104
+ if languageConfig.Params == nil {
105
+ languageConfig.Params = hmaps.Params{}
106
+ }
107
+ languageConfig.Params["env_client"] = true
108
+ }
109
+ builder.Cfg = cfg
110
+ builder.Fs = hugofs.NewFrom(builder.Afs, cfg.GetFirstLanguageConfig().BaseConfig())
111
+
112
+ return nil
113
+ }
114
+
115
+ func (builder *editorSiteBuilder) createSites() error {
116
+ if err := builder.loadConfig(); err != nil {
117
+ return fmt.Errorf("failed to load config: %w", err)
118
+ }
119
+
120
+ builder.Fs.PublishDir = hugofs.NewCreateCountingFs(builder.Fs.PublishDir)
121
+
122
+ sites, err := hugolib.NewHugoSites(deps.DepsCfg{
123
+ Fs: builder.Fs,
124
+ Configs: builder.Cfg,
125
+ })
126
+ if err != nil {
127
+ return fmt.Errorf("failed to create sites: %w", err)
128
+ }
129
+ builder.Sites = sites
130
+
131
+ return nil
132
+ }
133
+
134
+ func (builder *editorSiteBuilder) build() error {
135
+ var events []fsnotify.Event
136
+ if builder.Sites == nil {
137
+ if err := builder.createSites(); err != nil {
138
+ return err
139
+ }
140
+ } else {
141
+ events = builder.changeEvents()
142
+ }
143
+
144
+ err := builder.Sites.Build(hugolib.BuildCfg{NoBuildLock: true}, events...)
145
+ if err != nil {
146
+ return err
147
+ }
148
+
149
+ builder.changedFiles = nil
150
+ builder.removedFiles = nil
151
+
152
+ if n := builder.Sites.NumLogErrors(); n > 0 {
153
+ err = fmt.Errorf("logged %d errors", n)
154
+ }
155
+ return err
156
+ }
157
+
158
+ // buildIfDirty runs the initial build when the site doesn't exist yet, then
159
+ // an incremental build only when files changed since the last build, so
160
+ // renders with nothing pending skip the build entirely.
161
+ func (builder *editorSiteBuilder) buildIfDirty() error {
162
+ if builder.Sites == nil {
163
+ return builder.build()
164
+ }
165
+
166
+ if len(builder.changedFiles) == 0 && len(builder.removedFiles) == 0 {
167
+ return nil
168
+ }
169
+
170
+ if err := builder.build(); err != nil {
171
+ return fmt.Errorf("build after pending content changes: %w", err)
172
+ }
173
+ return nil
174
+ }
175
+
176
+ func (builder *editorSiteBuilder) writeFile(filename, content string) {
177
+ filename = normalizeMemfsPath(filename)
178
+ if err := afero.WriteFile(builder.Afs, filepath.FromSlash(filename), []byte(content), 0755); err != nil {
179
+ fmt.Println(fmt.Sprintf("Failed to write file: %s", err))
180
+ return
181
+ }
182
+
183
+ builder.changedFiles = append(builder.changedFiles, filename)
184
+ }
185
+
186
+ func (builder *editorSiteBuilder) removeFile(filename string) {
187
+ filename = normalizeMemfsPath(filename)
188
+ if err := builder.Afs.Remove(filename); err != nil {
189
+ fmt.Println(fmt.Sprintf("Failed to remove file: %s", err))
190
+ return
191
+ }
192
+
193
+ // Publish-dir files aren't Hugo source files, so their removal shouldn't
194
+ // be fed back in as a change event.
195
+ if !strings.HasPrefix(filepath.ToSlash(filename), "public/") {
196
+ builder.removedFiles = append(builder.removedFiles, filename)
197
+ }
198
+ }
199
+
200
+ func (builder *editorSiteBuilder) readFile(filename string) (string, error) {
201
+ filename = normalizeMemfsPath(filename)
202
+ b, err := afero.ReadFile(builder.Afs, filepath.Clean(filename))
203
+ if err != nil {
204
+ return "", err
205
+ }
206
+ return string(b), nil
207
+ }
208
+
209
+ func (builder *editorSiteBuilder) changeEvents() []fsnotify.Event {
210
+ var events []fsnotify.Event
211
+
212
+ for _, v := range builder.changedFiles {
213
+ events = append(events, fsnotify.Event{
214
+ Name: v,
215
+ Op: fsnotify.Write,
216
+ })
217
+ }
218
+
219
+ for _, v := range builder.removedFiles {
220
+ events = append(events, fsnotify.Event{
221
+ Name: v,
222
+ Op: fsnotify.Remove,
223
+ })
224
+ }
225
+
226
+ return events
227
+ }
228
+
229
+ // normalizeMemfsPath maps the browser's site-root-relative source paths
230
+ // ("/content/blog/one.md") to the in-memory fs's slash-less relative form,
231
+ // which Hugo's contentDir/dataDir/layoutDir also use.
232
+ func normalizeMemfsPath(p string) string {
233
+ return strings.TrimPrefix(filepath.ToSlash(p), "/")
234
+ }
235
+
236
+ var builder editorSiteBuilder
237
+
238
+ func main() {
239
+ builder = editorSiteBuilder{Afs: afero.NewMemMapFs()}
240
+
241
+ log.SetOutput(io.Discard)
242
+
243
+ c := make(chan struct{}, 0)
244
+ js.Global().Set("writeHugoFiles", js.FuncOf(writeHugoFiles))
245
+ js.Global().Set("removeHugoFiles", js.FuncOf(removeHugoFiles))
246
+ js.Global().Set("readHugoFiles", js.FuncOf(readHugoFiles))
247
+ js.Global().Set("initHugoEditorSite", js.FuncOf(initHugoEditorSite))
248
+ js.Global().Set("renderHugoPartials", js.FuncOf(renderHugoPartials))
249
+ <-c
250
+ }
251
+
252
+ func errorValue(format string, args ...interface{}) js.Value {
253
+ return js.ValueOf(map[string]interface{}{
254
+ "error": fmt.Sprintf(format, args...),
255
+ })
256
+ }
257
+
258
+ func writeHugoFiles(this js.Value, args []js.Value) interface{} {
259
+ var writeFiles map[string]string
260
+ if err := json.Unmarshal([]byte(args[0].String()), &writeFiles); err != nil {
261
+ return errorValue("bad writeHugoFiles payload: %s", err)
262
+ }
263
+
264
+ for fileName, fileContents := range writeFiles {
265
+ builder.writeFile(fileName, fileContents)
266
+ }
267
+ return nil
268
+ }
269
+
270
+ func removeHugoFiles(this js.Value, args []js.Value) interface{} {
271
+ var removeFiles []string
272
+ if err := json.Unmarshal([]byte(args[0].String()), &removeFiles); err != nil {
273
+ return errorValue("bad removeHugoFiles payload: %s", err)
274
+ }
275
+
276
+ for _, fileName := range removeFiles {
277
+ builder.removeFile(fileName)
278
+ }
279
+ return nil
280
+ }
281
+
282
+ func readHugoFiles(this js.Value, args []js.Value) interface{} {
283
+ var readFiles []string
284
+ if err := json.Unmarshal([]byte(args[0].String()), &readFiles); err != nil {
285
+ return errorValue("bad readHugoFiles payload: %s", err)
286
+ }
287
+
288
+ fileContents := make(map[string]interface{})
289
+ for _, fileName := range readFiles {
290
+ contents, err := builder.readFile(fileName)
291
+ if err != nil {
292
+ continue
293
+ }
294
+ fileContents[fileName] = contents
295
+ }
296
+
297
+ return js.ValueOf(fileContents)
298
+ }
299
+
300
+ func initHugoEditorSite(this js.Value, args []js.Value) interface{} {
301
+ if err := builder.loadConfig(); err != nil {
302
+ return errorValue("failed to load config: %s", err)
303
+ }
304
+
305
+ builder.writeFile(filepath.Join(
306
+ builder.Cfg.Base.LayoutDir, "_default", "__cc-dispatch.html"), dispatchView)
307
+ return nil
308
+ }
309
+
310
+ // renderRequests is the browser's queued batch: the one render target every
311
+ // request shares, plus the requests to render against it.
312
+ type renderRequests struct {
313
+ Target string `json:"target"`
314
+ Requests []renderRequest `json:"requests"`
315
+ }
316
+
317
+ type renderRequest struct {
318
+ ID string `json:"id"`
319
+ Partial string `json:"partial"`
320
+ Props json.RawMessage `json:"props"`
321
+ }
322
+
323
+ func (builder *editorSiteBuilder) targetPage(target string) page.Page {
324
+ target = normalizeMemfsPath(target)
325
+ if target != "" && builder.Sites != nil {
326
+ for _, s := range builder.Sites.Sites {
327
+ for _, p := range s.Pages() {
328
+ f := p.File()
329
+ if f == nil {
330
+ continue
331
+ }
332
+ if filepath.Join(builder.Cfg.Base.ContentDir, f.Path()) != target {
333
+ continue
334
+ }
335
+ return p
336
+ }
337
+ }
338
+ }
339
+ return nil
340
+ }
341
+
342
+ func renderHugoPartials(this js.Value, args []js.Value) interface{} {
343
+ var payload renderRequests
344
+ if err := json.Unmarshal([]byte(args[0].String()), &payload); err != nil {
345
+ return errorValue("bad renderHugoPartials payload: %s", err)
346
+ }
347
+ reqs := payload.Requests
348
+ if len(reqs) == 0 {
349
+ return errorValue("renderHugoPartials requires at least one request")
350
+ }
351
+ if err := builder.buildIfDirty(); err != nil {
352
+ return errorValue("editor site build failed: %s", err)
353
+ }
354
+
355
+ for _, req := range reqs {
356
+ if req.Partial == "" {
357
+ return errorValue("renderHugoPartials requires a \"partial\" name on every request")
358
+ }
359
+ }
360
+
361
+ html, err := builder.renderBatch(payload.Target, reqs)
362
+ if err != nil {
363
+ return errorValue("%s", err)
364
+ }
365
+
366
+ return js.ValueOf(map[string]interface{}{
367
+ "html": html,
368
+ })
369
+ }
370
+
371
+ func (builder *editorSiteBuilder) renderBatch(target string, reqs []renderRequest) (string, error) {
372
+ requests := make(map[string]interface{}, len(reqs))
373
+ for _, req := range reqs {
374
+ var props interface{}
375
+ if len(req.Props) > 0 {
376
+ if err := json.Unmarshal(req.Props, &props); err != nil {
377
+ return "", fmt.Errorf("bad props for %s: %s", req.Partial, err)
378
+ }
379
+ }
380
+ requests[req.ID] = map[string]interface{}{
381
+ "partial": req.Partial,
382
+ "props": props,
383
+ }
384
+ }
385
+ // Decode the batch through Hugo's YAML decoder so props decode with identical types
386
+ encoded, err := json.Marshal(requests)
387
+ if err != nil {
388
+ return "", fmt.Errorf("failed to encode render batch: %s", err)
389
+ }
390
+ decoded, err := metadecoders.Decoder{}.UnmarshalToMap(encoded, metadecoders.YAML)
391
+ if err != nil {
392
+ return "", fmt.Errorf("failed to decode render batch: %s", err)
393
+ }
394
+ hmaps.PrepareParams(decoded)
395
+
396
+ renderTarget := builder.targetPage(target)
397
+ if renderTarget == nil {
398
+ renderTarget = page.NopPage
399
+ }
400
+ store := builder.Sites.GetTemplateStore()
401
+ ctx := store.PrepareTopLevelRenderCtx(context.Background(), renderTarget)
402
+ tmpl := store.LookupByPath(dispatchViewPath)
403
+ if tmpl == nil {
404
+ return "", fmt.Errorf("dispatch view %q not found", dispatchViewPath)
405
+ }
406
+ var out strings.Builder
407
+ if err := store.ExecuteWithContext(ctx, tmpl, &out,
408
+ map[string]interface{}{"cc_requests": decoded}); err != nil {
409
+ return "", fmt.Errorf("failed to render the dispatch view for %q: %w", target, err)
410
+ }
411
+ return out.String(), nil
412
+ }
@@ -16,6 +16,7 @@ build time; this directory is what that bundle pulls in.
16
16
  - [Eleventy global](#eleventy-global)
17
17
  - [`pkg` global](#pkg-global)
18
18
  - [Filters](#filters)
19
+ - [What the auto-mirror actually does](#what-the-auto-mirror-actually-does)
19
20
  - [Adding a custom filter](#adding-a-custom-filter)
20
21
  - [Overriding a built-in](#overriding-a-built-in)
21
22
  - [Shortcodes and paired shortcodes](#shortcodes-and-paired-shortcodes)
@@ -49,6 +50,20 @@ not implemented — see "Limitations and fallbacks".
49
50
  npm install @cloudcannon/editable-regions
50
51
  ```
51
52
 
53
+ Requires **Node 20.19+ or 22.12+**. The plugin is an ES module; those are the
54
+ releases where Node can `require()` one, so a CommonJS config can pull it in
55
+ with a plain `require`. On anything older, use a dynamic import from an async
56
+ config instead:
57
+
58
+ ```js
59
+ module.exports = async function (eleventyConfig) {
60
+ const { default: editableRegions } = await import(
61
+ "@cloudcannon/editable-regions/eleventy"
62
+ );
63
+ eleventyConfig.addPlugin(editableRegions);
64
+ };
65
+ ```
66
+
52
67
  Wire the plugin into your `eleventy.config.mjs`. The minimal case is one
53
68
  line — Liquid is the plugin's default language and is enabled implicitly:
54
69
 
@@ -127,7 +142,7 @@ works too.
127
142
  | `liquid.pairedShortcodes` | Same as `shortcodes`, for paired shortcodes. |
128
143
  | `liquid.tags` | Map of tag name → factory module path. Browser-side override. Tags auto-mirror from the config like filters/shortcodes; use this only for a tag that can't run in the browser as written. |
129
144
  | `liquid.configPath` | Path to the Eleventy config file to import and replay for the auto-mirror, relative to the project root. Defaults to the first of 11ty's standard names that exists (`.eleventy.js`, `eleventy.config.{js,mjs,cjs}`). Set only if you run Eleventy with a non-default `--config`. |
130
- | `liquid.browserStub` | Extra bare module specifiers to stub out of the browser bundle, on top of the 11ty toolchain and Node built-ins (always stubbed). Use when the config imports a native/Node-only package (e.g. `sharp`) that no browser-bound helper actually calls. |
145
+ | `liquid.browserStub` | Extra bare module specifiers to stub out of the browser bundle, on top of the 11ty toolchain and Node built-ins (always stubbed). Use for a native/Node-only package (e.g. `sharp`, or a Node-only 11ty plugin) that would otherwise break bundling, or whose config-time calls would abort the auto-mirror. See "What the auto-mirror actually does". |
131
146
 
132
147
  ## How it fits together
133
148
 
@@ -151,7 +166,7 @@ Globals are passed to `new Liquid({ globals })` inside `createSharedLiquidEngine
151
166
 
152
167
  | Global | Status | Notes |
153
168
  | --- | --- | --- |
154
- | `collections` | Implemented | `Proxy` that lazily resolves `collections.foo` to an array of items via the Visual Editor API. Items shaped roughly like Eleventy's: `{ url, inputPath, data }`. |
169
+ | `collections` | Implemented | Object with one lazy getter per collection name, resolving `collections.foo` to an array of items via the Visual Editor API. Items shaped roughly like Eleventy's: `{ url, inputPath, data }`. Listing the collections is a single API call; a collection's files are fetched only when a template reads that key, with bounded concurrency, and cached until the collection changes. A component that never mentions `collections` issues no per-file requests. |
155
170
  | `ENV_CLIENT` | Implemented | Always `true` in this bundle. Templates can branch on it to opt out of build-only logic. |
156
171
  | `page` | Partial | `Proxy` backed by `CloudCannon.currentFile()`. See below for which properties are supported. |
157
172
  | custom globals | Opt-in | Whatever you pass via `pluginOptions.globals` (e.g. an `env` object), embedded at build time. See "Custom globals" below. |
@@ -260,6 +275,15 @@ name collision: **built-ins**, **auto-mirrored**, then **overrides**.
260
275
  at render time will throw when invoked in the browser — the signal to add
261
276
  an override.
262
277
 
278
+ `async` configs and `async` plugins are supported: the replay is awaited,
279
+ and component rendering is held until it finishes. This matters because
280
+ `await import("@11ty/eleventy")` — the usual way a CommonJS config reaches
281
+ the ESM-only `RenderPlugin` / `I18nPlugin` exports — makes the whole config
282
+ async, and none of its helpers exist until that import settles.
283
+
284
+ See "What the auto-mirror actually does" below before assuming a helper
285
+ will survive the trip.
286
+
263
287
  3. **Overrides** (`pluginOptions.liquid.filters`). A map from filter name to
264
288
  module path. Two reasons to use this:
265
289
  - **A mirrored filter throws at render time** — supply a browser-safe
@@ -269,6 +293,69 @@ name collision: **built-ins**, **auto-mirrored**, then **overrides**.
269
293
  `eleventyConfig.addFilter("url", …)` won't reach live editing unless you
270
294
  also register it here.
271
295
 
296
+ ### What the auto-mirror actually does
297
+
298
+ The mirror is **not** a static scan of your config. The bundle imports your
299
+ real config module and *runs it* in the browser, against a stand-in
300
+ `eleventyConfig` that records `addFilter` / `addShortcode` / `addLiquidTag`
301
+ calls and ignores everything else. That's what makes closures and imports
302
+ survive — and it means every line of your config executes in a browser.
303
+
304
+ Most of what that implies is handled for you:
305
+
306
+ - **Node built-ins and the 11ty toolchain are stubbed**, so importing them is
307
+ harmless. A stubbed module that gets *called* during the replay is skipped
308
+ with a console warning and the rest of the config still mirrors; the same
309
+ call from inside a rendered helper throws, because there it's a real
310
+ problem you need to fix.
311
+ - **Node globals are shimmed.** `process.env.X`, `process.cwd()`, `__dirname`
312
+ and `__filename` resolve to inert values rather than a `ReferenceError`.
313
+ `process.env.NODE_ENV` reads `"development"`, for the same reason
314
+ `eleventy.env.runMode` is `"serve"` — the editor isn't a production build,
315
+ and a config gated on `NODE_ENV === "production"` shouldn't drag build-only
316
+ plugins into the mirror. Real values belong in `pluginOptions.globals`.
317
+
318
+ What's left is code that runs at config time and needs something the browser
319
+ genuinely doesn't have. In rough order of what to reach for:
320
+
321
+ 1. **`pluginOptions.liquid.browserStub`** — the usual answer. Add the module
322
+ specifier and it resolves to a stub, so calls through it are skipped
323
+ instead of aborting the replay. This is what a Node-only plugin needs,
324
+ including the argument-side case that nothing else can intercept:
325
+
326
+ ```js
327
+ // `pluginBookshop({...})` is evaluated *before* `addPlugin` is called, so
328
+ // no amount of proxying `eleventyConfig` can catch it — the module itself
329
+ // has to be stubbed.
330
+ eleventyConfig.addPlugin(pluginBookshop({ /* Node-only */ }));
331
+
332
+ eleventyConfig.addPlugin(editableRegions, {
333
+ liquid: { browserStub: ["@bookshop/eleventy-bookshop"] },
334
+ });
335
+ ```
336
+
337
+ 2. **A per-helper override** (`pluginOptions.liquid.filters` / `.shortcodes` /
338
+ `.pairedShortcodes` / `.tags`) — for a helper that mirrors fine but can't
339
+ *run* in the browser. See "Adding a custom filter".
340
+
341
+ 3. **An early return** — last resort, for config-time code that sits behind no
342
+ import at all, so there's nothing to stub:
343
+
344
+ ```js
345
+ export default function (eleventyConfig) {
346
+ eleventyConfig.addFilter("shout", (s) => String(s).toUpperCase());
347
+
348
+ // Everything below is build-only; the browser mirror stops here.
349
+ if (typeof window !== "undefined") return;
350
+
351
+ const manifest = buildManifestFromDisk();
352
+ eleventyConfig.addGlobalData("manifest", manifest);
353
+ }
354
+ ```
355
+
356
+ Put it as late as you can: helpers registered *above* the return still
357
+ mirror, and anything below it won't be available in live editing.
358
+
272
359
  ### Adding a custom filter
273
360
 
274
361
  For most filters you don't need to do anything — registering with Eleventy
@@ -540,7 +627,9 @@ section catalogues the gaps and the patterns for working around them.
540
627
  | `htmlBaseUrl`, `serverlessUrl` filters | Registered as warn-once pass-throughs; return their input unchanged. `htmlBaseUrl` depends on the configured `pathPrefix` (we don't expose it yet); `serverlessUrl` is a build-time concept with no editor equivalent. | Override via `pluginOptions.liquid.filters` if you have a browser-safe equivalent. Otherwise wrap the template path in `{% if ENV_CLIENT %}` and skip it. |
541
628
  | `inputPathToUrl` filter when the source file wasn't in the last build | Falls back to warn-once and returns the input path unchanged. The build-time page map is what makes this filter work; for files added since the last build there's no URL to look up. | Re-build to pick up new pages. |
542
629
  | `renderTemplate` / `renderFile` / `renderContent` with a non-Liquid engine arg (e.g. `"njk"`, `"md"`) | Warn-once and return the body unchanged. We only ship LiquidJS in the bundle. | Switch the template to Liquid, or guard the call with `{% if ENV_CLIENT %}` so it only runs at build time. |
543
- | Mirrored filters/shortcodes that touch `this.ctx`, `process`, `require`, `__dirname`, or a closed-over Node import | Auto-mirror ships them verbatim; they throw at render time in the browser. The thrown error is wrapped by `enhanceLiquidError` with the filter/shortcode name. | Add a `pluginOptions.liquid.filters` (or `.shortcodes` / `.pairedShortcodes`) override pointing at a browser-safe replacement. |
630
+ | Mirrored filters/shortcodes that touch `this.ctx` or a closed-over Node import | Auto-mirror ships them verbatim; they throw at render time in the browser. The thrown error is wrapped by `enhanceLiquidError` with the filter/shortcode name. | Add a `pluginOptions.liquid.filters` (or `.shortcodes` / `.pairedShortcodes`) override pointing at a browser-safe replacement. |
631
+ | Mirrored helpers that read `process.env`, `process.cwd()`, `__dirname` or `__filename` | Don't throw — they read the shim (see "What the auto-mirror actually does"), so they render, but with placeholder values rather than the build's. | If the value matters, pass it through `pluginOptions.globals` and read it as a Liquid global, or override the helper. |
632
+ | `{{ collections \| json }}` — serialising the **whole** collections object | Renders `{"posts":{},"pages":{}}`. Each key is a lazy getter resolving to a Promise, and `JSON.stringify` can't await; every other access pattern is unaffected because LiquidJS *does* await during expression evaluation. Materialising for serialisation would mean fetching every file in every collection on any access, which is what the laziness exists to prevent. | Serialise one collection at a time — `{{ collections.posts \| json }}` works normally. |
544
633
  | Helpers from auto-loaded 11ty plugins used **inside a component** (e.g. `getBundle` / `getBundleFileUrl` / `renderTransforms` from `@11ty/eleventy-plugin-bundle`) | 11ty 3.x auto-loads several plugins that register universal helpers; the auto-mirror ships them verbatim and they'll throw if invoked from a template the editor re-renders. Layouts and pages aren't affected — the live runtime only renders components. | If you reference one of these in an editable component, add a browser-safe override via `pluginOptions.liquid.shortcodes` / `.filters`. Most users won't hit this because bundle helpers typically live in layouts. |
545
634
  | User overrides of a **built-in** filter name via `eleventyConfig.addFilter` | The auto-mirror skips built-in names, so the override doesn't reach the bundle — live editing keeps using our handwritten port. | Also register the override in `pluginOptions.liquid.filters`. See "Overriding a built-in". |
546
635
  | Custom Liquid tags | Not auto-mirrored. Templates referencing an unregistered custom tag will fail with an enhanced "tag X not found" error. | Register every tag you want available via `pluginOptions.liquid.tags`. |
@@ -8,7 +8,9 @@
8
8
  export function enhanceLiquidError(err, componentName) {
9
9
  const message = err instanceof Error ? err.message : String(err);
10
10
 
11
- const unknownFilter = message.match(/undefined filter[:.]?\s*(\S+)/i);
11
+ // LiquidJS appends its own position suffix ("undefined filter: foo, line:2,
12
+ // col:1"), so stop at the comma rather than at whitespace.
13
+ const unknownFilter = message.match(/undefined filter[:.]?\s*([^\s,]+)/i);
12
14
  if (unknownFilter) {
13
15
  const filterName = unknownFilter[1];
14
16
  return new Error(
@@ -1,5 +1,13 @@
1
1
  import { log, warn } from "./logger.mjs";
2
2
 
3
+ /**
4
+ * Matches `path.extname`: last dot of the final segment, leading dot excluded.
5
+ */
6
+ function hasExtension(/** @type {string} */ filePath) {
7
+ const basename = filePath.slice(filePath.lastIndexOf("/") + 1);
8
+ return basename.lastIndexOf(".") > 0;
9
+ }
10
+
3
11
  /**
4
12
  * In-memory filesystem for LiquidJS, reading from `window.cc_liquid_files`.
5
13
  * @type {any}
@@ -60,7 +68,9 @@ export const inMemoryFs = {
60
68
  /** @type {string} */ ext,
61
69
  ) {
62
70
  const extension = ext || ".liquid";
63
- const fileWithExt = file.endsWith(extension) ? file : `${file}${extension}`;
71
+ // Only append when the file has none, as LiquidJS's Node fs does —
72
+ // otherwise `include "card.html"` becomes `card.html.liquid`.
73
+ const fileWithExt = hasExtension(file) ? file : `${file}${extension}`;
64
74
  const normalizedRoot = root.replace(/^\.\//, "").replace(/\/*$/, "/");
65
75
  const resolved = `${normalizedRoot}${fileWithExt}`;
66
76
  log("resolve:", { root, file, ext }, "->", resolved);