@cloudcannon/editable-regions 0.0.16 → 0.0.18

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.
@@ -0,0 +1,582 @@
1
+ import fs from "node:fs";
2
+ import { builtinModules, createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import esbuild from "esbuild";
5
+ import { createIncludeWithTag } from "../liquid/include-with-tag.mjs";
6
+
7
+ /**
8
+ * @typedef {import("../../types/eleventy").LiquidOptions} LiquidOptions
9
+ * @typedef {import("../../types/eleventy").PluginOptions} PluginOptions
10
+ * @typedef {import("../../types/eleventy").NormalizedPluginOptions} NormalizedPluginOptions
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} EleventyDirectories
15
+ * @property {string} input
16
+ * @property {string} includes - Normalized, relative to project root
17
+ * @property {string} data
18
+ * @property {string} output
19
+ */
20
+
21
+ /**
22
+ * Payload Eleventy passes to `eleventy.after`. `directories` is the 3.x shape;
23
+ * `dir` the older fallback still passed in 3.x.
24
+ *
25
+ * @typedef {Object} EleventyEventPayload
26
+ * @property {EleventyDirectories} [directories]
27
+ * @property {EleventyDirectories} [dir]
28
+ * @property {Array<{inputPath?: string, outputPath?: string, url?: string}>} [results]
29
+ */
30
+
31
+ /**
32
+ * @typedef {(liquidEngine: import("liquidjs").Liquid) => { parse: (...args: any[]) => void, render: (...args: any[]) => unknown }} LiquidTagFactory
33
+ */
34
+
35
+ /**
36
+ * @typedef {"eleventy.before" | "eleventy.after" | "eleventy.beforeWatch" | "eleventy.beforeConfig"} EleventyEventName
37
+ */
38
+
39
+ /**
40
+ * The subset of Eleventy's (untyped) user config we touch.
41
+ *
42
+ * @typedef {Object} EleventyConfig
43
+ * @property {(name: string, factory: LiquidTagFactory) => void} addLiquidTag
44
+ * @property {(event: EleventyEventName, handler: (payload: EleventyEventPayload) => Promise<void> | void) => void} on
45
+ * @property {EleventyDirectories} dir
46
+ */
47
+
48
+ /**
49
+ * Eleventy plugin for CloudCannon editable regions. Registers Liquid tags
50
+ * and builds the live-editing client bundle.
51
+ *
52
+ * @param {EleventyConfig} eleventyConfig
53
+ * @param {PluginOptions} [pluginOptions]
54
+ */
55
+ export default function editableRegionsPlugin(eleventyConfig, pluginOptions) {
56
+ const options = normalizePluginOptions(pluginOptions);
57
+
58
+ // No supported languages enabled — nothing to register or bundle.
59
+ if (!options.liquid) return;
60
+ const liquidOptions = options.liquid;
61
+
62
+ eleventyConfig.addLiquidTag("includeWith", createIncludeWithTag);
63
+
64
+ eleventyConfig.on("eleventy.after", async ({ directories, dir, results }) => {
65
+ // 3.x `directories`, legacy `dir`, then a closure fallback.
66
+ const dirs = directories ?? dir ?? eleventyConfig.dir;
67
+
68
+ const rawExtensions = liquidOptions.extensions ?? [".liquid", ".html"];
69
+ const normalizedExtensions = rawExtensions.map((ext) =>
70
+ ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`,
71
+ );
72
+
73
+ const liveEditingSource = await generateLiveEditingSource(
74
+ options,
75
+ dirs,
76
+ normalizedExtensions,
77
+ results,
78
+ );
79
+
80
+ // esbuild only matches the final extension, so .bookshop.liquid -> .liquid
81
+ /** @type {Record<string, import('esbuild').Loader>} */
82
+ const loader = {};
83
+ for (const ext of normalizedExtensions) {
84
+ loader[ext.slice(ext.lastIndexOf("."))] = "text";
85
+ }
86
+
87
+ await esbuild.build({
88
+ stdin: {
89
+ contents: liveEditingSource,
90
+ resolveDir: process.cwd(),
91
+ },
92
+ loader,
93
+ bundle: true,
94
+ platform: "browser",
95
+ // The bundle imports the user's real Eleventy config (see
96
+ // `emitConfigMirror`), dragging in Node/build-time imports — stub them.
97
+ plugins: [createBrowserStubPlugin(liquidOptions.browserStub)],
98
+ outfile: options.output ?? `${dirs.output}/register-components.js`,
99
+ });
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Liquid is enabled implicitly; only `liquid: false` opts out.
105
+ *
106
+ * @param {PluginOptions | undefined} pluginOptions
107
+ * @returns {NormalizedPluginOptions}
108
+ */
109
+ function normalizePluginOptions(pluginOptions) {
110
+ const opts = pluginOptions ?? {};
111
+ return {
112
+ ...opts,
113
+ liquid: normalizeLiquidOption(opts.liquid),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Resolves the `liquid` option to either an options object (enabled) or
119
+ * `false` (disabled). Liquid is on by default, so anything but an explicit
120
+ * `false` enables it; `true` and an omitted value mean "on with defaults".
121
+ *
122
+ * @param {LiquidOptions | boolean | undefined} liquid
123
+ * @returns {LiquidOptions | false}
124
+ */
125
+ function normalizeLiquidOption(liquid) {
126
+ // Explicit opt-out is the only way to disable Liquid.
127
+ if (liquid === false) return false;
128
+
129
+ // An options object is used as-is.
130
+ if (liquid && typeof liquid === "object") return liquid;
131
+
132
+ // `true`, `undefined`, or `null` → enabled with default options.
133
+ return {};
134
+ }
135
+
136
+ /**
137
+ * Specifiers stubbed by exact match (subpaths bundle normally — notably the
138
+ * Node plugin's own `/browser` and `/liquid` runtime). `@11ty/eleventy` is
139
+ * matched separately in `shouldStub` because its subpaths must be stubbed too.
140
+ */
141
+ const ALWAYS_STUBBED = ["@cloudcannon/editable-regions/eleventy"];
142
+
143
+ /**
144
+ * esbuild plugin resolving Node built-ins and build-time-only packages to a
145
+ * Proxy that survives `import` and property access but throws when called or
146
+ * constructed — so the user's config bundles, and only a helper that actually
147
+ * invokes a Node API at render time fails.
148
+ *
149
+ * @param {string[]} [extraStubs] - Extra specifiers to stub
150
+ * (`pluginOptions.liquid.browserStub`), e.g. native deps like `sharp`.
151
+ * @returns {import('esbuild').Plugin}
152
+ */
153
+ function createBrowserStubPlugin(extraStubs = []) {
154
+ const nodeBuiltins = new Set([
155
+ ...builtinModules,
156
+ ...builtinModules.map((m) => `node:${m}`),
157
+ ]);
158
+ const exactStubs = new Set([...ALWAYS_STUBBED, ...extraStubs]);
159
+
160
+ const shouldStub = (/** @type {string} */ id) => {
161
+ if (nodeBuiltins.has(id)) return true;
162
+
163
+ if (id === "@11ty/eleventy" || id.startsWith("@11ty/eleventy/")) {
164
+ return true;
165
+ }
166
+
167
+ return exactStubs.has(id);
168
+ };
169
+
170
+ return {
171
+ name: "editable-regions-browser-stub",
172
+ setup(build) {
173
+ build.onResolve({ filter: /.*/ }, (args) =>
174
+ shouldStub(args.path)
175
+ ? { path: args.path, namespace: "er-stub" }
176
+ : null,
177
+ );
178
+ build.onLoad({ filter: /.*/, namespace: "er-stub" }, () => ({
179
+ contents: `
180
+ const handler = {
181
+ get: () => new Proxy(function () {}, handler),
182
+ apply: () => {
183
+ throw new Error("editable-regions: a Node/build-time API was called in the browser live-editing bundle. Provide a browser-friendly override via pluginOptions.liquid.<kind>.");
184
+ },
185
+ construct: () => {
186
+ throw new Error("editable-regions: a Node/build-time API was constructed in the browser live-editing bundle. Provide a browser-friendly override via pluginOptions.liquid.<kind>.");
187
+ },
188
+ };
189
+ module.exports = new Proxy(function () {}, handler);
190
+ `,
191
+ loader: "js",
192
+ }));
193
+ },
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Resolves the user's Eleventy config path (11ty doesn't expose it): an
199
+ * explicit `configPath`, else the first default config filename in the root.
200
+ *
201
+ * @param {LiquidOptions} liquidOptions
202
+ * @returns {string | null} Absolute path, or `null` if none found
203
+ */
204
+ function resolveEleventyConfigPath(liquidOptions) {
205
+ if (liquidOptions.configPath) {
206
+ return path.resolve(process.cwd(), liquidOptions.configPath);
207
+ }
208
+ // Matches 11ty's default resolution order (TemplateConfig.js).
209
+ const defaults = [
210
+ ".eleventy.js",
211
+ "eleventy.config.js",
212
+ "eleventy.config.mjs",
213
+ "eleventy.config.cjs",
214
+ ];
215
+ for (const name of defaults) {
216
+ const candidate = path.resolve(process.cwd(), name);
217
+ if (fs.existsSync(candidate)) return candidate;
218
+ }
219
+ return null;
220
+ }
221
+
222
+ /**
223
+ * Emits the import of the user's Eleventy config plus the call that replays it
224
+ * in the browser to auto-mirror its helpers (see
225
+ * `collectAndRegisterEleventyHelpers`). Passes the per-kind override names to
226
+ * skip; those are registered separately by `emitImportRegistrations` so the
227
+ * override wins.
228
+ *
229
+ * @param {string} configPath - Absolute path to the Eleventy config
230
+ * @param {LiquidOptions | undefined} liquidOptions
231
+ * @returns {string} JS source
232
+ */
233
+ function emitConfigMirror(configPath, liquidOptions) {
234
+ const skip = {
235
+ filters: Object.keys(liquidOptions?.filters ?? {}),
236
+ shortcodes: Object.keys(liquidOptions?.shortcodes ?? {}),
237
+ pairedShortcodes: Object.keys(liquidOptions?.pairedShortcodes ?? {}),
238
+ tags: Object.keys(liquidOptions?.tags ?? {}),
239
+ };
240
+
241
+ return (
242
+ `\nimport userEleventyConfig from ${JSON.stringify(configPath)};\n` +
243
+ `collectAndRegisterEleventyHelpers(userEleventyConfig, ${JSON.stringify({ skip })});\n`
244
+ );
245
+ }
246
+
247
+ /**
248
+ * Builds the JS source for the live-editing client bundle: imports and
249
+ * `register*` calls for components, filters, shortcodes, and tags.
250
+ *
251
+ * @param {NormalizedPluginOptions} options
252
+ * @param {EleventyDirectories} directories
253
+ * @param {string[]} normalizedExtensions - Lowercase, leading-dot
254
+ * @param {Array<{inputPath?: string, outputPath?: string, url?: string}> | undefined} results - From `eleventy.after`
255
+ * @returns {Promise<string>}
256
+ */
257
+ async function generateLiveEditingSource(
258
+ options,
259
+ directories,
260
+ normalizedExtensions,
261
+ results,
262
+ ) {
263
+ let source = "";
264
+
265
+ if (options.liquid) {
266
+ const liquidOptions = options.liquid;
267
+ // `input` alongside `includes` so `{% include %}` reaches sibling files.
268
+ const componentDirs = liquidOptions.componentDirs ?? [
269
+ directories.includes,
270
+ directories.input,
271
+ ];
272
+ const ignoreDirectories = liquidOptions.ignoreDirectories ?? [
273
+ directories.output,
274
+ "node_modules",
275
+ ];
276
+ const normalizedIgnoreDirs = ignoreDirectories.map((dir) =>
277
+ dir.toLowerCase(),
278
+ );
279
+
280
+ source += `
281
+ import { createSharedLiquidEngine, registerLiquidComponent, registerFilter, registerShortcode, registerPairedShortcode, registerCustomTag, registerGlobals, registerEleventyData, registerPkg, registerPageMap, initComponentProxy, setVerbose } from '@cloudcannon/editable-regions/liquid';
282
+ import { registerEleventyBuiltins, collectAndRegisterEleventyHelpers } from '@cloudcannon/editable-regions/eleventy/browser';
283
+
284
+ setVerbose(${Boolean(options.verbose)});
285
+
286
+ const liquidEngine = createSharedLiquidEngine({
287
+ root: ${JSON.stringify(componentDirs)},
288
+ extname: ".liquid",
289
+ strictFilters: true,
290
+ });
291
+
292
+ // Wires on Eleventy's built-in filters/shortcodes (browser ports)
293
+ // and RenderPlugin shims onto the host-agnostic engine.
294
+ registerEleventyBuiltins(liquidEngine);
295
+
296
+ window.cc_liquid_files = {};
297
+ `;
298
+
299
+ // User-supplied globals, embedded as a literal so editor-rendered
300
+ // templates read the same values the build exposes.
301
+ if (options.globals && Object.keys(options.globals).length > 0) {
302
+ source += `\nregisterGlobals(${JSON.stringify(options.globals)});\n`;
303
+ }
304
+
305
+ // Static `eleventy` global so templates branching on `eleventy.version` /
306
+ // `eleventy.env.runMode` see something sensible.
307
+ const eleventyData = buildEleventyData(directories);
308
+ source += `\nregisterEleventyData(${JSON.stringify(eleventyData)});\n`;
309
+
310
+ // 11ty exposes the project's package.json as the `pkg` global by default.
311
+ const pkg = buildPkg();
312
+ if (pkg) {
313
+ source += `\nregisterPkg(${JSON.stringify(pkg)});\n`;
314
+ }
315
+
316
+ // Build-time page map from 11ty's `results`, so the page/collections
317
+ // proxies and `inputPathToUrl` resolve computed/templated permalinks.
318
+ const pageMap = buildPageMap(results);
319
+ if (Object.keys(pageMap).length > 0) {
320
+ source += `\nregisterPageMap(${JSON.stringify(pageMap)});\n`;
321
+ }
322
+
323
+ // Pre-populate `window.cc_liquid_files` with every includable template,
324
+ // the map LiquidJS's in-memory fs (`liquid/fs.mjs`) resolves against.
325
+ const allLiquidFiles = await findAllLiquidFiles(
326
+ componentDirs,
327
+ normalizedExtensions,
328
+ normalizedIgnoreDirs,
329
+ );
330
+
331
+ for (const [i, filePath] of allLiquidFiles.entries()) {
332
+ const id = `liquidFile_${i}`;
333
+ source += `import ${id} from "./${filePath}";
334
+
335
+ window.cc_liquid_files["${filePath}"] = ${id};
336
+ `;
337
+ }
338
+
339
+ // Auto-mirror the user's config helpers by importing and replaying the
340
+ // real config in the browser. See `emitConfigMirror`.
341
+ const configPath = resolveEleventyConfigPath(liquidOptions);
342
+ if (configPath) {
343
+ source += emitConfigMirror(configPath, liquidOptions);
344
+ } else {
345
+ console.warn(
346
+ "[editable-regions] Could not locate an Eleventy config file to " +
347
+ "auto-mirror helpers from. Set `pluginOptions.liquid.configPath` " +
348
+ "if your config isn't at a default location. Filters/shortcodes " +
349
+ "defined in the config won't be available in live editing " +
350
+ "(overrides still work).",
351
+ );
352
+ }
353
+
354
+ // Register browser-side overrides and pinned components. Override names
355
+ // are excluded from the mirror, so each is its name's sole registration.
356
+ source += emitImportRegistrations(liquidOptions);
357
+
358
+ source += `
359
+ initComponentProxy();
360
+ `;
361
+ }
362
+ return source;
363
+ }
364
+
365
+ /**
366
+ * Builds the static `eleventy` global, mirroring the browser-applicable parts
367
+ * of https://www.11ty.dev/docs/data-eleventy-supplied/. `env.config`/`env.root`
368
+ * (absolute paths) and `serverless` are omitted; `env.runMode`/`env.source` are
369
+ * hardcoded to `"serve"`/`"cli"` so branching templates take a sane path.
370
+ *
371
+ * @param {EleventyDirectories} directories
372
+ */
373
+ function buildEleventyData(directories) {
374
+ const version = readEleventyVersion();
375
+
376
+ return {
377
+ version,
378
+ generator: `Eleventy v${version}`,
379
+ env: {
380
+ runMode: "serve",
381
+ source: "cli",
382
+ },
383
+ directories: {
384
+ input: directories.input,
385
+ includes: directories.includes,
386
+ data: directories.data,
387
+ output: directories.output,
388
+ },
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Reads the project `package.json` for the `pkg` global. Returns `null` on
394
+ * missing/malformed input so the bundle still builds.
395
+ */
396
+ function buildPkg() {
397
+ try {
398
+ const contents = fs.readFileSync(
399
+ path.join(process.cwd(), "package.json"),
400
+ "utf8",
401
+ );
402
+ return JSON.parse(contents);
403
+ } catch {
404
+ return null;
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Reads the installed Eleventy version, or `"unknown"` if unresolvable.
410
+ * `@11ty/eleventy` doesn't export `./package.json`, so we resolve its main
411
+ * entry and walk up to the package root instead.
412
+ */
413
+ function readEleventyVersion() {
414
+ try {
415
+ const require = createRequire(import.meta.url);
416
+ const entryPath = require.resolve("@11ty/eleventy");
417
+ let dir = path.dirname(entryPath);
418
+ while (true) {
419
+ const pkgPath = path.join(dir, "package.json");
420
+ if (fs.existsSync(pkgPath)) {
421
+ /** @type {{name?: string, version?: string}} */
422
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
423
+ if (pkg.name === "@11ty/eleventy" && pkg.version) return pkg.version;
424
+ }
425
+ const parent = path.dirname(dir);
426
+ if (parent === dir) break;
427
+ dir = parent;
428
+ }
429
+ return "unknown";
430
+ } catch {
431
+ return "unknown";
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Compacts 11ty's `results` into the page map, keyed by normalized input path
437
+ * (matching `normalizeInputPath` in `liquid/page-map.mjs`). Pagination yields
438
+ * duplicate `inputPath`s — we keep the first, since cursors aren't modelled in
439
+ * the editor. Returns `{}` if `results` is absent or malformed.
440
+ *
441
+ * @param {Array<{inputPath?: string, outputPath?: string, url?: string}> | undefined} results
442
+ */
443
+ function buildPageMap(results) {
444
+ if (!Array.isArray(results)) return {};
445
+
446
+ /** @type {Record<string, { url?: string, outputPath?: string }>} */
447
+ const map = {};
448
+
449
+ for (const entry of results) {
450
+ if (!entry || typeof entry.inputPath !== "string") continue;
451
+
452
+ const key = entry.inputPath.replace(/^\.\//, "").replace(/^\/+/, "");
453
+ if (!key || key in map) continue;
454
+
455
+ map[key] = {
456
+ url: typeof entry.url === "string" ? entry.url : undefined,
457
+ outputPath:
458
+ typeof entry.outputPath === "string" ? entry.outputPath : undefined,
459
+ };
460
+ }
461
+
462
+ return map;
463
+ }
464
+
465
+ /**
466
+ * @param {string[]} componentDirs
467
+ * @param {string[]} extensions
468
+ * @param {string[]} ignoreDirectories
469
+ */
470
+ async function findAllLiquidFiles(
471
+ componentDirs,
472
+ extensions,
473
+ ignoreDirectories,
474
+ ) {
475
+ const allFiles = [];
476
+
477
+ for (const dir of componentDirs) {
478
+ const files = await findFilesInDirectory({
479
+ directory: dir,
480
+ extensions,
481
+ ignoreDirectories,
482
+ });
483
+
484
+ allFiles.push(...files);
485
+ }
486
+
487
+ return allFiles;
488
+ }
489
+
490
+ /**
491
+ * @param {Object} options
492
+ * @param {string} options.directory
493
+ * @param {string[]} [options.extensions]
494
+ * @param {string[]} [options.ignoreDirectories]
495
+ * @returns {Promise<string[]>}
496
+ */
497
+ async function findFilesInDirectory({
498
+ directory,
499
+ extensions = [".html", ".liquid"],
500
+ ignoreDirectories = [],
501
+ }) {
502
+ const files = [];
503
+
504
+ try {
505
+ const entries = await fs.promises.readdir(directory, {
506
+ withFileTypes: true,
507
+ });
508
+
509
+ for (const entry of entries) {
510
+ const fullPath = path.join(directory, entry.name);
511
+
512
+ if (entry.isDirectory()) {
513
+ if (ignoreDirectories.includes(entry.name.toLowerCase())) {
514
+ continue;
515
+ }
516
+
517
+ const subFiles = await findFilesInDirectory({
518
+ directory: fullPath,
519
+ extensions,
520
+ ignoreDirectories,
521
+ });
522
+ files.push(...subFiles);
523
+ } else if (entry.isFile()) {
524
+ // Handles compound extensions too (e.g. `.bookshop.liquid`).
525
+ const filenameLower = entry.name.toLowerCase();
526
+ const hasValidExtension = extensions.some((ext) =>
527
+ filenameLower.endsWith(ext),
528
+ );
529
+ if (hasValidExtension) {
530
+ files.push(fullPath);
531
+ }
532
+ }
533
+ }
534
+ } catch (error) {
535
+ console.error("ERROR reading directory:", directory, error);
536
+ throw error;
537
+ }
538
+
539
+ return files;
540
+ }
541
+
542
+ /**
543
+ * Maps each `{ name: modulePath }` `pluginOptions.liquid` field to its runtime
544
+ * registration function. `components` pins a module ahead of the
545
+ * filesystem-resolution proxy via the same import-and-register shape.
546
+ */
547
+ const IMPORT_REGISTER_FNS = {
548
+ filters: "registerFilter",
549
+ shortcodes: "registerShortcode",
550
+ pairedShortcodes: "registerPairedShortcode",
551
+ tags: "registerCustomTag",
552
+ components: "registerLiquidComponent",
553
+ };
554
+
555
+ /**
556
+ * Emits an `import` + register-call pair for every `{ name: modulePath }` entry
557
+ * across the `IMPORT_REGISTER_FNS` maps, e.g.:
558
+ *
559
+ * import filters_0 from "./path/to/file";
560
+ * registerFilter("name", filters_0);
561
+ *
562
+ * @param {LiquidOptions | undefined} liquidOptions
563
+ * @returns {string} JS source
564
+ */
565
+ function emitImportRegistrations(liquidOptions) {
566
+ let out = "";
567
+
568
+ for (const optionKey of /** @type {Array<keyof typeof IMPORT_REGISTER_FNS>} */ (
569
+ Object.keys(IMPORT_REGISTER_FNS)
570
+ )) {
571
+ const registerFn = IMPORT_REGISTER_FNS[optionKey];
572
+
573
+ for (const [i, [name, file]] of Object.entries(
574
+ liquidOptions?.[optionKey] ?? {},
575
+ ).entries()) {
576
+ const id = `${optionKey}_${i}`;
577
+ out += `\nimport ${id} from "./${file}";\n${registerFn}(${JSON.stringify(name)}, ${id});\n`;
578
+ }
579
+ }
580
+
581
+ return out;
582
+ }