@cloudcannon/editable-regions 0.0.17 → 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.
@@ -1,20 +1,30 @@
1
- import { evalToken, Liquid, Tokenizer, toPromise } from "liquidjs";
2
- import { eleventyFilters } from "./11ty-filters.mjs";
1
+ import { Liquid } from "liquidjs";
2
+ import { enhanceLiquidError } from "./errors.mjs";
3
3
  import { inMemoryFs } from "./fs.mjs";
4
+ import {
5
+ buildCollectionsData,
6
+ buildPageData,
7
+ setEleventyData,
8
+ } from "./globals.mjs";
9
+ import { createIncludeWithTag } from "./include-with-tag.mjs";
4
10
  import { group, groupEnd, log } from "./logger.mjs";
5
11
  import { createPairedShortcodeTag, createShortcodeTag } from "./shortcodes.mjs";
6
12
 
7
- // Re-export logger utilities for external use
13
+ // Re-exported from their own modules (which avoid this file's browser-runtime
14
+ // side-effects) so the Node-side plugin can reach them via the package root.
15
+ export { createIncludeWithTag } from "./include-with-tag.mjs";
8
16
  export { group, groupEnd, log, setVerbose } from "./logger.mjs";
17
+ export { registerPageMap } from "./page-map.mjs";
9
18
 
10
19
  /** @type {import("liquidjs").Liquid | null} */
11
20
  let sharedLiquidEngine = null;
12
21
 
13
22
  /**
14
- * Creates and configures the shared Liquid engine instance.
23
+ * Creates the shared Liquid engine with the built-in `includeWith` tag. The
24
+ * host wires up its filters/shortcodes/ports afterwards (e.g.
25
+ * `registerEleventyBuiltins(engine)`).
15
26
  *
16
- * @param {{componentDirs?: string[]}} options - Liquid engine options
17
- * @returns {void}
27
+ * @param {import("liquidjs").LiquidOptions} [options] - Spread into `new Liquid(...)`
18
28
  */
19
29
  export function createSharedLiquidEngine(options) {
20
30
  log("Creating shared Liquid engine");
@@ -26,141 +36,175 @@ export function createSharedLiquidEngine(options) {
26
36
  },
27
37
  ...options,
28
38
  });
29
- log("Liquid engine instantiated");
30
-
31
- // Register Eleventy's built-in filters
32
- for (const [name, fn] of Object.entries(eleventyFilters)) {
33
- sharedLiquidEngine.registerFilter(name, fn);
34
- }
35
- log(
36
- "Registered",
37
- Object.keys(eleventyFilters).length,
38
- "built-in 11ty filters",
39
- );
40
39
 
41
40
  log(
42
- "Available files in window.cc_files:",
43
- Object.keys(window.cc_files || {}),
41
+ "Available files in window.cc_liquid_files:",
42
+ Object.keys(window.cc_liquid_files || {}),
44
43
  );
45
44
 
46
45
  sharedLiquidEngine.registerTag(
47
- "bind_include",
48
- createBindIncludeTag(sharedLiquidEngine),
46
+ "includeWith",
47
+ createIncludeWithTag(sharedLiquidEngine),
49
48
  );
50
- log("bind_include tag registered");
49
+
50
+ return sharedLiquidEngine;
51
51
  }
52
52
 
53
53
  /**
54
- * Registers a Liquid component with the CloudCannon component system.
55
- * Creates a wrapper that renders the Liquid template to an HTMLElement.
54
+ * Pins a Liquid component under `key`, taking precedence over the
55
+ * include-resolution proxy. Used for `pluginOptions.liquid.components`.
56
56
  *
57
- * @param {string} key - Unique identifier for the component
58
- * @param {string} contents - The Liquid template contents
59
- * @returns {void}
57
+ * @param {string} key
58
+ * @param {string} contents
60
59
  */
61
60
  export function registerLiquidComponent(key, contents) {
62
61
  log("Registering component:", key);
63
- log("Component contents preview:", contents?.substring?.(0, 200) || contents);
64
62
 
65
63
  if (!sharedLiquidEngine) {
66
64
  throw new Error(
67
65
  `sharedLiquidEngine not defined when registering component ${key}`,
68
66
  );
69
67
  }
70
- const liquidEngine = sharedLiquidEngine;
71
-
72
- /**
73
- * Wrapper function that renders the Liquid component to an HTMLElement.
74
- *
75
- * @param {Object} props - Props to pass to the Liquid template
76
- * @returns {Promise<HTMLElement>} The rendered component as an HTMLElement
77
- */
78
- const wrappedComponent = async (props) => {
79
- group(`Rendering component: ${key}`);
80
- log("Props:", props);
81
- log("Parsing and rendering template...");
82
- const htmlString = await liquidEngine.parseAndRender(contents, props);
83
- log(
84
- "Rendered HTML preview:",
85
- htmlString?.substring?.(0, 200) || htmlString,
86
- );
87
- const rootEl = document.createElement("div");
88
- rootEl.innerHTML = htmlString;
89
- groupEnd();
90
- return rootEl;
91
- };
92
68
 
93
69
  window.cc_components = window.cc_components || {};
94
- window.cc_components[key] = wrappedComponent;
95
- log(`Component registered, ${key}`);
70
+ window.cc_components[key] = createComponentRenderer(key, contents);
96
71
  }
97
72
 
98
73
  /**
99
- * Registers a custom Liquid filter.
74
+ * Wraps `window.cc_components` in a Proxy that resolves any component name on
75
+ * demand via `{% include %}` — the primary resolution path. Names registered
76
+ * via `registerLiquidComponent` take precedence. Call after
77
+ * `createSharedLiquidEngine()`.
100
78
  *
101
- * @param {string} name - The filter name
102
- * @param {any} fn - The filter function
103
79
  * @returns {void}
104
80
  */
105
- export function registerCustomFilter(name, fn) {
81
+ export function initComponentProxy() {
82
+ if (!sharedLiquidEngine) {
83
+ throw new Error(
84
+ "sharedLiquidEngine not defined when initializing component proxy",
85
+ );
86
+ }
87
+
88
+ const target = window.cc_components || {};
89
+
90
+ window.cc_components = new Proxy(target, {
91
+ get(registered, key, receiver) {
92
+ if (Reflect.has(registered, key)) {
93
+ return Reflect.get(registered, key, receiver);
94
+ }
95
+ if (typeof key === "string") {
96
+ return createComponentRenderer(key, `{% include "${key}" %}`);
97
+ }
98
+ return undefined;
99
+ },
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Sets the `eleventy` global on the shared engine. Built at build time by the
105
+ * bundle generator; this is a thin setter.
106
+ *
107
+ * @param {{version: string, generator: string, env: {runMode: string, source: string}, directories: Record<string, string>}} data
108
+ */
109
+ export function registerEleventyData(data) {
110
+ if (!sharedLiquidEngine) {
111
+ throw new Error(
112
+ "sharedLiquidEngine not defined when registering eleventy data",
113
+ );
114
+ }
115
+ /** @type {any} */ (sharedLiquidEngine).options.globals.eleventy = data;
116
+ // Also surface to `globals` so the page proxy can derive `outputPath`.
117
+ setEleventyData(data);
118
+ log("Registered eleventy data, version:", data?.version);
119
+ }
120
+
121
+ /**
122
+ * Merges user-supplied globals (`pluginOptions.globals`) onto the engine. The
123
+ * built-in globals (`page`, `collections`, `eleventy`, `pkg`) are applied
124
+ * separately and take precedence per render.
125
+ *
126
+ * @param {Record<string, unknown>} globals
127
+ */
128
+ export function registerGlobals(globals) {
129
+ if (!sharedLiquidEngine) {
130
+ throw new Error("sharedLiquidEngine not defined when registering globals");
131
+ }
132
+ Object.assign(
133
+ /** @type {any} */ (sharedLiquidEngine).options.globals,
134
+ globals ?? {},
135
+ );
136
+ log("Registered", Object.keys(globals ?? {}).length, "custom globals");
137
+ }
138
+
139
+ /**
140
+ * Sets the `pkg` global (11ty exposes `package.json` this way by default).
141
+ *
142
+ * @param {Record<string, any>} pkg
143
+ */
144
+ export function registerPkg(pkg) {
145
+ if (!sharedLiquidEngine) {
146
+ throw new Error("sharedLiquidEngine not defined when registering pkg");
147
+ }
148
+ /** @type {any} */ (sharedLiquidEngine).options.globals.pkg = pkg ?? {};
149
+ log("Registered pkg, fields:", Object.keys(pkg ?? {}).length);
150
+ }
151
+
152
+ /**
153
+ * Registers a Liquid filter. Called by both the auto-mirror pass and
154
+ * user-supplied overrides (`pluginOptions.liquid.filters`).
155
+ *
156
+ * @param {string} name
157
+ * @param {any} fn
158
+ */
159
+ export function registerFilter(name, fn) {
106
160
  log("Registering filter:", name);
107
161
  if (!sharedLiquidEngine) {
108
162
  throw new Error(
109
- `sharedLiquidEngine not defined when registering custom filter ${name}`,
163
+ `sharedLiquidEngine not defined when registering filter ${name}`,
110
164
  );
111
165
  }
112
166
  sharedLiquidEngine.registerFilter(name, fn);
113
167
  }
114
168
 
115
169
  /**
116
- * Registers a custom shortcode.
117
- *
118
- * Usage in templates: {% shortcodeName arg1, arg2 %}
170
+ * Registers a Liquid shortcode. Usage: {% shortcodeName arg1, arg2 %}
119
171
  *
120
- * @param {string} name - The shortcode name (used as the tag name)
121
- * @param {any} fn - The shortcode function (arg1, arg2, ...) => string
122
- * @returns {void}
172
+ * @param {string} name
173
+ * @param {any} fn - (arg1, arg2, ...) => string
123
174
  */
124
- export function registerCustomShortcode(name, fn) {
175
+ export function registerShortcode(name, fn) {
125
176
  log("Registering shortcode:", name);
126
177
  if (!sharedLiquidEngine) {
127
178
  throw new Error(
128
- `sharedLiquidEngine not defined when registering custom shortcode ${name}`,
179
+ `sharedLiquidEngine not defined when registering shortcode ${name}`,
129
180
  );
130
181
  }
131
- sharedLiquidEngine.registerTag(name, createShortcodeTag(fn, name));
182
+ sharedLiquidEngine.registerTag(name, createShortcodeTag(name, fn));
132
183
  }
133
184
 
134
185
  /**
135
- * Registers a custom paired shortcode (with content between tags).
136
- *
137
- * Usage in templates: {% shortcodeName arg %}content{% endshortcodeName %}
186
+ * Registers a Liquid paired shortcode.
187
+ * Usage: {% shortcodeName arg %}content{% endshortcodeName %}
138
188
  *
139
- * @param {string} name - The shortcode name (used as the tag name)
140
- * @param {any} fn - The shortcode function (content, arg1, ...) => string
141
- * @returns {void}
189
+ * @param {string} name
190
+ * @param {any} fn - (content, arg1, ...) => string
142
191
  */
143
- export function registerCustomPairedShortcode(name, fn) {
192
+ export function registerPairedShortcode(name, fn) {
144
193
  log("Registering paired shortcode:", name);
145
194
  if (!sharedLiquidEngine) {
146
195
  throw new Error(
147
- `sharedLiquidEngine not defined when registering custom paired shortcode ${name}`,
196
+ `sharedLiquidEngine not defined when registering paired shortcode ${name}`,
148
197
  );
149
198
  }
150
199
  sharedLiquidEngine.registerTag(name, createPairedShortcodeTag(name, fn));
151
200
  }
152
201
 
153
202
  /**
154
- * Registers a custom tag with full LiquidJS parser access.
203
+ * Registers a custom tag with full LiquidJS parser access (the factory
204
+ * receives the engine). Usage: {% tagName args %}
155
205
  *
156
- * Custom tags are more powerful than shortcodes - they receive full access to
157
- * the LiquidJS parser and can implement complex parsing/rendering logic.
158
- *
159
- * Usage in templates: {% tagName args %}
160
- *
161
- * @param {string} name - The tag name
162
- * @param {any} factory - Factory function (liquidEngine) => { parse(), render() }
163
- * @returns {void}
206
+ * @param {string} name
207
+ * @param {any} factory - (liquidEngine) => { parse(), render() }
164
208
  */
165
209
  export function registerCustomTag(name, factory) {
166
210
  log("Registering custom tag:", name);
@@ -173,97 +217,46 @@ export function registerCustomTag(name, factory) {
173
217
  }
174
218
 
175
219
  /**
176
- * Creates a bind_include tag for spreading object props into includes.
177
- * Like Astro's {...props} spread for Liquid includes.
220
+ * Wraps `parseAndRender` with logging, error mapping, and HTMLElement output.
178
221
  *
179
- * Usage: {% bind_include "path/to/partial", objectToSpread %}
180
- *
181
- * @param {any} _liquidEngine - The LiquidJS engine instance (provided by LiquidJS, accessed via this.liquid)
182
- * @returns {any} Tag implementation with parse and render methods
222
+ * @param {string} name
223
+ * @param {string} templateSource - A literal template, or `{% include "name" %}`
224
+ * @returns {(props: Record<string, any>) => Promise<HTMLElement>}
183
225
  */
184
- export function createBindIncludeTag(_liquidEngine) {
185
- return {
186
- /**
187
- * Parses the bind_include tag arguments.
188
- * @param {any} tagToken - The tag token from LiquidJS parser
189
- */
190
- parse(tagToken) {
191
- log("bind_include parsing tag with args:", tagToken.args);
192
- const tokenizer = new Tokenizer(
193
- tagToken.args,
194
- this.liquid.options.operatorsTrie,
226
+ function createComponentRenderer(name, templateSource) {
227
+ return async (props) => {
228
+ if (!sharedLiquidEngine) {
229
+ throw new Error(
230
+ `sharedLiquidEngine not defined when rendering component ${name}`,
195
231
  );
232
+ }
233
+ group(`Rendering component: ${name}`);
234
+ log("Props:", props);
196
235
 
197
- this.pathToken = tokenizer.readValue();
198
- if (!this.pathToken)
199
- throw new Error("bind_include: missing path argument");
200
- log("bind_include parsed path token:", this.pathToken);
201
-
202
- tokenizer.skipBlank();
203
- if (tokenizer.peek() !== ",")
204
- throw new Error("bind_include: expected comma separator");
205
- tokenizer.advance();
206
- tokenizer.skipBlank();
207
-
208
- this.objectToken = tokenizer.readValue();
209
- if (!this.objectToken)
210
- throw new Error("bind_include: missing object argument");
211
- log("bind_include parsed object token:", this.objectToken);
212
- },
213
-
214
- /**
215
- * Renders the included template with spread props.
216
- * @param {any} context - The LiquidJS render context
217
- */
218
- async render(context) {
219
- group("bind_include rendering");
220
- log("Evaluating path token...");
221
- const path = await toPromise(evalToken(this.pathToken, context));
222
- log("Path resolved to:", path);
223
-
224
- log("Evaluating object token...");
225
- const obj = await toPromise(evalToken(this.objectToken, context));
226
- log("Object resolved to:", obj);
227
-
228
- if (!path || typeof path !== "string") {
229
- groupEnd();
230
- throw new Error(`bind_include: invalid path "${path}"`);
231
- }
232
- if (!obj || typeof obj !== "object") {
233
- log("Object is not valid, returning empty");
234
- groupEnd();
235
- return;
236
- }
237
-
238
- log(
239
- "Including:",
240
- path,
241
- "with",
242
- Object.keys(obj).length,
243
- "props:",
244
- Object.keys(obj),
236
+ let htmlString;
237
+ try {
238
+ htmlString = await sharedLiquidEngine.parseAndRender(
239
+ templateSource,
240
+ // `page`/`collections` spread last so props can't shadow them
241
+ // (mirroring 11ty); they resolve at the top-level globals level.
242
+ {
243
+ ...props,
244
+ page: buildPageData(),
245
+ collections: buildCollectionsData(),
246
+ },
245
247
  );
246
-
247
- context.push(obj);
248
- try {
249
- log("Parsing file:", path);
250
- const templates = await this.liquid.parseFile(path);
251
- log("File parsed, template count:", templates?.length || 0);
252
-
253
- log("Rendering templates...");
254
- const result = await this.liquid.render(templates, context);
255
- log("Rendered result preview:", result?.substring?.(0, 200) || result);
256
- groupEnd();
257
- return result;
258
- } catch (err) {
259
- const error = /** @type {Error} */ (err);
260
- log("Error during render:", error.message);
261
- log("Full error:", error);
262
- groupEnd();
263
- throw error;
264
- } finally {
265
- context.pop();
266
- }
267
- },
248
+ } catch (err) {
249
+ log("Error during render:", err);
250
+ groupEnd();
251
+ throw enhanceLiquidError(err, name);
252
+ }
253
+ log(
254
+ "Rendered HTML preview:",
255
+ htmlString?.substring?.(0, 200) || htmlString,
256
+ );
257
+ const rootEl = document.createElement("div");
258
+ rootEl.innerHTML = htmlString;
259
+ groupEnd();
260
+ return rootEl;
268
261
  };
269
262
  }
@@ -1,16 +1,8 @@
1
- /**
2
- * Simple logger for live editing integration.
3
- * Enable verbose mode to see detailed logs in browser console.
4
- */
1
+ // Logging is gated on verbose mode, except `warn`/`warnOnce`.
5
2
 
6
3
  let verboseEnabled = false;
7
4
 
8
- /**
9
- * Enables or disables verbose logging.
10
- *
11
- * @param {boolean} enabled - Whether to enable verbose logging
12
- * @returns {void}
13
- */
5
+ /** @param {boolean} enabled */
14
6
  export function setVerbose(enabled) {
15
7
  verboseEnabled = enabled;
16
8
  if (enabled) {
@@ -18,91 +10,36 @@ export function setVerbose(enabled) {
18
10
  }
19
11
  }
20
12
 
21
- /**
22
- * Returns whether verbose logging is enabled.
23
- *
24
- * @returns {boolean}
25
- */
26
- export function isVerbose() {
27
- return verboseEnabled;
28
- }
29
-
30
- /**
31
- * Log only when verbose mode is enabled.
32
- * Use for diagnostic information during development.
33
- *
34
- * @param {...any} args - Arguments to log
35
- * @returns {void}
36
- */
37
- export function log(...args) {
13
+ export function log(/** @type {any[]} */ ...args) {
38
14
  if (verboseEnabled) {
39
15
  console.log(...args);
40
16
  }
41
17
  }
42
18
 
43
- /**
44
- * Always log warnings.
45
- *
46
- * @param {...any} args - Arguments to log
47
- * @returns {void}
48
- */
49
- export function warn(...args) {
19
+ export function warn(/** @type {any[]} */ ...args) {
50
20
  console.warn(...args);
51
21
  }
52
22
 
53
- /**
54
- * Always log errors.
55
- *
56
- * @param {...any} args - Arguments to log
57
- * @returns {void}
58
- */
59
- export function error(...args) {
60
- console.error(...args);
23
+ const warnedKeys = new Set();
24
+
25
+ /** Warns once per key for the lifetime of the page. */
26
+ export function warnOnce(
27
+ /** @type {string} */ key,
28
+ /** @type {any[]} */ ...args
29
+ ) {
30
+ if (warnedKeys.has(key)) return;
31
+ warnedKeys.add(key);
32
+ warn(...args);
61
33
  }
62
34
 
63
- /**
64
- * Group logs (only in verbose mode).
65
- *
66
- * @param {string} label - Group label
67
- * @returns {void}
68
- */
69
- export function group(label) {
35
+ export function group(/** @type {string} */ label) {
70
36
  if (verboseEnabled) {
71
37
  console.group(label);
72
38
  }
73
39
  }
74
40
 
75
- /**
76
- * End a console group (only in verbose mode).
77
- *
78
- * @returns {void}
79
- */
80
41
  export function groupEnd() {
81
42
  if (verboseEnabled) {
82
43
  console.groupEnd();
83
44
  }
84
45
  }
85
-
86
- /**
87
- * Start timing an operation (only in verbose mode).
88
- *
89
- * @param {string} label - Timer label
90
- * @returns {void}
91
- */
92
- export function time(label) {
93
- if (verboseEnabled) {
94
- console.time(label);
95
- }
96
- }
97
-
98
- /**
99
- * End timing an operation (only in verbose mode).
100
- *
101
- * @param {string} label - Timer label
102
- * @returns {void}
103
- */
104
- export function timeEnd(label) {
105
- if (verboseEnabled) {
106
- console.timeEnd(label);
107
- }
108
- }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Build-time snapshot of every page Eleventy produced, keyed by normalized
3
+ * input path. Holds permalinks computed by JS config or `eleventyComputed`,
4
+ * which the live front-matter read can't see. Lives in its own module so
5
+ * consumers can import it without the side-effects of `helpers/cloudcannon.mjs`.
6
+ *
7
+ * @typedef {{ url?: string, outputPath?: string }} PageMapEntry
8
+ */
9
+
10
+ /** @type {Record<string, PageMapEntry>} */
11
+ let pageMap = {};
12
+
13
+ /** @param {Record<string, PageMapEntry> | null | undefined} map */
14
+ export function registerPageMap(map) {
15
+ pageMap = map ?? {};
16
+ }
17
+
18
+ export function getPageMap() {
19
+ return pageMap;
20
+ }
21
+
22
+ /**
23
+ * Normalises an Eleventy input path (`./src/foo.md`, `/src/foo.md`) to the
24
+ * no-leading-slash form used as the map's keys, so paths from different
25
+ * sources (11ty `results`, CC `currentFile().path`) compare equal.
26
+ *
27
+ * @param {string | null | undefined} p
28
+ */
29
+ export function normalizeInputPath(p) {
30
+ if (typeof p !== "string" || !p) return "";
31
+ return p.replace(/^\.\//, "").replace(/^\/+/, "");
32
+ }