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

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.
@@ -64,6 +64,15 @@ export default class EditableRegionErrorCard extends HTMLElement {
64
64
 
65
65
  body.appendChild(stack);
66
66
  }
67
+
68
+ if ("hint" in error && typeof error.hint === "string") {
69
+ const hint = error.hint;
70
+ const hintElement = document.createElement("p");
71
+ hintElement.className = "hint";
72
+ hintElement.innerHTML = hint;
73
+
74
+ body.appendChild(hintElement);
75
+ }
67
76
  }
68
77
 
69
78
  if (this.hasAttribute("hint")) {
@@ -3,30 +3,95 @@
3
3
  * on the core's component error card, so it should tell the user what to fix.
4
4
  */
5
5
 
6
+ const TEMPLATE_FRAME_RE =
7
+ /^template: (.+?): executing ".*?" at .*?: error calling (.+?): /;
8
+ const FAILED_SEPARATOR = ": execute of template failed: ";
9
+
10
+ export interface ParsedHugoError {
11
+ /** Template refs (file:line:column), outermost call first. */
12
+ frames: string[];
13
+ /** The template function whose call failed in the innermost frame. */
14
+ call?: string;
15
+ /** The underlying error, with the template-call chain stripped. */
16
+ message: string;
17
+ }
18
+
19
+ export function parseHugoTemplateError(raw: string): ParsedHugoError {
20
+ const frames: string[] = [];
21
+ let call: string | undefined;
22
+ let rest = raw.trim();
23
+ for (;;) {
24
+ const match = rest.match(TEMPLATE_FRAME_RE);
25
+ if (!match) break;
26
+ frames.push(match[1]);
27
+ call = match[2];
28
+ rest = rest.slice(match[0].length);
29
+ const quotedSeparator = rest.match(
30
+ /^"[^"]*": execute of template failed: /,
31
+ );
32
+ if (quotedSeparator) {
33
+ rest = rest.slice(quotedSeparator[0].length);
34
+ } else {
35
+ const separator = rest.indexOf(FAILED_SEPARATOR);
36
+ const after =
37
+ separator === -1 ? "" : rest.slice(separator + FAILED_SEPARATOR.length);
38
+ if (after.startsWith("template: ")) {
39
+ rest = after;
40
+ }
41
+ }
42
+ }
43
+ return { frames, call, message: rest };
44
+ }
45
+
6
46
  export function enhanceHugoError(message: string, componentKey: string): Error {
47
+ const parsed = parseHugoTemplateError(message);
48
+
7
49
  let hint = "";
8
50
 
9
51
  if (/partial .* not found/i.test(message)) {
10
52
  hint =
11
- " This partial isn't in the bundled template snapshot. Check that it " +
53
+ "This partial isn't in the bundled template snapshot. Check that it " +
12
54
  "lives under one of the directories in " +
13
55
  "`params.editable_regions.template_dirs` (by default the partials, " +
14
56
  "render hooks, and shortcodes of your configured layout dir) " +
15
57
  "and rebuild the site.";
16
- } else if (/execute of template failed/i.test(message)) {
58
+ } else if (parsed.frames.length > 0) {
17
59
  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.";
60
+ "The partial errored while rendering in the editor. If the code " +
61
+ "should only run in the site build, guard it with " +
62
+ "`{{ if not site.Params.env_client }}`, " +
63
+ "or supply an editor-safe version via " +
64
+ "`params.editable_regions.templates_overrides`.";
21
65
  } else if (/logged \d+ errors/i.test(message)) {
22
66
  hint =
23
- " Hugo logged errors during the render — open the browser console " +
67
+ "Hugo logged errors during the render — open the browser console " +
24
68
  "for the underlying messages.";
25
69
  }
26
70
 
27
- return new Error(
28
- `Failed to render Hugo component "${componentKey}": ${message}.${hint}`,
29
- );
71
+ const primary =
72
+ parsed.frames.length > 0 && parsed.call
73
+ ? `${parsed.call}: ${parsed.message}`
74
+ : parsed.message;
75
+ const sentence = /[.!?]$/.test(primary) ? primary : `${primary}.`;
76
+
77
+ const error = new Error(
78
+ `Failed to render Hugo component "${componentKey}": ${sentence}`,
79
+ ) as Error & { hint?: string };
80
+
81
+ if (hint) {
82
+ error.hint = hint;
83
+ }
84
+
85
+ if (parsed.frames.length > 0) {
86
+ error.stack = parsed.frames
87
+ .slice()
88
+ .reverse()
89
+ .slice(0, 10)
90
+ .map((ref) => ` at ${ref}`)
91
+ .join("\n");
92
+ }
93
+
94
+ return error;
30
95
  }
31
96
 
32
97
  /** Error for a partial missing from the editor's template bundle; raised when
@@ -0,0 +1,282 @@
1
+ import { enhanceHugoError, missingComponentError } from "./errors.ts";
2
+ import { HugoWasm } from "./hugo-wasm.ts";
3
+ import { group, groupEnd, log, warn } from "./logger.ts";
4
+ import type { WorkerRequest, WorkerResponse } from "./worker-protocol.ts";
5
+
6
+ export interface EngineTransport {
7
+ boot(args: {
8
+ wasmUrl: string;
9
+ env: string;
10
+ files: Record<string, string>;
11
+ }): Promise<void>;
12
+ writeFiles(json: string): Promise<void>;
13
+ removeFiles(json: string): Promise<void>;
14
+ init(): Promise<string | undefined>;
15
+ render(payload: string): Promise<{ html?: string; error?: string }>;
16
+ }
17
+
18
+ class WorkerClient implements EngineTransport {
19
+ private pending = new Map<string, (response: WorkerResponse) => void>();
20
+ private nextId = 0;
21
+
22
+ constructor(private worker: Worker) {
23
+ worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
24
+ const response = event.data;
25
+ this.pending.get(response.id)?.(response);
26
+ this.pending.delete(response.id);
27
+ };
28
+ }
29
+
30
+ private send(request: WorkerRequest): Promise<WorkerResponse> {
31
+ return new Promise((resolve) => {
32
+ this.pending.set(request.id, resolve);
33
+ this.worker.postMessage(request);
34
+ });
35
+ }
36
+
37
+ async boot({
38
+ wasmUrl,
39
+ env,
40
+ files,
41
+ }: {
42
+ wasmUrl: string;
43
+ env: string;
44
+ files: Record<string, string>;
45
+ }): Promise<void> {
46
+ const response = await this.send({
47
+ id: `cc-worker-${this.nextId++}`,
48
+ kind: "boot",
49
+ wasmUrl,
50
+ env,
51
+ files,
52
+ });
53
+ if (response.error) {
54
+ throw new Error(response.error);
55
+ }
56
+ }
57
+
58
+ async writeFiles(json: string): Promise<void> {
59
+ await this.send({
60
+ id: `cc-worker-${this.nextId++}`,
61
+ kind: "writeFiles",
62
+ json,
63
+ });
64
+ }
65
+
66
+ async removeFiles(json: string): Promise<void> {
67
+ await this.send({
68
+ id: `cc-worker-${this.nextId++}`,
69
+ kind: "removeFiles",
70
+ json,
71
+ });
72
+ }
73
+
74
+ async init(): Promise<string | undefined> {
75
+ const response = await this.send({
76
+ id: `cc-worker-${this.nextId++}`,
77
+ kind: "init",
78
+ });
79
+ return response.error;
80
+ }
81
+
82
+ async render(payload: string): Promise<{ html?: string; error?: string }> {
83
+ const response = await this.send({
84
+ id: `cc-worker-${this.nextId++}`,
85
+ kind: "render",
86
+ payload,
87
+ });
88
+ return { html: response.html, error: response.error };
89
+ }
90
+ }
91
+
92
+ interface QueuedRender {
93
+ id: string;
94
+ partial: string;
95
+ props: Record<string, any>;
96
+ resolve: (el: HTMLElement) => void;
97
+ reject: (err: unknown) => void;
98
+ }
99
+
100
+ const BATCH_WINDOW_MS = 10;
101
+
102
+ export interface HugoRendererOptions {
103
+ wasmUrl: string;
104
+ env: string;
105
+ workerUrl?: string;
106
+ getTarget: () => string;
107
+ getFiles: () => Record<string, string>;
108
+ onBooted?: () => Promise<void>;
109
+ }
110
+
111
+ /**
112
+ * Main-thread facade over the Hugo engine: owns batching, the render-id demux
113
+ * into real elements, and transport selection. Runs the engine in a worker
114
+ * when one is provided and constructible, falling back to an in-page
115
+ * HugoWasm; the interface returns HTMLElements either way.
116
+ */
117
+ export class HugoRenderer {
118
+ private enginePromise: Promise<void> | null = null;
119
+ private transportPromise: Promise<EngineTransport> | null = null;
120
+ private batch: QueuedRender[] = [];
121
+ private batchTimer: ReturnType<typeof setTimeout> | null = null;
122
+ private nextRenderId = 0;
123
+
124
+ constructor(private options: HugoRendererOptions) {}
125
+
126
+ async ready(): Promise<void> {
127
+ this.enginePromise ??= this.start().catch((err) => {
128
+ this.enginePromise = null;
129
+ this.transportPromise = null;
130
+ throw err;
131
+ });
132
+ await this.enginePromise;
133
+ }
134
+
135
+ async writeFiles(json: string): Promise<void> {
136
+ await (await this.transport()).writeFiles(json);
137
+ }
138
+
139
+ async removeFiles(json: string): Promise<void> {
140
+ await (await this.transport()).removeFiles(json);
141
+ }
142
+
143
+ async renderPartial(
144
+ partial: string,
145
+ props: Record<string, any> = {},
146
+ ): Promise<HTMLElement> {
147
+ await this.ready();
148
+ return new Promise<HTMLElement>((resolve, reject) => {
149
+ const id = `cc-render-${this.nextRenderId++}`;
150
+ log("Queueing Hugo component:", partial, "Props:", props);
151
+ this.batch.push({ id, partial, props, resolve, reject });
152
+ if (this.batchTimer === null) {
153
+ this.batchTimer = setTimeout(() => this.flushBatch(), BATCH_WINDOW_MS);
154
+ }
155
+ });
156
+ }
157
+
158
+ private transport(): Promise<EngineTransport> {
159
+ this.transportPromise ??= Promise.resolve(this.createEngine());
160
+ return this.transportPromise;
161
+ }
162
+
163
+ private createEngine(): EngineTransport {
164
+ if (this.options.workerUrl && typeof Worker === "function") {
165
+ try {
166
+ log("Starting Hugo renderer in a worker");
167
+ return new WorkerClient(new Worker(this.options.workerUrl));
168
+ } catch {
169
+ warn("Hugo worker unavailable, rendering on the main thread");
170
+ }
171
+ }
172
+ return new HugoWasm();
173
+ }
174
+
175
+ private async start(): Promise<void> {
176
+ const engine = await this.transport();
177
+
178
+ group("Starting Hugo renderer");
179
+ await engine.boot({
180
+ wasmUrl: this.options.wasmUrl,
181
+ env: this.options.env,
182
+ files: this.options.getFiles(),
183
+ });
184
+ await this.options.onBooted?.();
185
+
186
+ const initError = await engine.init();
187
+ if (initError) {
188
+ groupEnd();
189
+ throw new Error(`Hugo editor site failed to build: ${initError}`);
190
+ }
191
+
192
+ log("Hugo renderer ready");
193
+ groupEnd();
194
+ }
195
+
196
+ private flushBatch(): void {
197
+ this.batchTimer = null;
198
+ const queued = this.batch;
199
+ this.batch = [];
200
+ if (queued.length === 0) return;
201
+
202
+ group(`Rendering ${queued.length} Hugo component(s)`);
203
+ this.runBatch(queued);
204
+ }
205
+
206
+ private async runBatch(queued: QueuedRender[]): Promise<void> {
207
+ try {
208
+ const engine = await this.ready().then(() => this.transport());
209
+ const result = await engine.render(
210
+ JSON.stringify({
211
+ target: this.options.getTarget(),
212
+ requests: queued.map(({ id, partial, props }) => ({
213
+ id,
214
+ partial,
215
+ props,
216
+ })),
217
+ }),
218
+ );
219
+ this.demuxBatch(queued, result);
220
+ } catch (err) {
221
+ for (const { partial, reject } of queued) {
222
+ reject(enhanceHugoError(String(err), partial));
223
+ }
224
+ } finally {
225
+ groupEnd();
226
+ }
227
+ }
228
+
229
+ private demuxBatch(
230
+ queued: QueuedRender[],
231
+ result: { html?: string; error?: string } | null,
232
+ ): void {
233
+ if (result?.error || typeof result?.html !== "string") {
234
+ log("Render error:", result?.error);
235
+ for (const { partial, reject } of queued) {
236
+ reject(enhanceHugoError(result?.error ?? "no output", partial));
237
+ }
238
+ return;
239
+ }
240
+
241
+ const holder = document.createElement("div");
242
+ holder.innerHTML = result.html;
243
+ for (const render of queued) {
244
+ const keyed = holder.querySelector<HTMLElement>(
245
+ `[data-cc-render="${render.id}"]`,
246
+ );
247
+ if (!keyed) {
248
+ render.reject(
249
+ new Error(
250
+ `Hugo render produced no output for component "${render.partial}"`,
251
+ ),
252
+ );
253
+ continue;
254
+ }
255
+
256
+ const missing = keyed.querySelector("cc-missing-partial");
257
+ if (missing) {
258
+ render.reject(
259
+ missingComponentError(
260
+ missing.getAttribute("data-name") || render.partial,
261
+ ),
262
+ );
263
+ continue;
264
+ }
265
+
266
+ const failed = keyed.querySelector("cc-failed-partial");
267
+ if (failed) {
268
+ render.reject(
269
+ enhanceHugoError(
270
+ failed.getAttribute("data-message") || "unknown error",
271
+ failed.getAttribute("data-name") || render.partial,
272
+ ),
273
+ );
274
+ continue;
275
+ }
276
+
277
+ keyed.removeAttribute("data-cc-render");
278
+ log("Rendered HTML preview:", keyed.innerHTML.substring(0, 200));
279
+ render.resolve(keyed);
280
+ }
281
+ }
282
+ }
@@ -0,0 +1,116 @@
1
+ import "./wasm_exec.js";
2
+
3
+ export interface HugoWasmBootArgs {
4
+ wasmUrl: string;
5
+ env: string;
6
+ files: Record<string, string>;
7
+ }
8
+
9
+ interface PendingOp {
10
+ run: () => Promise<unknown>;
11
+ resolve: (value: unknown) => void;
12
+ reject: (err: unknown) => void;
13
+ }
14
+
15
+ export class HugoWasm {
16
+ private pending: PendingOp[] = [];
17
+ private draining = false;
18
+
19
+ async boot(args: HugoWasmBootArgs): Promise<void> {
20
+ await this.enqueue(() => this.runBoot(args));
21
+ }
22
+
23
+ writeFiles(json: string): Promise<void> {
24
+ return this.enqueue(async () => {
25
+ writeHugoFiles(json);
26
+ });
27
+ }
28
+
29
+ removeFiles(json: string): Promise<void> {
30
+ return this.enqueue(async () => {
31
+ removeHugoFiles(json);
32
+ });
33
+ }
34
+
35
+ async init(): Promise<string | undefined> {
36
+ return this.enqueue(async () => initHugoEditorSite()?.error);
37
+ }
38
+
39
+ render(payload: string): Promise<{ html?: string; error?: string }> {
40
+ return this.enqueue(
41
+ () =>
42
+ new Promise((resolve) => {
43
+ renderHugoPartials(payload, resolve);
44
+ }),
45
+ );
46
+ }
47
+
48
+ private enqueue<T>(run: () => Promise<T>): Promise<T> {
49
+ return new Promise<T>((resolve, reject) => {
50
+ this.pending.push({
51
+ run,
52
+ resolve: resolve as (value: unknown) => void,
53
+ reject,
54
+ });
55
+ this.pump();
56
+ });
57
+ }
58
+
59
+ private pump(): void {
60
+ if (this.draining) return;
61
+ this.draining = true;
62
+ this.drain();
63
+ }
64
+
65
+ private async drain(): Promise<void> {
66
+ while (this.pending.length > 0) {
67
+ const op = this.pending.shift();
68
+ if (!op) break;
69
+ try {
70
+ op.resolve(await op.run());
71
+ } catch (err) {
72
+ op.reject(err);
73
+ }
74
+ }
75
+ this.draining = false;
76
+ }
77
+
78
+ private async runBoot({
79
+ wasmUrl,
80
+ env,
81
+ files,
82
+ }: HugoWasmBootArgs): Promise<void> {
83
+ const response = await fetch(wasmUrl);
84
+ if (!response.ok || !response.body) {
85
+ throw new Error(
86
+ `Failed to fetch Hugo WASM from ${wasmUrl}: HTTP ${response.status}`,
87
+ );
88
+ }
89
+
90
+ let wasmBuffer: ArrayBuffer;
91
+ if (wasmUrl.endsWith(".gz")) {
92
+ const decompressed = response.body.pipeThrough(
93
+ new DecompressionStream("gzip"),
94
+ );
95
+ wasmBuffer = await new Response(decompressed).arrayBuffer();
96
+ } else {
97
+ wasmBuffer = await response.arrayBuffer();
98
+ }
99
+
100
+ const go = new (globalThis as any).Go();
101
+ const { instance } = await WebAssembly.instantiate(
102
+ wasmBuffer,
103
+ go.importObject,
104
+ );
105
+ go.run(instance);
106
+
107
+ while (
108
+ typeof (globalThis as { renderHugoPartials?: unknown })
109
+ .renderHugoPartials !== "function"
110
+ ) {
111
+ await new Promise((resolve) => setTimeout(resolve, 10));
112
+ }
113
+
114
+ writeHugoFiles(JSON.stringify({ ...files, "cc-env": env }));
115
+ }
116
+ }
@@ -1,10 +1,9 @@
1
- import "./wasm_exec.js";
2
1
  import {
3
2
  apiLoadedPromise,
4
3
  CloudCannon,
5
4
  } from "../../../helpers/cloudcannon.mjs";
6
- import { enhanceHugoError, missingComponentError } from "./errors.ts";
7
- import { group, groupEnd, log, setVerbose, warn } from "./logger.ts";
5
+ import { HugoRenderer } from "./hugo-renderer.ts";
6
+ import { log, setVerbose, warn } from "./logger.ts";
8
7
 
9
8
  /** A `(props) => HTMLElement` renderer installed on `window.cc_components`. */
10
9
  type HugoComponentRenderer = (
@@ -15,8 +14,19 @@ type HugoComponentRenderer = (
15
14
  * no associated file (the renderer then falls back to the home page). */
16
15
  let currentFilePath = "";
17
16
 
18
- /** @type {Promise<void> | null} */
19
- let enginePromise: Promise<void> | null = null;
17
+ let renderer: HugoRenderer | null = null;
18
+
19
+ function getRenderer(): HugoRenderer {
20
+ renderer ??= new HugoRenderer({
21
+ wasmUrl: window.cc_hugo?.wasmUrl ?? "/_cloudcannon/hugo_renderer.wasm.gz",
22
+ env: window.cc_hugo?.env ?? "production",
23
+ workerUrl: window.cc_hugo?.workerUrl,
24
+ getTarget: () => currentFilePath,
25
+ getFiles: () => ({ ...(window.cc_hugo_files ?? {}) }),
26
+ onBooted: () => loadAPIData(getRenderer()),
27
+ });
28
+ return renderer;
29
+ }
20
30
 
21
31
  /**
22
32
  * Entry point, called by the prebuilt runtime bundle. Installs the component
@@ -36,84 +46,15 @@ export function initHugoLiveEditing(): void {
36
46
  initComponentProxy();
37
47
 
38
48
  apiLoadedPromise.then(() => {
39
- ensureEngine().catch((err) => {
40
- warn("Failed to start the Hugo renderer:", err);
41
- });
49
+ getRenderer()
50
+ .ready()
51
+ .catch((err) => {
52
+ warn("Failed to start the Hugo renderer:", err);
53
+ });
42
54
  });
43
55
  }
44
56
 
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> {
57
+ async function loadAPIData(engine: HugoRenderer): Promise<void> {
117
58
  const files: Record<string, string> = {};
118
59
  try {
119
60
  currentFilePath = CloudCannon.currentFile().path;
@@ -131,7 +72,7 @@ async function loadAPIData(): Promise<void> {
131
72
  return;
132
73
  }
133
74
 
134
- writeHugoFiles(
75
+ engine.writeFiles(
135
76
  JSON.stringify({
136
77
  [path]: `---\n${JSON.stringify(frontMatter)}\n---\n`,
137
78
  }),
@@ -139,7 +80,7 @@ async function loadAPIData(): Promise<void> {
139
80
  });
140
81
  collection.addEventListener("delete", (event) => {
141
82
  if (currentPath !== event.detail.sourcePath) {
142
- removeHugoFiles(JSON.stringify([event.detail.sourcePath]));
83
+ engine.removeFiles(JSON.stringify([event.detail.sourcePath]));
143
84
  }
144
85
  });
145
86
 
@@ -156,14 +97,16 @@ async function loadAPIData(): Promise<void> {
156
97
  dataset.addEventListener("change", async (event) => {
157
98
  const data = await CloudCannon.file(event.detail.sourcePath).data.get();
158
99
  if (data === undefined || data === null) return;
159
- writeHugoFiles(
100
+ engine.writeFiles(
160
101
  JSON.stringify({
161
102
  [datasetPath(event.detail.sourcePath)]: `${JSON.stringify(data)}\n`,
162
103
  }),
163
104
  );
164
105
  });
165
106
  dataset.addEventListener("delete", (event) => {
166
- removeHugoFiles(JSON.stringify([datasetPath(event.detail.sourcePath)]));
107
+ engine.removeFiles(
108
+ JSON.stringify([datasetPath(event.detail.sourcePath)]),
109
+ );
167
110
  });
168
111
 
169
112
  const result = await dataset.items();
@@ -178,7 +121,7 @@ async function loadAPIData(): Promise<void> {
178
121
  log(
179
122
  `Loading editor content: ${Object.keys(files).length} files (editing ${currentPath})`,
180
123
  );
181
- writeHugoFiles(JSON.stringify(files));
124
+ await engine.writeFiles(JSON.stringify(files));
182
125
  }
183
126
  }
184
127
 
@@ -192,137 +135,14 @@ function datasetPath(apiPath: string): string {
192
135
  return `${apiPath.replace(/\.[^./]*$/, "")}.json`;
193
136
  }
194
137
 
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
138
  /** Builds the `(props) => HTMLElement` renderer the shared core calls. */
320
139
  function createComponentRenderer(key: string): HugoComponentRenderer {
321
140
  return async (props: Record<string, any> = {}) => {
322
141
  // Render only once the engine is ready; the flush then sends the
323
142
  // boot-captured currentFilePath as the batch's shared target.
324
- await ensureEngine();
325
- return renderHugoPartial(key, props);
143
+ const engine = getRenderer();
144
+ await engine.ready();
145
+ return engine.renderPartial(key, props);
326
146
  };
327
147
  }
328
148
 
@@ -0,0 +1,18 @@
1
+ export type WorkerRequest =
2
+ | {
3
+ id: string;
4
+ kind: "boot";
5
+ wasmUrl: string;
6
+ env: string;
7
+ files: Record<string, string>;
8
+ }
9
+ | { id: string; kind: "writeFiles"; json: string }
10
+ | { id: string; kind: "removeFiles"; json: string }
11
+ | { id: string; kind: "init" }
12
+ | { id: string; kind: "render"; payload: string };
13
+
14
+ export interface WorkerResponse {
15
+ id: string;
16
+ html?: string;
17
+ error?: string;
18
+ }
@@ -0,0 +1,51 @@
1
+ import { HugoWasm } from "./hugo-wasm.ts";
2
+ import type { WorkerRequest } from "./worker-protocol.ts";
3
+
4
+ let engine: HugoWasm | null = null;
5
+
6
+ export async function handleWorkerMessage(
7
+ event: MessageEvent<WorkerRequest>,
8
+ ): Promise<void> {
9
+ const request = event.data;
10
+ try {
11
+ switch (request.kind) {
12
+ case "boot": {
13
+ engine = new HugoWasm();
14
+ await engine.boot(request);
15
+ break;
16
+ }
17
+ case "writeFiles": {
18
+ await engine?.writeFiles(request.json);
19
+ break;
20
+ }
21
+ case "removeFiles": {
22
+ await engine?.removeFiles(request.json);
23
+ break;
24
+ }
25
+ case "init": {
26
+ const error = await engine?.init();
27
+ if (error) {
28
+ postMessage({ id: request.id, error });
29
+ return;
30
+ }
31
+ break;
32
+ }
33
+ case "render": {
34
+ const result = await engine?.render(request.payload);
35
+ postMessage({
36
+ id: request.id,
37
+ html: result?.html,
38
+ error: result?.error,
39
+ });
40
+ return;
41
+ }
42
+ }
43
+ postMessage({ id: request.id });
44
+ } catch (err) {
45
+ postMessage({ id: request.id, error: String(err) });
46
+ }
47
+ }
48
+
49
+ self.onmessage = (event) => {
50
+ handleWorkerMessage(event);
51
+ };
@@ -71,15 +71,26 @@
71
71
  {{- end -}}
72
72
  {{- end -}}
73
73
 
74
+ {{- $workerSrc := resources.Get "integrations/hugo/browser/worker.ts" -}}
75
+ {{- $workerUrl := "" -}}
76
+ {{- if $workerSrc -}}
77
+ {{- $workerUrl = ($workerSrc | js.Build (dict
78
+ "minify" (not hugo.IsDevelopment)
79
+ "targetPath" "_cloudcannon/hugo-worker.js"
80
+ ) | fingerprint).RelPermalink -}}
81
+ {{- end -}}
82
+
74
83
  {{- $ctx := dict
75
84
  "meta" (dict
76
85
  "generator" (printf "Hugo %s" hugo.Version)
77
86
  "wasmUrl" $wasmUrl
87
+ "workerUrl" $workerUrl
78
88
  "verbose" ($cfg.verbose | default false)
79
89
  "env" (hugo.Environment | default "production")
80
90
  )
81
91
  "files" (merge $templateFiles $configFiles $additionalFiles)
82
92
  -}}
93
+
83
94
  {{- $entry := resources.Get "integrations/hugo/browser/entry.js" -}}
84
95
  {{- if not $entry -}}
85
96
  {{- errorf "editable-regions: integrations/hugo/browser/entry.js not found in assets — is the editable-regions module imported and mounted?" -}}
@@ -4,7 +4,6 @@ go 1.26.0
4
4
 
5
5
  require (
6
6
  github.com/fsnotify/fsnotify v1.9.0
7
- github.com/goccy/go-yaml v1.19.2
8
7
  github.com/gohugoio/hugo v0.164.0
9
8
  github.com/spf13/afero v1.15.0
10
9
  )
@@ -40,11 +39,12 @@ require (
40
39
  github.com/evanw/esbuild v0.28.1 // indirect
41
40
  github.com/fatih/color v1.18.0 // indirect
42
41
  github.com/frankban/quicktest v1.14.6 // indirect
43
- github.com/getkin/kin-openapi v0.140.0 // indirect
42
+ github.com/getkin/kin-openapi v0.144.0 // indirect
44
43
  github.com/go-openapi/jsonpointer v0.22.5 // indirect
45
44
  github.com/go-openapi/swag/jsonname v0.25.5 // indirect
46
45
  github.com/gobuffalo/flect v1.0.3 // indirect
47
46
  github.com/gobwas/glob v0.2.3 // indirect
47
+ github.com/goccy/go-yaml v1.19.2 // indirect
48
48
  github.com/gohugoio/gift v0.2.0 // indirect
49
49
  github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6 // indirect
50
50
  github.com/gohugoio/go-radix v1.2.0 // indirect
@@ -69,8 +69,8 @@ require (
69
69
  github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
70
70
  github.com/muesli/smartcrop v0.3.0 // indirect
71
71
  github.com/niklasfasching/go-org v1.9.1 // indirect
72
- github.com/oasdiff/yaml v0.1.0 // indirect
73
- github.com/oasdiff/yaml3 v0.0.13 // indirect
72
+ github.com/oasdiff/yaml v0.1.1 // indirect
73
+ github.com/oasdiff/yaml3 v0.0.14 // indirect
74
74
  github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
75
75
  github.com/olekukonko/errors v1.2.0 // indirect
76
76
  github.com/olekukonko/ll v0.1.6 // indirect
@@ -74,8 +74,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
74
74
  github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
75
75
  github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
76
76
  github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
77
- github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g=
78
- github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g=
77
+ github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y=
78
+ github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
79
79
  github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
80
80
  github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
81
81
  github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
@@ -151,10 +151,10 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6
151
151
  github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
152
152
  github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
153
153
  github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
154
- github.com/oasdiff/yaml v0.1.0 h1:0bqZjfKc/8S9urj4JuwepX41WX9EoA6ifhU3SV06cXg=
155
- github.com/oasdiff/yaml v0.1.0/go.mod h1:kOlRmMdL2X3vucLCEQO5u61SU22RysnfXvcttrZA1O0=
156
- github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg=
157
- github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
154
+ github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
155
+ github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
156
+ github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
157
+ github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
158
158
  github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
159
159
  github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
160
160
  github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo=
@@ -3,11 +3,13 @@ package main
3
3
  import (
4
4
  "context"
5
5
  "encoding/json"
6
+ "errors"
6
7
  "fmt"
7
8
  "io"
8
9
  "log"
9
10
  "path/filepath"
10
11
  "strings"
12
+ "sync"
11
13
  "syscall/js"
12
14
  _ "time/tzdata"
13
15
 
@@ -96,6 +98,7 @@ func (builder *editorSiteBuilder) loadConfig() error {
96
98
  cfg.Base.WorkingDir = ""
97
99
  cfg.Base.Internal.Running = true
98
100
  cfg.Base.Internal.Watch = true
101
+ cfg.Base.EnableGitInfo = false
99
102
 
100
103
  for _, languageConfig := range cfg.LanguageConfigMap {
101
104
  languageConfig.Internal.Running = true
@@ -235,6 +238,8 @@ func normalizeMemfsPath(p string) string {
235
238
 
236
239
  var builder editorSiteBuilder
237
240
 
241
+ var renderMutex sync.Mutex
242
+
238
243
  func main() {
239
244
  builder = editorSiteBuilder{Afs: afero.NewMemMapFs()}
240
245
 
@@ -340,32 +345,54 @@ func (builder *editorSiteBuilder) targetPage(target string) page.Page {
340
345
  }
341
346
 
342
347
  func renderHugoPartials(this js.Value, args []js.Value) interface{} {
348
+ if len(args) != 2 || args[1].Type() != js.TypeFunction {
349
+ return errorValue("renderHugoPartials requires a callback function argument")
350
+ }
351
+ callback := args[1]
352
+
353
+ deliver := func(html string, err error) {
354
+ result := map[string]interface{}{}
355
+ if err != nil {
356
+ result["error"] = err.Error()
357
+ } else {
358
+ result["html"] = html
359
+ }
360
+ callback.Invoke(js.ValueOf(result))
361
+ }
362
+
343
363
  var payload renderRequests
344
364
  if err := json.Unmarshal([]byte(args[0].String()), &payload); err != nil {
345
- return errorValue("bad renderHugoPartials payload: %s", err)
365
+ deliver("", fmt.Errorf("bad renderHugoPartials payload: %s", err))
366
+ return nil
346
367
  }
347
368
  reqs := payload.Requests
348
369
  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)
370
+ deliver("", errors.New("renderHugoPartials requires at least one request"))
371
+ return nil
353
372
  }
354
-
355
373
  for _, req := range reqs {
356
374
  if req.Partial == "" {
357
- return errorValue("renderHugoPartials requires a \"partial\" name on every request")
375
+ deliver("", errors.New("renderHugoPartials requires a \"partial\" name on every request"))
376
+ return nil
358
377
  }
359
378
  }
360
379
 
361
- html, err := builder.renderBatch(payload.Target, reqs)
362
- if err != nil {
363
- return errorValue("%s", err)
364
- }
380
+ go func() {
381
+ html, err := builder.buildAndRender(payload.Target, reqs)
382
+ deliver(html, err)
383
+ }()
365
384
 
366
- return js.ValueOf(map[string]interface{}{
367
- "html": html,
368
- })
385
+ return nil
386
+ }
387
+
388
+ func (builder *editorSiteBuilder) buildAndRender(target string, reqs []renderRequest) (string, error) {
389
+ renderMutex.Lock()
390
+ defer renderMutex.Unlock()
391
+
392
+ if err := builder.buildIfDirty(); err != nil {
393
+ return "", fmt.Errorf("editor site build failed: %s", err)
394
+ }
395
+ return builder.renderBatch(target, reqs)
369
396
  }
370
397
 
371
398
  func (builder *editorSiteBuilder) renderBatch(target string, reqs []renderRequest) (string, error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudcannon/editable-regions",
3
- "version": "0.0.20-rc.1",
3
+ "version": "0.0.20-rc.3",
4
4
  "type": "module",
5
5
  "description": "Visual Editing for the CloudCannon CMS.",
6
6
  "keywords": [
package/types/hugo.d.ts CHANGED
@@ -29,6 +29,7 @@ declare global {
29
29
  interface HugoRuntimeMeta {
30
30
  generator?: string;
31
31
  wasmUrl?: string;
32
+ workerUrl?: string;
32
33
  verbose?: boolean;
33
34
  env?: string;
34
35
  }
@@ -53,7 +54,7 @@ declare global {
53
54
  CloudCannonAPI?: CloudCannonVisualEditorAPIRouter;
54
55
  /** The installed v0/v1 CloudCannon API for this page. */
55
56
  CloudCannon?: CloudCannonVisualEditorAPIV0 | CloudCannonVisualEditorAPIV1;
56
- /** Emitter metadata: generator, wasmUrl, verbose, env. */
57
+ /** Emitter metadata: generator, wasmUrl, workerUrl, verbose, env. */
57
58
  cc_hugo?: HugoRuntimeMeta;
58
59
  /** Template/config snapshot keyed by physical path. */
59
60
  cc_hugo_files?: Record<string, string>;
@@ -65,5 +66,8 @@ declare global {
65
66
  function removeHugoFiles(json: string): HugoEditorResult | null;
66
67
  function readHugoFiles(json: string): Record<string, string>;
67
68
  function initHugoEditorSite(): HugoEditorResult | null;
68
- function renderHugoPartials(json: string): HugoRenderResult | null;
69
+ function renderHugoPartials(
70
+ json: string,
71
+ callback: (result: HugoRenderResult) => void,
72
+ ): void;
69
73
  }