@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
@@ -1,9 +1,22 @@
1
1
  import fs from "node:fs";
2
2
  import { builtinModules, createRequire } from "node:module";
3
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import esbuild from "esbuild";
5
6
  import { createIncludeWithTag } from "../liquid/include-with-tag.mjs";
6
7
 
8
+ /** This package's `integrations/eleventy/browser` directory. */
9
+ const BROWSER_DIR = path.join(
10
+ path.dirname(fileURLToPath(import.meta.url)),
11
+ "browser",
12
+ );
13
+
14
+ /** Substituted for unbound `process` / `__dirname` / `__filename`. */
15
+ const PROCESS_SHIM_PATH = path.join(BROWSER_DIR, "process-shim.mjs");
16
+
17
+ /** Backs the generated module stubs; see `createBrowserStubPlugin`. */
18
+ const STUB_MODE_PATH = path.join(BROWSER_DIR, "stub-mode.mjs");
19
+
7
20
  /**
8
21
  * @typedef {import("../../types/eleventy").LiquidOptions} LiquidOptions
9
22
  * @typedef {import("../../types/eleventy").PluginOptions} PluginOptions
@@ -95,6 +108,10 @@ export default function editableRegionsPlugin(eleventyConfig, pluginOptions) {
95
108
  // The bundle imports the user's real Eleventy config (see
96
109
  // `emitConfigMirror`), dragging in Node/build-time imports — stub them.
97
110
  plugins: [createBrowserStubPlugin(liquidOptions.browserStub)],
111
+ // Node *globals* aren't imports, so the stub plugin can't reach them.
112
+ // esbuild defines `process.env.NODE_ENV` only; everything else is left
113
+ // unbound and throws at load.
114
+ inject: [PROCESS_SHIM_PATH],
98
115
  outfile: options.output ?? `${dirs.output}/register-components.js`,
99
116
  });
100
117
  });
@@ -142,9 +159,13 @@ const ALWAYS_STUBBED = ["@cloudcannon/editable-regions/eleventy"];
142
159
 
143
160
  /**
144
161
  * esbuild plugin resolving Node built-ins and build-time-only packages to a
145
- * Proxy that survives `import` and property access but throws when called or
146
- * constructed so the user's config bundles, and only a helper that actually
147
- * invokes a Node API at render time fails.
162
+ * Proxy that survives `import` and property access, so the user's config
163
+ * bundles for the browser. What happens when one is *called* depends on the
164
+ * phase see `browser/stub-mode.mjs`.
165
+ *
166
+ * `process` is the exception: it resolves to the real shim, so an explicit
167
+ * `import … from "node:process"` reads the same values as the bare global
168
+ * that `inject` substitutes.
148
169
  *
149
170
  * @param {string[]} [extraStubs] - Extra specifiers to stub
150
171
  * (`pluginOptions.liquid.browserStub`), e.g. native deps like `sharp`.
@@ -170,25 +191,39 @@ function createBrowserStubPlugin(extraStubs = []) {
170
191
  return {
171
192
  name: "editable-regions-browser-stub",
172
193
  setup(build) {
173
- build.onResolve({ filter: /.*/ }, (args) =>
174
- shouldStub(args.path)
194
+ build.onResolve({ filter: /.*/ }, (args) => {
195
+ if (args.path === "process" || args.path === "node:process") {
196
+ return { path: args.path, namespace: "er-process" };
197
+ }
198
+ return shouldStub(args.path)
175
199
  ? { path: args.path, namespace: "er-stub" }
176
- : null,
177
- );
178
- build.onLoad({ filter: /.*/, namespace: "er-stub" }, () => ({
200
+ : null;
201
+ });
202
+ // Re-exported as CommonJS rather than resolving straight to the shim:
203
+ // against an ES module, a named import esbuild can't match
204
+ // (`import { hrtime } from "node:process"`) is a hard build error,
205
+ // where CJS interop resolves it to `undefined` at runtime.
206
+ build.onLoad({ filter: /.*/, namespace: "er-process" }, () => ({
207
+ contents: `module.exports = require(${JSON.stringify(PROCESS_SHIM_PATH)}).process;`,
208
+ loader: "js",
209
+ resolveDir: BROWSER_DIR,
210
+ }));
211
+ build.onLoad({ filter: /.*/, namespace: "er-stub" }, (args) => ({
212
+ // CommonJS for the same reason as `er-process` above: a stub's
213
+ // export names aren't knowable, so named imports need CJS interop.
179
214
  contents: `
215
+ const { onStubInvoked } = require(${JSON.stringify(STUB_MODE_PATH)});
216
+ const specifier = ${JSON.stringify(args.path)};
180
217
  const handler = {
181
218
  get: () => new Proxy(function () {}, handler),
182
- apply: () => {
183
- throw new Error("editable-regions: a Node/build-time API was called in the browser live-editing bundle. Provide a browser-friendly override via pluginOptions.liquid.<kind>.");
184
- },
185
- construct: () => {
186
- throw new Error("editable-regions: a Node/build-time API was constructed in the browser live-editing bundle. Provide a browser-friendly override via pluginOptions.liquid.<kind>.");
187
- },
219
+ apply: () => onStubInvoked(specifier, "called"),
220
+ construct: () => onStubInvoked(specifier, "constructed"),
188
221
  };
189
222
  module.exports = new Proxy(function () {}, handler);
190
223
  `,
191
224
  loader: "js",
225
+ // So the `require` above resolves out of the stub's namespace.
226
+ resolveDir: BROWSER_DIR,
192
227
  }));
193
228
  },
194
229
  };
@@ -226,9 +261,12 @@ function resolveEleventyConfigPath(liquidOptions) {
226
261
  * skip; those are registered separately by `emitImportRegistrations` so the
227
262
  * override wins.
228
263
  *
264
+ * Split because the replay is awaited inside `initLiveEditing`, while an
265
+ * `import` can only live at module scope.
266
+ *
229
267
  * @param {string} configPath - Absolute path to the Eleventy config
230
268
  * @param {LiquidOptions | undefined} liquidOptions
231
- * @returns {string} JS source
269
+ * @returns {{imports: string, body: string}} JS source
232
270
  */
233
271
  function emitConfigMirror(configPath, liquidOptions) {
234
272
  const skip = {
@@ -238,10 +276,10 @@ function emitConfigMirror(configPath, liquidOptions) {
238
276
  tags: Object.keys(liquidOptions?.tags ?? {}),
239
277
  };
240
278
 
241
- return (
242
- `\nimport userEleventyConfig from ${JSON.stringify(configPath)};\n` +
243
- `collectAndRegisterEleventyHelpers(userEleventyConfig, ${JSON.stringify({ skip })});\n`
244
- );
279
+ return {
280
+ imports: `\nimport userEleventyConfig from ${JSON.stringify(configPath)};\n`,
281
+ body: `await collectAndRegisterEleventyHelpers(userEleventyConfig, ${JSON.stringify({ skip })});\n`,
282
+ };
245
283
  }
246
284
 
247
285
  /**
@@ -339,9 +377,13 @@ async function generateLiveEditingSource(
339
377
  // Auto-mirror the user's config helpers by importing and replaying the
340
378
  // real config in the browser. See `emitConfigMirror`.
341
379
  const configPath = resolveEleventyConfigPath(liquidOptions);
342
- if (configPath) {
343
- source += emitConfigMirror(configPath, liquidOptions);
344
- } else {
380
+ const configMirror = configPath
381
+ ? emitConfigMirror(configPath, liquidOptions)
382
+ : { imports: "", body: "" };
383
+
384
+ source += configMirror.imports;
385
+
386
+ if (!configPath) {
345
387
  console.warn(
346
388
  "[editable-regions] Could not locate an Eleventy config file to " +
347
389
  "auto-mirror helpers from. Set `pluginOptions.liquid.configPath` " +
@@ -353,10 +395,18 @@ async function generateLiveEditingSource(
353
395
 
354
396
  // Register browser-side overrides and pinned components. Override names
355
397
  // are excluded from the mirror, so each is its name's sole registration.
356
- source += emitImportRegistrations(liquidOptions);
398
+ const registrations = emitImportRegistrations(liquidOptions);
399
+ source += registrations.imports;
357
400
 
401
+ // One awaited sequence, so nothing registers ahead of the async replay and
402
+ // `window.cc_components` is published only once it's complete.
358
403
  source += `
359
- initComponentProxy();
404
+ async function initLiveEditing() {
405
+ ${configMirror.body}${registrations.body}
406
+ initComponentProxy();
407
+ }
408
+
409
+ initLiveEditing();
360
410
  `;
361
411
  }
362
412
  return source;
@@ -556,14 +606,15 @@ const IMPORT_REGISTER_FNS = {
556
606
  * Emits an `import` + register-call pair for every `{ name: modulePath }` entry
557
607
  * across the `IMPORT_REGISTER_FNS` maps, e.g.:
558
608
  *
559
- * import filters_0 from "./path/to/file";
560
- * registerFilter("name", filters_0);
609
+ * import filters_0 from "./path/to/file"; // module scope
610
+ * registerFilter("name", filters_0); // inside `initLiveEditing`
561
611
  *
562
612
  * @param {LiquidOptions | undefined} liquidOptions
563
- * @returns {string} JS source
613
+ * @returns {{imports: string, body: string}} JS source
564
614
  */
565
615
  function emitImportRegistrations(liquidOptions) {
566
- let out = "";
616
+ let imports = "";
617
+ let body = "";
567
618
 
568
619
  for (const optionKey of /** @type {Array<keyof typeof IMPORT_REGISTER_FNS>} */ (
569
620
  Object.keys(IMPORT_REGISTER_FNS)
@@ -574,9 +625,10 @@ function emitImportRegistrations(liquidOptions) {
574
625
  liquidOptions?.[optionKey] ?? {},
575
626
  ).entries()) {
576
627
  const id = `${optionKey}_${i}`;
577
- out += `\nimport ${id} from "./${file}";\n${registerFn}(${JSON.stringify(name)}, ${id});\n`;
628
+ imports += `\nimport ${id} from "./${file}";\n`;
629
+ body += `${registerFn}(${JSON.stringify(name)}, ${id});\n`;
578
630
  }
579
631
  }
580
632
 
581
- return out;
633
+ return { imports, body };
582
634
  }
@@ -0,0 +1,13 @@
1
+ // Entry asset for the Hugo module's live-editing bundle. This file is a Go
2
+ // template: editable-regions/resources.html renders it with the site's
3
+ // snapshot via resources.ExecuteAsTemplate, then bundles the result with
4
+ // js.Build. It is not valid JS until rendered — excluded from biome.
5
+ //
6
+ // The window.cc_hugo* assignments run before initHugoLiveEditing() in
7
+ // program order, so the runtime reads a fully populated snapshot.
8
+ window.cc_hugo = {{ .meta | jsonify }};
9
+ window.cc_hugo_files = {{ .files | jsonify }};
10
+
11
+ import { initHugoLiveEditing } from "./index.ts";
12
+
13
+ initHugoLiveEditing();
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Maps raw Hugo renderer errors to actionable messages. The message ends up
3
+ * on the core's component error card, so it should tell the user what to fix.
4
+ */
5
+
6
+ export function enhanceHugoError(message: string, componentKey: string): Error {
7
+ let hint = "";
8
+
9
+ if (/partial .* not found/i.test(message)) {
10
+ hint =
11
+ " This partial isn't in the bundled template snapshot. Check that it " +
12
+ "lives under one of the directories in " +
13
+ "`params.editable_regions.template_dirs` (by default the partials, " +
14
+ "render hooks, and shortcodes of your configured layout dir) " +
15
+ "and rebuild the site.";
16
+ } else if (/execute of template failed/i.test(message)) {
17
+ hint =
18
+ " The partial errored while rendering in the editor. If it depends on " +
19
+ "build-only state (page context, resources, .Site.Pages), guard that " +
20
+ "code with `if hugo.IsServer` or move it out of the component.";
21
+ } else if (/logged \d+ errors/i.test(message)) {
22
+ hint =
23
+ " Hugo logged errors during the render — open the browser console " +
24
+ "for the underlying messages.";
25
+ }
26
+
27
+ return new Error(
28
+ `Failed to render Hugo component "${componentKey}": ${message}.${hint}`,
29
+ );
30
+ }
31
+
32
+ /** Error for a partial missing from the editor's template bundle; raised when
33
+ * the dispatch layout's `templates.Exists` check fails. */
34
+ export function missingComponentError(componentKey: string): Error {
35
+ return new Error(
36
+ `No Hugo partial found for component "${componentKey}". This partial ` +
37
+ `isn't captured in the editor's template bundle. Make sure it's a ` +
38
+ `partial, shortcode, or render hook under your layout tree and ` +
39
+ `rebuild the site.`,
40
+ );
41
+ }
@@ -0,0 +1,344 @@
1
+ import "./wasm_exec.js";
2
+ import {
3
+ apiLoadedPromise,
4
+ CloudCannon,
5
+ } from "../../../helpers/cloudcannon.mjs";
6
+ import { enhanceHugoError, missingComponentError } from "./errors.ts";
7
+ import { group, groupEnd, log, setVerbose, warn } from "./logger.ts";
8
+
9
+ /** A `(props) => HTMLElement` renderer installed on `window.cc_components`. */
10
+ type HugoComponentRenderer = (
11
+ props?: Record<string, any>,
12
+ ) => Promise<HTMLElement>;
13
+
14
+ /** The file being edited, captured once at boot; `""` when the open page has
15
+ * no associated file (the renderer then falls back to the home page). */
16
+ let currentFilePath = "";
17
+
18
+ /** @type {Promise<void> | null} */
19
+ let enginePromise: Promise<void> | null = null;
20
+
21
+ /**
22
+ * Entry point, called by the prebuilt runtime bundle. Installs the component
23
+ * proxy immediately and warms the WASM engine once the editor API appears —
24
+ * so loading the script on a production page never fetches the WASM.
25
+ */
26
+ export function initHugoLiveEditing(): void {
27
+ const files = window.cc_hugo_files ?? {};
28
+
29
+ setVerbose(Boolean(window.cc_hugo?.verbose));
30
+ log(
31
+ "Hugo live editing initialized.",
32
+ Object.keys(files).length,
33
+ "templates in snapshot",
34
+ );
35
+
36
+ initComponentProxy();
37
+
38
+ apiLoadedPromise.then(() => {
39
+ ensureEngine().catch((err) => {
40
+ warn("Failed to start the Hugo renderer:", err);
41
+ });
42
+ });
43
+ }
44
+
45
+ /** Boots the WASM renderer once and builds the editor site from the snapshot. */
46
+ export function ensureEngine(): Promise<void> {
47
+ if (!enginePromise) {
48
+ enginePromise = startEngine().catch((err) => {
49
+ // Allow a retry on transient failures (e.g. a dropped WASM fetch).
50
+ enginePromise = null;
51
+ throw err;
52
+ });
53
+ }
54
+ return enginePromise;
55
+ }
56
+
57
+ async function startEngine(): Promise<void> {
58
+ const wasmUrl =
59
+ window.cc_hugo?.wasmUrl ?? "/_cloudcannon/hugo_renderer.wasm.gz";
60
+
61
+ group("Starting Hugo renderer");
62
+ log("Fetching WASM from", wasmUrl);
63
+
64
+ const response = await fetch(wasmUrl);
65
+ if (!response.ok || !response.body) {
66
+ groupEnd();
67
+ throw new Error(
68
+ `Failed to fetch Hugo WASM from ${wasmUrl}: HTTP ${response.status}`,
69
+ );
70
+ }
71
+
72
+ let wasmBuffer: ArrayBuffer;
73
+ if (wasmUrl.endsWith(".gz")) {
74
+ const decompressed = response.body.pipeThrough(
75
+ new DecompressionStream("gzip"),
76
+ );
77
+ wasmBuffer = await new Response(decompressed).arrayBuffer();
78
+ } else {
79
+ wasmBuffer = await response.arrayBuffer();
80
+ }
81
+
82
+ const go = new (globalThis as any).Go();
83
+ const { instance } = await WebAssembly.instantiate(
84
+ wasmBuffer,
85
+ go.importObject,
86
+ );
87
+ go.run(instance);
88
+
89
+ // The Go side registers its globals synchronously at startup.
90
+ while (
91
+ typeof (globalThis as { renderHugoPartials?: unknown })
92
+ .renderHugoPartials !== "function"
93
+ ) {
94
+ await new Promise((resolve) => setTimeout(resolve, 10));
95
+ }
96
+
97
+ const files = {
98
+ ...(window.cc_hugo_files ?? {}),
99
+ "cc-env": window.cc_hugo?.env ?? "production",
100
+ };
101
+ writeHugoFiles(JSON.stringify(files));
102
+
103
+ await loadAPIData();
104
+
105
+ const initError = initHugoEditorSite();
106
+
107
+ if (initError?.error) {
108
+ groupEnd();
109
+ throw new Error(`Hugo editor site failed to build: ${initError.error}`);
110
+ }
111
+
112
+ log("Hugo renderer ready");
113
+ groupEnd();
114
+ }
115
+
116
+ async function loadAPIData(): Promise<void> {
117
+ const files: Record<string, string> = {};
118
+ try {
119
+ currentFilePath = CloudCannon.currentFile().path;
120
+ } catch {
121
+ currentFilePath = "";
122
+ }
123
+ const currentPath = currentFilePath;
124
+
125
+ const collections = await CloudCannon.collections();
126
+ for (const collection of collections) {
127
+ collection.addEventListener("change", async (event) => {
128
+ const path = event.detail.sourcePath;
129
+ const frontMatter = await CloudCannon.file(path).data.get();
130
+ if (!frontMatter || typeof frontMatter !== "object") {
131
+ return;
132
+ }
133
+
134
+ writeHugoFiles(
135
+ JSON.stringify({
136
+ [path]: `---\n${JSON.stringify(frontMatter)}\n---\n`,
137
+ }),
138
+ );
139
+ });
140
+ collection.addEventListener("delete", (event) => {
141
+ if (currentPath !== event.detail.sourcePath) {
142
+ removeHugoFiles(JSON.stringify([event.detail.sourcePath]));
143
+ }
144
+ });
145
+
146
+ const items = await collection.items();
147
+ for (const file of items) {
148
+ const frontMatter = await file.data.get();
149
+ if (!frontMatter || typeof frontMatter !== "object") continue;
150
+ files[file.path] = `---\n${JSON.stringify(frontMatter)}\n---\n`;
151
+ }
152
+ }
153
+
154
+ const datasets = await CloudCannon.datasets();
155
+ for (const dataset of datasets) {
156
+ dataset.addEventListener("change", async (event) => {
157
+ const data = await CloudCannon.file(event.detail.sourcePath).data.get();
158
+ if (data === undefined || data === null) return;
159
+ writeHugoFiles(
160
+ JSON.stringify({
161
+ [datasetPath(event.detail.sourcePath)]: `${JSON.stringify(data)}\n`,
162
+ }),
163
+ );
164
+ });
165
+ dataset.addEventListener("delete", (event) => {
166
+ removeHugoFiles(JSON.stringify([datasetPath(event.detail.sourcePath)]));
167
+ });
168
+
169
+ const result = await dataset.items();
170
+ for (const file of Array.isArray(result) ? result : [result]) {
171
+ const data = await file.data.get();
172
+ if (data === undefined || data === null) continue;
173
+ files[datasetPath(file.path)] = `${JSON.stringify(data)}\n`;
174
+ }
175
+ }
176
+
177
+ if (Object.keys(files).length > 0) {
178
+ log(
179
+ `Loading editor content: ${Object.keys(files).length} files (editing ${currentPath})`,
180
+ );
181
+ writeHugoFiles(JSON.stringify(files));
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Maps a dataset's source path to its mirrored data-dir path. `.yaml`/`.yml`/`.json`
187
+ * keep their extension (Hugo natively decodes all three); anything else is
188
+ * rewritten to `.json` so a decoder Hugo understands handles it.
189
+ */
190
+ function datasetPath(apiPath: string): string {
191
+ if (/\.(ya?ml|json)$/i.test(apiPath)) return apiPath;
192
+ return `${apiPath.replace(/\.[^./]*$/, "")}.json`;
193
+ }
194
+
195
+ /**
196
+ * Queues a partial render, resolving with the rendered element once the batch
197
+ * window closes; all callers get batching transparently.
198
+ */
199
+ interface QueuedRender {
200
+ id: string;
201
+ partial: string;
202
+ props: Record<string, any>;
203
+ resolve: (el: HTMLElement) => void;
204
+ reject: (err: unknown) => void;
205
+ }
206
+
207
+ const BATCH_WINDOW_MS = 10;
208
+
209
+ let batch: QueuedRender[] = [];
210
+ let batchTimer: ReturnType<typeof setTimeout> | null = null;
211
+ let nextRenderId = 0;
212
+
213
+ /**
214
+ * Queues a partial render, resolving with the rendered element once the batch
215
+ * window closes; all callers get batching transparently.
216
+ */
217
+ export async function renderHugoPartial(
218
+ partial: string,
219
+ props: Record<string, any> = {},
220
+ ): Promise<HTMLElement> {
221
+ await ensureEngine();
222
+
223
+ return new Promise<HTMLElement>((resolve, reject) => {
224
+ const id = `cc-render-${nextRenderId++}`;
225
+ log("Queueing Hugo component:", partial, "Props:", props);
226
+ batch.push({ id, partial, props, resolve, reject });
227
+ if (batchTimer === null) {
228
+ batchTimer = setTimeout(flushBatch, BATCH_WINDOW_MS);
229
+ }
230
+ });
231
+ }
232
+
233
+ /** Flushes the batch: one renderer call, then demux the keyed output. */
234
+ function flushBatch(): void {
235
+ batchTimer = null;
236
+ const queued = batch;
237
+ batch = [];
238
+ if (queued.length === 0) return;
239
+
240
+ group(`Rendering ${queued.length} Hugo component(s)`);
241
+ try {
242
+ const result = renderHugoPartials(
243
+ JSON.stringify({
244
+ target: currentFilePath,
245
+ requests: queued.map(({ id, partial, props }) => ({
246
+ id,
247
+ partial,
248
+ props,
249
+ })),
250
+ }),
251
+ );
252
+ demuxBatch(queued, result);
253
+ } catch (err) {
254
+ for (const { partial, reject } of queued) {
255
+ reject(enhanceHugoError(String(err), partial));
256
+ }
257
+ }
258
+ groupEnd();
259
+ }
260
+
261
+ /**
262
+ * Resolves each request's keyed element from the combined output; errors and
263
+ * missing partials fail only the calls they apply to, the rest still resolves.
264
+ */
265
+ function demuxBatch(
266
+ queued: QueuedRender[],
267
+ result: { html?: string; error?: string } | null,
268
+ ): void {
269
+ if (result?.error || typeof result?.html !== "string") {
270
+ log("Render error:", result?.error);
271
+ for (const { partial, reject } of queued) {
272
+ reject(enhanceHugoError(result?.error ?? "no output", partial));
273
+ }
274
+ return;
275
+ }
276
+
277
+ const holder = document.createElement("div");
278
+ holder.innerHTML = result.html;
279
+ for (const render of queued) {
280
+ const keyed = holder.querySelector<HTMLElement>(
281
+ `[data-cc-render="${render.id}"]`,
282
+ );
283
+ if (!keyed) {
284
+ render.reject(
285
+ new Error(
286
+ `Hugo render produced no output for component "${render.partial}"`,
287
+ ),
288
+ );
289
+ continue;
290
+ }
291
+
292
+ const missing = keyed.querySelector("cc-missing-partial");
293
+ if (missing) {
294
+ render.reject(
295
+ missingComponentError(
296
+ missing.getAttribute("data-name") || render.partial,
297
+ ),
298
+ );
299
+ continue;
300
+ }
301
+
302
+ const failed = keyed.querySelector("cc-failed-partial");
303
+ if (failed) {
304
+ render.reject(
305
+ enhanceHugoError(
306
+ failed.getAttribute("data-message") || "unknown error",
307
+ failed.getAttribute("data-name") || render.partial,
308
+ ),
309
+ );
310
+ continue;
311
+ }
312
+
313
+ keyed.removeAttribute("data-cc-render");
314
+ log("Rendered HTML preview:", keyed.innerHTML.substring(0, 200));
315
+ render.resolve(keyed);
316
+ }
317
+ }
318
+
319
+ /** Builds the `(props) => HTMLElement` renderer the shared core calls. */
320
+ function createComponentRenderer(key: string): HugoComponentRenderer {
321
+ return async (props: Record<string, any> = {}) => {
322
+ // Render only once the engine is ready; the flush then sends the
323
+ // boot-captured currentFilePath as the batch's shared target.
324
+ await ensureEngine();
325
+ return renderHugoPartial(key, props);
326
+ };
327
+ }
328
+
329
+ export function initComponentProxy(): void {
330
+ const win = window;
331
+ const target = win.cc_components ?? {};
332
+
333
+ win.cc_components = new Proxy(target, {
334
+ get(registered, key, receiver) {
335
+ if (Reflect.has(registered, key)) {
336
+ return Reflect.get(registered, key, receiver);
337
+ }
338
+ if (typeof key === "string") {
339
+ return createComponentRenderer(key);
340
+ }
341
+ return undefined;
342
+ },
343
+ });
344
+ }
@@ -0,0 +1,42 @@
1
+ // Module-local logger; logging is gated on verbose mode, except
2
+ // `warn`/`warnOnce`.
3
+
4
+ let verboseEnabled = false;
5
+
6
+ export function setVerbose(enabled: boolean): void {
7
+ verboseEnabled = enabled;
8
+ if (enabled) {
9
+ console.log("Live editing verbose logging enabled");
10
+ }
11
+ }
12
+
13
+ export function log(...args: any[]): void {
14
+ if (verboseEnabled) {
15
+ console.log(...args);
16
+ }
17
+ }
18
+
19
+ export function warn(...args: any[]): void {
20
+ console.warn(...args);
21
+ }
22
+
23
+ const warnedKeys = new Set<string>();
24
+
25
+ /** Warns once per key for the lifetime of the page. */
26
+ export function warnOnce(key: string, ...args: any[]): void {
27
+ if (warnedKeys.has(key)) return;
28
+ warnedKeys.add(key);
29
+ warn(...args);
30
+ }
31
+
32
+ export function group(label: string): void {
33
+ if (verboseEnabled) {
34
+ console.group(label);
35
+ }
36
+ }
37
+
38
+ export function groupEnd(): void {
39
+ if (verboseEnabled) {
40
+ console.groupEnd();
41
+ }
42
+ }