@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.
- package/helpers/checks.ts +0 -22
- package/helpers/hydrate-editable-regions.ts +2 -1
- package/integrations/astro/react-renderer.mjs +94 -19
- package/integrations/astro/svelte-renderer.mjs +72 -15
- package/integrations/astro/vue-renderer.mjs +61 -0
- package/integrations/eleventy/browser/collect-config.mjs +69 -8
- package/integrations/eleventy/browser/inert.mjs +35 -0
- package/integrations/eleventy/browser/process-shim.mjs +32 -0
- package/integrations/eleventy/browser/stub-mode.mjs +61 -0
- package/integrations/eleventy/index.cjs +28 -1
- package/integrations/eleventy/index.mjs +82 -30
- package/integrations/hugo/browser/entry.js +13 -0
- package/integrations/hugo/browser/errors.ts +41 -0
- package/integrations/hugo/browser/index.ts +344 -0
- package/integrations/hugo/browser/logger.ts +42 -0
- package/integrations/hugo/browser/wasm_exec.js +575 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/find-config-files.html +14 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/find-dep-template-files.html +66 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/find-files-with-extension.html +27 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/find-template-files.html +51 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/load-deps.html +5 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/normalize-extensions.html +9 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/resources.html +87 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/wasm-url.html +28 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions.html +6 -0
- package/integrations/hugo/renderer/build.sh +24 -0
- package/integrations/hugo/renderer/go.mod +100 -0
- package/integrations/hugo/renderer/go.sum +241 -0
- package/integrations/hugo/renderer/main.go +412 -0
- package/integrations/liquid/README.md +92 -3
- package/integrations/liquid/errors.mjs +3 -1
- package/integrations/liquid/fs.mjs +11 -1
- package/integrations/liquid/globals.mjs +131 -26
- package/integrations/liquid/index.mjs +5 -2
- package/integrations/vue.mjs +28 -0
- package/nodes/editable-array-item.ts +6 -1
- package/nodes/editable-component.ts +2 -3
- package/nodes/editable-text.ts +6 -0
- package/nodes/editable.ts +6 -1
- package/package.json +132 -90
- package/types/astro.d.ts +4 -0
- package/types/eleventy.d.ts +11 -4
- package/types/hugo.d.ts +69 -0
- package/types/liquid.d.ts +0 -1
- 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
|
|
145
|
+
| `liquid.browserStub` | Extra bare module specifiers to stub out of the browser bundle, on top of the 11ty toolchain and Node built-ins (always stubbed). Use for a native/Node-only package (e.g. `sharp`, or a Node-only 11ty plugin) that would otherwise break bundling, or whose config-time calls would abort the auto-mirror. See "What the auto-mirror actually does". |
|
|
131
146
|
|
|
132
147
|
## How it fits together
|
|
133
148
|
|
|
@@ -151,7 +166,7 @@ Globals are passed to `new Liquid({ globals })` inside `createSharedLiquidEngine
|
|
|
151
166
|
|
|
152
167
|
| Global | Status | Notes |
|
|
153
168
|
| --- | --- | --- |
|
|
154
|
-
| `collections` | Implemented |
|
|
169
|
+
| `collections` | Implemented | Object with one lazy getter per collection name, resolving `collections.foo` to an array of items via the Visual Editor API. Items shaped roughly like Eleventy's: `{ url, inputPath, data }`. Listing the collections is a single API call; a collection's files are fetched only when a template reads that key, with bounded concurrency, and cached until the collection changes. A component that never mentions `collections` issues no per-file requests. |
|
|
155
170
|
| `ENV_CLIENT` | Implemented | Always `true` in this bundle. Templates can branch on it to opt out of build-only logic. |
|
|
156
171
|
| `page` | Partial | `Proxy` backed by `CloudCannon.currentFile()`. See below for which properties are supported. |
|
|
157
172
|
| custom globals | Opt-in | Whatever you pass via `pluginOptions.globals` (e.g. an `env` object), embedded at build time. See "Custom globals" below. |
|
|
@@ -260,6 +275,15 @@ name collision: **built-ins**, **auto-mirrored**, then **overrides**.
|
|
|
260
275
|
at render time will throw when invoked in the browser — the signal to add
|
|
261
276
|
an override.
|
|
262
277
|
|
|
278
|
+
`async` configs and `async` plugins are supported: the replay is awaited,
|
|
279
|
+
and component rendering is held until it finishes. This matters because
|
|
280
|
+
`await import("@11ty/eleventy")` — the usual way a CommonJS config reaches
|
|
281
|
+
the ESM-only `RenderPlugin` / `I18nPlugin` exports — makes the whole config
|
|
282
|
+
async, and none of its helpers exist until that import settles.
|
|
283
|
+
|
|
284
|
+
See "What the auto-mirror actually does" below before assuming a helper
|
|
285
|
+
will survive the trip.
|
|
286
|
+
|
|
263
287
|
3. **Overrides** (`pluginOptions.liquid.filters`). A map from filter name to
|
|
264
288
|
module path. Two reasons to use this:
|
|
265
289
|
- **A mirrored filter throws at render time** — supply a browser-safe
|
|
@@ -269,6 +293,69 @@ name collision: **built-ins**, **auto-mirrored**, then **overrides**.
|
|
|
269
293
|
`eleventyConfig.addFilter("url", …)` won't reach live editing unless you
|
|
270
294
|
also register it here.
|
|
271
295
|
|
|
296
|
+
### What the auto-mirror actually does
|
|
297
|
+
|
|
298
|
+
The mirror is **not** a static scan of your config. The bundle imports your
|
|
299
|
+
real config module and *runs it* in the browser, against a stand-in
|
|
300
|
+
`eleventyConfig` that records `addFilter` / `addShortcode` / `addLiquidTag`
|
|
301
|
+
calls and ignores everything else. That's what makes closures and imports
|
|
302
|
+
survive — and it means every line of your config executes in a browser.
|
|
303
|
+
|
|
304
|
+
Most of what that implies is handled for you:
|
|
305
|
+
|
|
306
|
+
- **Node built-ins and the 11ty toolchain are stubbed**, so importing them is
|
|
307
|
+
harmless. A stubbed module that gets *called* during the replay is skipped
|
|
308
|
+
with a console warning and the rest of the config still mirrors; the same
|
|
309
|
+
call from inside a rendered helper throws, because there it's a real
|
|
310
|
+
problem you need to fix.
|
|
311
|
+
- **Node globals are shimmed.** `process.env.X`, `process.cwd()`, `__dirname`
|
|
312
|
+
and `__filename` resolve to inert values rather than a `ReferenceError`.
|
|
313
|
+
`process.env.NODE_ENV` reads `"development"`, for the same reason
|
|
314
|
+
`eleventy.env.runMode` is `"serve"` — the editor isn't a production build,
|
|
315
|
+
and a config gated on `NODE_ENV === "production"` shouldn't drag build-only
|
|
316
|
+
plugins into the mirror. Real values belong in `pluginOptions.globals`.
|
|
317
|
+
|
|
318
|
+
What's left is code that runs at config time and needs something the browser
|
|
319
|
+
genuinely doesn't have. In rough order of what to reach for:
|
|
320
|
+
|
|
321
|
+
1. **`pluginOptions.liquid.browserStub`** — the usual answer. Add the module
|
|
322
|
+
specifier and it resolves to a stub, so calls through it are skipped
|
|
323
|
+
instead of aborting the replay. This is what a Node-only plugin needs,
|
|
324
|
+
including the argument-side case that nothing else can intercept:
|
|
325
|
+
|
|
326
|
+
```js
|
|
327
|
+
// `pluginBookshop({...})` is evaluated *before* `addPlugin` is called, so
|
|
328
|
+
// no amount of proxying `eleventyConfig` can catch it — the module itself
|
|
329
|
+
// has to be stubbed.
|
|
330
|
+
eleventyConfig.addPlugin(pluginBookshop({ /* Node-only */ }));
|
|
331
|
+
|
|
332
|
+
eleventyConfig.addPlugin(editableRegions, {
|
|
333
|
+
liquid: { browserStub: ["@bookshop/eleventy-bookshop"] },
|
|
334
|
+
});
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
2. **A per-helper override** (`pluginOptions.liquid.filters` / `.shortcodes` /
|
|
338
|
+
`.pairedShortcodes` / `.tags`) — for a helper that mirrors fine but can't
|
|
339
|
+
*run* in the browser. See "Adding a custom filter".
|
|
340
|
+
|
|
341
|
+
3. **An early return** — last resort, for config-time code that sits behind no
|
|
342
|
+
import at all, so there's nothing to stub:
|
|
343
|
+
|
|
344
|
+
```js
|
|
345
|
+
export default function (eleventyConfig) {
|
|
346
|
+
eleventyConfig.addFilter("shout", (s) => String(s).toUpperCase());
|
|
347
|
+
|
|
348
|
+
// Everything below is build-only; the browser mirror stops here.
|
|
349
|
+
if (typeof window !== "undefined") return;
|
|
350
|
+
|
|
351
|
+
const manifest = buildManifestFromDisk();
|
|
352
|
+
eleventyConfig.addGlobalData("manifest", manifest);
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Put it as late as you can: helpers registered *above* the return still
|
|
357
|
+
mirror, and anything below it won't be available in live editing.
|
|
358
|
+
|
|
272
359
|
### Adding a custom filter
|
|
273
360
|
|
|
274
361
|
For most filters you don't need to do anything — registering with Eleventy
|
|
@@ -540,7 +627,9 @@ section catalogues the gaps and the patterns for working around them.
|
|
|
540
627
|
| `htmlBaseUrl`, `serverlessUrl` filters | Registered as warn-once pass-throughs; return their input unchanged. `htmlBaseUrl` depends on the configured `pathPrefix` (we don't expose it yet); `serverlessUrl` is a build-time concept with no editor equivalent. | Override via `pluginOptions.liquid.filters` if you have a browser-safe equivalent. Otherwise wrap the template path in `{% if ENV_CLIENT %}` and skip it. |
|
|
541
628
|
| `inputPathToUrl` filter when the source file wasn't in the last build | Falls back to warn-once and returns the input path unchanged. The build-time page map is what makes this filter work; for files added since the last build there's no URL to look up. | Re-build to pick up new pages. |
|
|
542
629
|
| `renderTemplate` / `renderFile` / `renderContent` with a non-Liquid engine arg (e.g. `"njk"`, `"md"`) | Warn-once and return the body unchanged. We only ship LiquidJS in the bundle. | Switch the template to Liquid, or guard the call with `{% if ENV_CLIENT %}` so it only runs at build time. |
|
|
543
|
-
| Mirrored filters/shortcodes that touch `this.ctx
|
|
630
|
+
| Mirrored filters/shortcodes that touch `this.ctx` or a closed-over Node import | Auto-mirror ships them verbatim; they throw at render time in the browser. The thrown error is wrapped by `enhanceLiquidError` with the filter/shortcode name. | Add a `pluginOptions.liquid.filters` (or `.shortcodes` / `.pairedShortcodes`) override pointing at a browser-safe replacement. |
|
|
631
|
+
| Mirrored helpers that read `process.env`, `process.cwd()`, `__dirname` or `__filename` | Don't throw — they read the shim (see "What the auto-mirror actually does"), so they render, but with placeholder values rather than the build's. | If the value matters, pass it through `pluginOptions.globals` and read it as a Liquid global, or override the helper. |
|
|
632
|
+
| `{{ collections \| json }}` — serialising the **whole** collections object | Renders `{"posts":{},"pages":{}}`. Each key is a lazy getter resolving to a Promise, and `JSON.stringify` can't await; every other access pattern is unaffected because LiquidJS *does* await during expression evaluation. Materialising for serialisation would mean fetching every file in every collection on any access, which is what the laziness exists to prevent. | Serialise one collection at a time — `{{ collections.posts \| json }}` works normally. |
|
|
544
633
|
| Helpers from auto-loaded 11ty plugins used **inside a component** (e.g. `getBundle` / `getBundleFileUrl` / `renderTransforms` from `@11ty/eleventy-plugin-bundle`) | 11ty 3.x auto-loads several plugins that register universal helpers; the auto-mirror ships them verbatim and they'll throw if invoked from a template the editor re-renders. Layouts and pages aren't affected — the live runtime only renders components. | If you reference one of these in an editable component, add a browser-safe override via `pluginOptions.liquid.shortcodes` / `.filters`. Most users won't hit this because bundle helpers typically live in layouts. |
|
|
545
634
|
| User overrides of a **built-in** filter name via `eleventyConfig.addFilter` | The auto-mirror skips built-in names, so the override doesn't reach the bundle — live editing keeps using our handwritten port. | Also register the override in `pluginOptions.liquid.filters`. See "Overriding a built-in". |
|
|
546
635
|
| Custom Liquid tags | Not auto-mirrored. Templates referencing an unregistered custom tag will fail with an enhanced "tag X not found" error. | Register every tag you want available via `pluginOptions.liquid.tags`. |
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
export function enhanceLiquidError(err, componentName) {
|
|
9
9
|
const message = err instanceof Error ? err.message : String(err);
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// LiquidJS appends its own position suffix ("undefined filter: foo, line:2,
|
|
12
|
+
// col:1"), so stop at the comma rather than at whitespace.
|
|
13
|
+
const unknownFilter = message.match(/undefined filter[:.]?\s*([^\s,]+)/i);
|
|
12
14
|
if (unknownFilter) {
|
|
13
15
|
const filterName = unknownFilter[1];
|
|
14
16
|
return new Error(
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { log, warn } from "./logger.mjs";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Matches `path.extname`: last dot of the final segment, leading dot excluded.
|
|
5
|
+
*/
|
|
6
|
+
function hasExtension(/** @type {string} */ filePath) {
|
|
7
|
+
const basename = filePath.slice(filePath.lastIndexOf("/") + 1);
|
|
8
|
+
return basename.lastIndexOf(".") > 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
/**
|
|
4
12
|
* In-memory filesystem for LiquidJS, reading from `window.cc_liquid_files`.
|
|
5
13
|
* @type {any}
|
|
@@ -60,7 +68,9 @@ export const inMemoryFs = {
|
|
|
60
68
|
/** @type {string} */ ext,
|
|
61
69
|
) {
|
|
62
70
|
const extension = ext || ".liquid";
|
|
63
|
-
|
|
71
|
+
// Only append when the file has none, as LiquidJS's Node fs does —
|
|
72
|
+
// otherwise `include "card.html"` becomes `card.html.liquid`.
|
|
73
|
+
const fileWithExt = hasExtension(file) ? file : `${file}${extension}`;
|
|
64
74
|
const normalizedRoot = root.replace(/^\.\//, "").replace(/\/*$/, "/");
|
|
65
75
|
const resolved = `${normalizedRoot}${fileWithExt}`;
|
|
66
76
|
log("resolve:", { root, file, ext }, "->", resolved);
|