@cloudcannon/editable-regions 0.0.18 → 0.0.20-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/helpers/checks.ts +0 -22
- package/helpers/hydrate-editable-regions.ts +2 -1
- package/integrations/astro/react-renderer.mjs +94 -19
- package/integrations/astro/svelte-renderer.mjs +72 -15
- package/integrations/astro/vue-renderer.mjs +61 -0
- package/integrations/eleventy/browser/collect-config.mjs +69 -8
- package/integrations/eleventy/browser/inert.mjs +35 -0
- package/integrations/eleventy/browser/process-shim.mjs +32 -0
- package/integrations/eleventy/browser/stub-mode.mjs +61 -0
- package/integrations/eleventy/index.cjs +28 -1
- package/integrations/eleventy/index.mjs +82 -30
- package/integrations/hugo/browser/entry.js +13 -0
- package/integrations/hugo/browser/errors.ts +41 -0
- package/integrations/hugo/browser/index.ts +344 -0
- package/integrations/hugo/browser/logger.ts +42 -0
- package/integrations/hugo/browser/wasm_exec.js +575 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/find-config-files.html +14 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/find-dep-template-files.html +66 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/find-files-with-extension.html +27 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/find-template-files.html +51 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/load-deps.html +5 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/normalize-extensions.html +9 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/resources.html +87 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions/wasm-url.html +28 -0
- package/integrations/hugo/hugo-module/layouts/partials/editable-regions.html +6 -0
- package/integrations/hugo/renderer/build.sh +24 -0
- package/integrations/hugo/renderer/go.mod +100 -0
- package/integrations/hugo/renderer/go.sum +241 -0
- package/integrations/hugo/renderer/main.go +412 -0
- package/integrations/liquid/README.md +92 -3
- package/integrations/liquid/errors.mjs +3 -1
- package/integrations/liquid/fs.mjs +11 -1
- package/integrations/liquid/globals.mjs +131 -26
- package/integrations/liquid/index.mjs +5 -2
- package/integrations/vue.mjs +28 -0
- package/nodes/editable-array-item.ts +6 -1
- package/nodes/editable-component.ts +2 -3
- package/nodes/editable-text.ts +6 -0
- package/nodes/editable.ts +6 -1
- package/package.json +132 -90
- package/types/astro.d.ts +4 -0
- package/types/eleventy.d.ts +11 -4
- package/types/hugo.d.ts +69 -0
- package/types/liquid.d.ts +0 -1
- package/types/vue.d.ts +40 -0
|
@@ -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
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
628
|
+
imports += `\nimport ${id} from "./${file}";\n`;
|
|
629
|
+
body += `${registerFn}(${JSON.stringify(name)}, ${id});\n`;
|
|
578
630
|
}
|
|
579
631
|
}
|
|
580
632
|
|
|
581
|
-
return
|
|
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
|
+
}
|