@cloudcannon/editable-regions 0.0.19 → 0.0.20-rc.2
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/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/globals.mjs +7 -1
- package/package.json +31 -19
- package/types/hugo.d.ts +69 -0
|
@@ -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
|
+
}
|