@cloudcannon/editable-regions 0.0.20-rc.2 → 0.0.20

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.
@@ -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?" -}}
@@ -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) {
@@ -1,4 +1,6 @@
1
- import { createElement } from "react";
1
+ "use client";
2
+
3
+ import { createElement, useEffect, useRef } from "react";
2
4
  import { flushSync } from "react-dom";
3
5
  import { createRoot } from "react-dom/client";
4
6
  import { addEditableComponentRenderer } from "../helpers/cloudcannon.mjs";
@@ -26,3 +28,63 @@ export const registerReactComponent = (key, component) => {
26
28
 
27
29
  addEditableComponentRenderer(key, wrappedComponent);
28
30
  };
31
+
32
+ /**
33
+ * Root editable region component for React (e.g. Next.js App Router pages).
34
+ * Keeps editable regions from mutating the DOM before frameworks like Next.js finish hydrating.
35
+ *
36
+ * @param {{ tag?: string } & Record<string, any>} props
37
+ * All other props and attributes are forwarded to the rendered element.
38
+ */
39
+ export const EditableRegions = ({ tag: Tag = "div", ...attrs }) => {
40
+ const elementRef = useRef(null);
41
+ /** @type {{ current: any | null }} */
42
+ const editableRef = useRef(null);
43
+
44
+ useEffect(() => {
45
+ /** @param {any} regions */
46
+ const connectEditable = (regions) => {
47
+ if (!regions || editableRef.current) {
48
+ return;
49
+ }
50
+
51
+ editableRef.current = new regions.Editable(elementRef.current);
52
+ editableRef.current.connect();
53
+ };
54
+
55
+ const onLoad = () => {
56
+ connectEditable(/** @type {any} */ (window).editableRegions);
57
+ };
58
+
59
+ if (/** @type {any} */ (window).editableRegions) {
60
+ connectEditable(/** @type {any} */ (window).editableRegions);
61
+ } else {
62
+ document.addEventListener("editable-regions:load", onLoad, {
63
+ once: true,
64
+ });
65
+ }
66
+
67
+ return () => {
68
+ document.removeEventListener("editable-regions:load", onLoad);
69
+ editableRef.current?.disconnect();
70
+ editableRef.current = null;
71
+ };
72
+ }, []);
73
+
74
+ /** @type {Record<string, any>} */
75
+ const forwarded = {};
76
+ for (const [name, value] of Object.entries(attrs)) {
77
+ if (/^data-(prop|literal)/i.test(name)) {
78
+ continue;
79
+ }
80
+
81
+ forwarded[name] = value;
82
+ }
83
+
84
+ return createElement(Tag, {
85
+ ...forwarded,
86
+ ref: elementRef,
87
+ "data-editable": "_dynamic",
88
+ "data-prop": "",
89
+ });
90
+ };
@@ -0,0 +1,80 @@
1
+ <script>
2
+ import { onDestroy, onMount } from "svelte";
3
+
4
+ // Element type to render. Defaults to "div".
5
+ /** @type {string | undefined} */
6
+ export let tag = "div";
7
+
8
+ /** @type {any} */
9
+ let editable = null;
10
+ /** @type {HTMLElement | undefined} */
11
+ let element;
12
+ /** @type {(() => void) | null} */
13
+ let pendingLoadHandler = null;
14
+
15
+ // data-prop and data-literal attributes are controlled by this component
16
+ // itself; user-supplied ones are ignored.
17
+ $: restProps = filterProps($$restProps);
18
+
19
+ function filterProps(props) {
20
+ const filtered = {};
21
+ for (const [name, value] of Object.entries(props)) {
22
+ if (/^data-(prop|literal)/i.test(name)) {
23
+ continue;
24
+ }
25
+
26
+ filtered[name] = value;
27
+ }
28
+ return filtered;
29
+ }
30
+
31
+ onMount(() => {
32
+ /**
33
+ * @param {any} regions
34
+ */
35
+ const connectEditable = (regions) => {
36
+ if (!regions || editable) {
37
+ return;
38
+ }
39
+
40
+ editable = new regions.Editable(element);
41
+ editable.connect();
42
+ };
43
+
44
+ if (window.editableRegions) {
45
+ connectEditable(window.editableRegions);
46
+ } else {
47
+ pendingLoadHandler = () => {
48
+ connectEditable(window.editableRegions);
49
+ };
50
+ document.addEventListener(
51
+ "editable-regions:load",
52
+ pendingLoadHandler,
53
+ { once: true },
54
+ );
55
+ }
56
+ });
57
+
58
+ onDestroy(() => {
59
+ if (pendingLoadHandler) {
60
+ document.removeEventListener(
61
+ "editable-regions:load",
62
+ pendingLoadHandler,
63
+ );
64
+ pendingLoadHandler = null;
65
+ }
66
+
67
+ editable?.disconnect();
68
+ editable = null;
69
+ });
70
+ </script>
71
+
72
+ <svelte:element
73
+ this={tag}
74
+ {...restProps}
75
+ bind:this={element}
76
+ data-editable="_dynamic"
77
+ data-prop=""
78
+ >
79
+ <slot />
80
+ </svelte:element>
@@ -1,4 +1,6 @@
1
- import { addEditableComponentRenderer } from "../helpers/cloudcannon.mjs";
1
+ export { default as EditableRegions } from "./EditableRegions.svelte";
2
+
3
+ import { addEditableComponentRenderer } from "../../helpers/cloudcannon.mjs";
2
4
 
3
5
  /** @type{((component: any, args: { target: HTMLElement, props: unknown }) => void) | undefined} */
4
6
  let mount;