@cloudcannon/editable-regions 0.0.17 → 0.0.19

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.
Files changed (42) hide show
  1. package/helpers/checks.ts +0 -22
  2. package/helpers/cloudcannon.mjs +7 -22
  3. package/helpers/hydrate-editable-regions.ts +2 -1
  4. package/integrations/astro/astro-integration.mjs +1 -4
  5. package/integrations/astro/index.mjs +15 -41
  6. package/integrations/astro/modules/assets.js +1 -4
  7. package/integrations/astro/modules/content.js +2 -11
  8. package/integrations/astro/react-renderer.mjs +98 -24
  9. package/integrations/astro/svelte-renderer.mjs +72 -15
  10. package/integrations/astro/vue-renderer.mjs +61 -0
  11. package/integrations/eleventy/browser/collect-config.mjs +249 -0
  12. package/integrations/eleventy/browser/index.mjs +9 -0
  13. package/integrations/eleventy/browser/inert.mjs +35 -0
  14. package/integrations/eleventy/browser/liquid-builtins.mjs +306 -0
  15. package/integrations/eleventy/browser/liquid-render.mjs +216 -0
  16. package/integrations/eleventy/browser/process-shim.mjs +32 -0
  17. package/integrations/eleventy/browser/stub-mode.mjs +61 -0
  18. package/integrations/eleventy/index.cjs +28 -0
  19. package/integrations/eleventy/index.mjs +634 -0
  20. package/integrations/liquid/README.md +677 -0
  21. package/integrations/liquid/errors.mjs +41 -0
  22. package/integrations/liquid/fs.mjs +36 -75
  23. package/integrations/liquid/globals.mjs +308 -0
  24. package/integrations/liquid/include-with-tag.mjs +84 -0
  25. package/integrations/liquid/index.mjs +166 -170
  26. package/integrations/liquid/logger.mjs +15 -78
  27. package/integrations/liquid/page-map.mjs +32 -0
  28. package/integrations/liquid/shortcodes.mjs +62 -99
  29. package/integrations/react.mjs +5 -9
  30. package/integrations/vue.mjs +28 -0
  31. package/nodes/editable-array-item.ts +6 -1
  32. package/nodes/editable-component.ts +2 -3
  33. package/nodes/editable-text.ts +6 -0
  34. package/nodes/editable.ts +6 -1
  35. package/package.json +120 -79
  36. package/types/astro.d.ts +4 -0
  37. package/types/eleventy.d.cts +20 -0
  38. package/types/eleventy.d.ts +88 -0
  39. package/types/liquid.d.ts +14 -12
  40. package/types/vue.d.ts +40 -0
  41. package/integrations/eleventy.mjs +0 -294
  42. package/integrations/liquid/11ty-filters.mjs +0 -69
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Rewrites a LiquidJS error into an actionable message for the error card.
3
+ *
4
+ * @param {unknown} err
5
+ * @param {string} componentName
6
+ * @returns {Error}
7
+ */
8
+ export function enhanceLiquidError(err, componentName) {
9
+ const message = err instanceof Error ? err.message : String(err);
10
+
11
+ // LiquidJS appends its own position suffix ("undefined filter: foo, line:2,
12
+ // col:1"), so stop at the comma rather than at whitespace.
13
+ const unknownFilter = message.match(/undefined filter[:.]?\s*([^\s,]+)/i);
14
+ if (unknownFilter) {
15
+ const filterName = unknownFilter[1];
16
+ return new Error(
17
+ `Unknown filter "${filterName}" while rendering "${componentName}". ` +
18
+ `Please check your config and make sure you have registered "${filterName}" in the filters option.`,
19
+ );
20
+ }
21
+
22
+ const missingTemplate = message.match(/ENOENT.*?"([^"]+)"/);
23
+ if (missingTemplate) {
24
+ const filePath = missingTemplate[1];
25
+ return new Error(
26
+ `Failed to find included template "${filePath}" while rendering "${componentName}". ` +
27
+ `Please check that the file exists and is within your configured component directories.`,
28
+ );
29
+ }
30
+
31
+ const missingTag = message.match(/tag "?(\S+?)"? not found/i);
32
+ if (missingTag) {
33
+ const tagName = missingTag[1];
34
+ return new Error(
35
+ `Unknown tag "${tagName}" while rendering "${componentName}". ` +
36
+ `Please check your config and make sure you have registered "${tagName}" in the tags, shortcodes, or pairedShortcodes option.`,
37
+ );
38
+ }
39
+
40
+ return new Error(`Error rendering "${componentName}": ${message}`);
41
+ }
@@ -1,127 +1,88 @@
1
1
  import { log, warn } from "./logger.mjs";
2
2
 
3
3
  /**
4
- * In-memory filesystem for LiquidJS that reads from window.cc_files.
5
- *
6
- * @type {any} LiquidJS-compatible filesystem object
4
+ * Matches `path.extname`: last dot of the final segment, leading dot excluded.
5
+ */
6
+ function hasExtension(/** @type {string} */ filePath) {
7
+ const basename = filePath.slice(filePath.lastIndexOf("/") + 1);
8
+ return basename.lastIndexOf(".") > 0;
9
+ }
10
+
11
+ /**
12
+ * In-memory filesystem for LiquidJS, reading from `window.cc_liquid_files`.
13
+ * @type {any}
7
14
  */
8
15
  export const inMemoryFs = {
9
16
  sep: "/",
10
17
 
11
- /**
12
- * Gets the directory name from a file path.
13
- *
14
- * @param {string} filePath - The file path
15
- * @returns {string} The directory portion of the path
16
- */
17
- dirname(filePath) {
18
+ dirname(/** @type {string} */ filePath) {
18
19
  const parts = filePath.split("/");
19
20
  parts.pop();
20
21
  return parts.join("/") || "/";
21
22
  },
22
23
 
23
- /**
24
- * Synchronously reads a file from the in-memory store.
25
- *
26
- * @param {string} filePath - The file path to read
27
- * @returns {string | undefined} The file contents or undefined if not found
28
- */
29
- readFileSync(filePath) {
30
- log("readFileSync:", filePath);
31
- const fileContents = window.cc_files?.[filePath];
24
+ readFileSync(/** @type {string} */ filePath) {
25
+ const fileContents = window.cc_liquid_files?.[filePath];
32
26
 
33
- if (fileContents === undefined) {
34
- const availableFiles = Object.keys(window.cc_files || {});
27
+ if (fileContents === undefined || fileContents === null) {
28
+ const availableFiles = Object.keys(window.cc_liquid_files || {});
35
29
  warn("File not found:", filePath);
36
30
  log("Available files:", availableFiles);
37
- } else {
38
- log("File found, length:", fileContents?.length || 0);
31
+ throw new Error(
32
+ `ENOENT: Failed to find "${filePath}" in the bundled template files. Please check that this file exists and is within your configured component directories.`,
33
+ );
39
34
  }
40
35
 
41
36
  return fileContents;
42
37
  },
43
38
 
44
- /**
45
- * Asynchronously reads a file from the in-memory store.
46
- *
47
- * @param {string} filePath - The file path to read
48
- * @returns {Promise<string>} The file contents
49
- * @throws {Error} If filePath is empty
50
- */
51
- async readFile(filePath) {
52
- log("readFile:", filePath);
39
+ async readFile(/** @type {string} */ filePath) {
53
40
  if (!filePath) {
54
41
  throw new Error("readFile called with empty path");
55
42
  }
56
43
  return this.readFileSync(filePath);
57
44
  },
58
45
 
59
- /**
60
- * Asynchronously checks if a file exists.
61
- *
62
- * @param {string} filePath - The file path to check
63
- * @returns {Promise<boolean>} True if the file exists
64
- */
65
- async exists(filePath) {
46
+ existsSync(/** @type {string} */ filePath) {
66
47
  if (!filePath || typeof filePath !== "string") {
67
- log("exists: invalid path", filePath);
68
48
  return false;
69
49
  }
70
- const result = this.existsSync(filePath);
71
- log("exists:", filePath, "=", result);
72
- return result;
50
+ const fileContents = window.cc_liquid_files?.[filePath];
51
+ return fileContents !== null && fileContents !== undefined;
73
52
  },
74
53
 
75
- /**
76
- * Synchronously checks if a file exists.
77
- *
78
- * @param {string} filePath - The file path to check
79
- * @returns {boolean} True if the file exists
80
- */
81
- existsSync(filePath) {
54
+ async exists(/** @type {string} */ filePath) {
82
55
  if (!filePath || typeof filePath !== "string") {
83
56
  return false;
84
57
  }
85
- const fileContents = window.cc_files?.[filePath];
86
- const exists = fileContents !== null && fileContents !== undefined;
87
- log("existsSync:", filePath, "=", exists);
88
- return exists;
58
+ return this.existsSync(filePath);
89
59
  },
90
60
 
91
61
  /**
92
- * Resolves a file path by joining the root with the file name and extension.
93
- * LiquidJS calls this once per root directory and checks exists() on the
62
+ * LiquidJS calls this once per root directory and checks `exists()` on the
94
63
  * result, so no directory searching is needed here.
95
- *
96
- * @param {string} root - The root directory provided by LiquidJS
97
- * @param {string} file - The file name to resolve
98
- * @param {string} [ext] - The file extension (defaults to ".liquid")
99
- * @returns {string} The resolved file path
100
64
  */
101
- resolve(root, file, ext) {
65
+ resolve(
66
+ /** @type {string} */ root,
67
+ /** @type {string} */ file,
68
+ /** @type {string} */ ext,
69
+ ) {
102
70
  const extension = ext || ".liquid";
103
- const fileWithExt = file.endsWith(extension) ? file : `${file}${extension}`;
71
+ // Only append when the file has none, as LiquidJS's Node fs does —
72
+ // otherwise `include "card.html"` becomes `card.html.liquid`.
73
+ const fileWithExt = hasExtension(file) ? file : `${file}${extension}`;
104
74
  const normalizedRoot = root.replace(/^\.\//, "").replace(/\/*$/, "/");
105
75
  const resolved = `${normalizedRoot}${fileWithExt}`;
106
76
  log("resolve:", { root, file, ext }, "->", resolved);
107
77
  return resolved;
108
78
  },
109
79
 
110
- /**
111
- * Returns file stat (always returns isFile: true for compatibility).
112
- *
113
- * @returns {Promise<{isFile: () => boolean}>}
114
- */
115
- async statAsync() {
80
+ // The store is flat, so anything stat'd is a file.
81
+ statSync() {
116
82
  return { isFile: () => true };
117
83
  },
118
84
 
119
- /**
120
- * Returns file stat synchronously (always returns isFile: true for compatibility).
121
- *
122
- * @returns {{isFile: () => boolean}}
123
- */
124
- statSync() {
85
+ async statAsync() {
125
86
  return { isFile: () => true };
126
87
  },
127
88
  };
@@ -0,0 +1,308 @@
1
+ // Builders for the `page` and `collections` globals on the shared Liquid
2
+ // engine. Both return Promises that LiquidJS awaits at the globals level.
3
+ // `page` resolves to a plain object; `collections` resolves to an object whose
4
+ // keys are lazy getters, so a template only pays for the collections it reads.
5
+
6
+ import { apiLoadedPromise, CloudCannon } from "../../helpers/cloudcannon.mjs";
7
+ import { getPageMap, normalizeInputPath } from "./page-map.mjs";
8
+
9
+ /** @type {{ directories?: { output?: string } } | null} */
10
+ let eleventyData = null;
11
+
12
+ /** @param {{ directories?: { output?: string } } | null} data */
13
+ export function setEleventyData(data) {
14
+ eleventyData = data;
15
+ }
16
+
17
+ /** Strips the file extension from a path. */
18
+ function stripExtension(/** @type {string} */ p) {
19
+ return p.replace(/\.[^./]+$/, "");
20
+ }
21
+
22
+ /**
23
+ * 11ty's folder-style permalink: trailing-slash URL, `index` files mapping to
24
+ * the parent dir. Last-resort fallback when neither a literal front-matter
25
+ * `permalink` nor the page map resolves a URL.
26
+ */
27
+ function deriveDefaultUrl(/** @type {string} */ inputPath) {
28
+ const stem = stripExtension(inputPath).replace(/^\.?\//, "/");
29
+ const withLeadingSlash = stem.startsWith("/") ? stem : `/${stem}`;
30
+ const withoutIndex = withLeadingSlash.replace(/\/index$/, "/");
31
+ return withoutIndex.endsWith("/") ? withoutIndex : `${withoutIndex}/`;
32
+ }
33
+
34
+ /**
35
+ * A front-matter `permalink` usable verbatim: a plain string with no Liquid
36
+ * templating. Templated permalinks (e.g. `"/{{ page.date }}/"`) need the full
37
+ * build context to render, so we return `undefined` and let the caller fall
38
+ * back to the page map's already-resolved value.
39
+ */
40
+ function literalPermalink(
41
+ /** @type {Record<string, any> | null | undefined} */ data,
42
+ ) {
43
+ const permalink = data?.permalink;
44
+ if (typeof permalink !== "string") return undefined;
45
+ if (permalink.includes("{{") || permalink.includes("{%")) return undefined;
46
+ return permalink;
47
+ }
48
+
49
+ /**
50
+ * Resolves the URL for an input file, in priority order: literal front-matter
51
+ * `permalink` (so editor edits show before a rebuild) → build-time page map →
52
+ * 11ty's folder-style default.
53
+ */
54
+ function resolveUrl(
55
+ /** @type {Record<string, any> | null | undefined} */ data,
56
+ /** @type {string} */ inputPath,
57
+ ) {
58
+ const permalink = literalPermalink(data);
59
+ if (permalink) return permalink;
60
+ const mapped = getPageMap()[normalizeInputPath(inputPath)];
61
+ if (mapped?.url) return mapped.url;
62
+ return deriveDefaultUrl(inputPath);
63
+ }
64
+
65
+ /** Same priority layering as `resolveUrl`, for the output path. */
66
+ function resolveOutputPath(
67
+ /** @type {Record<string, any> | null | undefined} */ data,
68
+ /** @type {string} */ inputPath,
69
+ ) {
70
+ const outputDir = eleventyData?.directories?.output;
71
+ const permalink = literalPermalink(data);
72
+ if (permalink) {
73
+ return outputDir ? joinOutputPath(outputDir, permalink) : undefined;
74
+ }
75
+ const mapped = getPageMap()[normalizeInputPath(inputPath)];
76
+ if (mapped?.outputPath) return mapped.outputPath;
77
+ if (!outputDir) return undefined;
78
+ return joinOutputPath(outputDir, deriveDefaultUrl(inputPath));
79
+ }
80
+
81
+ /** Basename minus extension. Matches 11ty's `fileSlug` derivation. */
82
+ function deriveFileSlug(/** @type {string} */ inputPath) {
83
+ const base = inputPath.split("/").pop() ?? "";
84
+ return stripExtension(base);
85
+ }
86
+
87
+ /** Full path minus extension, with a leading slash. */
88
+ function deriveFilePathStem(/** @type {string} */ inputPath) {
89
+ const stem = stripExtension(inputPath).replace(/^\.?\//, "/");
90
+ return stem.startsWith("/") ? stem : `/${stem}`;
91
+ }
92
+
93
+ /** Coerces a front-matter date value into a Date, or `undefined`. */
94
+ function toDate(/** @type {unknown} */ raw) {
95
+ if (!raw) return undefined;
96
+ const d = new Date(/** @type {any} */ (raw));
97
+ return Number.isNaN(d.getTime()) ? undefined : d;
98
+ }
99
+
100
+ /**
101
+ * Joins an output dir and URL the way 11ty does: trailing-slash URLs become
102
+ * `<dir><url>index.html`; others are appended as-is.
103
+ */
104
+ function joinOutputPath(
105
+ /** @type {string} */ outputDir,
106
+ /** @type {string} */ url,
107
+ ) {
108
+ const dir = outputDir.replace(/\/+$/, "");
109
+ const tail = url.endsWith("/") ? `${url}index.html` : url;
110
+ return `${dir}${tail}`;
111
+ }
112
+
113
+ /**
114
+ * Materialises a CC API file into the 11ty collection-item shape.
115
+ *
116
+ * @param {import("@cloudcannon/visual-editor-api").CloudCannonVisualEditorAPIV1File} file
117
+ */
118
+ async function materialiseFile(file) {
119
+ const data = (await file.data.get()) ?? {};
120
+ return {
121
+ url: resolveUrl(data, file.path),
122
+ outputPath: resolveOutputPath(data, file.path),
123
+ inputPath: file.path,
124
+ fileSlug: deriveFileSlug(file.path),
125
+ filePathStem: deriveFilePathStem(file.path),
126
+ date: toDate(/** @type {any} */ (data).date),
127
+ data,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Builds the `page` object for the file open in the Visual Editor. Called
133
+ * before every render so live front-matter edits are reflected immediately.
134
+ *
135
+ * @returns {Promise<Record<string, any>>}
136
+ */
137
+ export async function buildPageData() {
138
+ await apiLoadedPromise;
139
+ const file = CloudCannon?.currentFile?.();
140
+ if (!file) return {};
141
+ const inputPath = file.path;
142
+ const data = (await file.data.get()) ?? {};
143
+ return {
144
+ inputPath,
145
+ fileSlug: deriveFileSlug(inputPath),
146
+ filePathStem: deriveFilePathStem(inputPath),
147
+ outputFileExtension: "html",
148
+ url: resolveUrl(data, inputPath),
149
+ outputPath: resolveOutputPath(data, inputPath),
150
+ date: toDate(/** @type {any} */ (data).date),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Ceiling on concurrent `file.data.get()` calls. One call per file over a
156
+ * collection of thousands fails with `ERR_INSUFFICIENT_RESOURCES` — a net-stack
157
+ * error, so each resolves to a request somewhere behind the editor API.
158
+ */
159
+ const MATERIALISE_CONCURRENCY = 24;
160
+
161
+ /**
162
+ * `Promise.all(items.map(fn))` with at most `limit` calls in flight. Results
163
+ * keep their input order.
164
+ *
165
+ * @template T, R
166
+ * @param {T[]} items
167
+ * @param {(item: T) => Promise<R>} fn
168
+ * @param {number} limit
169
+ * @returns {Promise<R[]>}
170
+ */
171
+ async function mapWithConcurrency(items, fn, limit) {
172
+ /** @type {R[]} */
173
+ const results = new Array(items.length);
174
+ let cursor = 0;
175
+
176
+ const worker = async () => {
177
+ while (cursor < items.length) {
178
+ const index = cursor++;
179
+ results[index] = await fn(items[index]);
180
+ }
181
+ };
182
+
183
+ await Promise.all(
184
+ Array.from({ length: Math.min(limit, items.length) }, worker),
185
+ );
186
+ return results;
187
+ }
188
+
189
+ /** One `CloudCannon.collections()` call, keyed by name. @type {Promise<Map<string, any>> | null} */
190
+ let collectionIndexCache = null;
191
+
192
+ /** Materialised items, per collection name. @type {Map<string, Promise<any[]>>} */
193
+ const collectionItemsCache = new Map();
194
+
195
+ /** @type {Promise<Record<string, any>> | null} */
196
+ let collectionsCache = null;
197
+
198
+ /** @type {Array<{ target: any, event: "change" | "delete", handler: () => void }>} */
199
+ let collectionsSubscriptions = [];
200
+
201
+ /**
202
+ * Enumerates the site's collections — one API call, cached — and subscribes to
203
+ * `change`/`delete` on each so an edit drops the caches. Never calls
204
+ * `collection.items()`: knowing the *names* is what lets the getters be
205
+ * enumerable without fetching behind them.
206
+ *
207
+ * @returns {Promise<Map<string, any>>}
208
+ */
209
+ function loadCollectionIndex() {
210
+ if (!collectionIndexCache) {
211
+ collectionIndexCache = (async () => {
212
+ await apiLoadedPromise;
213
+ const allCollections = await CloudCannon?.collections?.();
214
+
215
+ /** @type {Map<string, any>} */
216
+ const index = new Map();
217
+ if (!allCollections?.length) return index;
218
+
219
+ for (const collection of allCollections) {
220
+ index.set(collection.collectionKey, collection);
221
+
222
+ const handler = () => resetCollectionsCache();
223
+ collection.addEventListener("change", handler);
224
+ collection.addEventListener("delete", handler);
225
+ collectionsSubscriptions.push(
226
+ { target: collection, event: "change", handler },
227
+ { target: collection, event: "delete", handler },
228
+ );
229
+ }
230
+ return index;
231
+ })();
232
+ }
233
+ return collectionIndexCache;
234
+ }
235
+
236
+ /**
237
+ * Materialises one collection's files, memoised per name — the only place that
238
+ * issues per-file requests. An unknown name is `[]`, matching 11ty.
239
+ *
240
+ * @param {string} key
241
+ * @returns {Promise<any[]>}
242
+ */
243
+ function loadCollectionItems(key) {
244
+ let items = collectionItemsCache.get(key);
245
+ if (!items) {
246
+ items = (async () => {
247
+ const collection = (await loadCollectionIndex()).get(key);
248
+ if (!collection) return [];
249
+
250
+ let files;
251
+ try {
252
+ files = await collection.items();
253
+ } catch {
254
+ return [];
255
+ }
256
+ return mapWithConcurrency(
257
+ files,
258
+ materialiseFile,
259
+ MATERIALISE_CONCURRENCY,
260
+ );
261
+ })();
262
+ collectionItemsCache.set(key, items);
263
+ }
264
+ return items;
265
+ }
266
+
267
+ /**
268
+ * Builds (or returns cached) the `collections` object. Every key is a lazy
269
+ * getter returning a `Promise` of its items, which LiquidJS awaits during
270
+ * expression evaluation — so a component that never mentions `collections`
271
+ * issues no per-file requests.
272
+ *
273
+ * Getters not a Proxy: LiquidJS probes `next` and `toLiquid` on every object
274
+ * it resolves, and a blanket-getter Proxy answers those with a Promise, which
275
+ * breaks the lookup entirely.
276
+ *
277
+ * @returns {Promise<Record<string, any>>}
278
+ */
279
+ export function buildCollectionsData() {
280
+ if (!collectionsCache) {
281
+ collectionsCache = (async () => {
282
+ const index = await loadCollectionIndex();
283
+
284
+ /** @type {Record<string, any>} */
285
+ const collections = {};
286
+ for (const key of index.keys()) {
287
+ Object.defineProperty(collections, key, {
288
+ enumerable: true,
289
+ configurable: true,
290
+ get: () => loadCollectionItems(key),
291
+ });
292
+ }
293
+ return collections;
294
+ })();
295
+ }
296
+ return collectionsCache;
297
+ }
298
+
299
+ /** Clears every collections cache and tears down the invalidation listeners. */
300
+ export function resetCollectionsCache() {
301
+ for (const { target, event, handler } of collectionsSubscriptions) {
302
+ target.removeEventListener(event, handler);
303
+ }
304
+ collectionsSubscriptions = [];
305
+ collectionIndexCache = null;
306
+ collectionItemsCache.clear();
307
+ collectionsCache = null;
308
+ }
@@ -0,0 +1,84 @@
1
+ // `includeWith` tag — spreads an object into a Liquid `{% include %}` the way
2
+ // Astro's `{...props}` does. Kept in its own file so the Node-side Eleventy
3
+ // plugin can import it without pulling in browser-runtime modules.
4
+
5
+ import { evalToken, Tokenizer, toPromise } from "liquidjs";
6
+ import { enhanceLiquidError } from "./errors.mjs";
7
+ import { group, groupEnd, log } from "./logger.mjs";
8
+
9
+ /**
10
+ * Usage: {% includeWith "path/to/partial", objectToSpread %}
11
+ *
12
+ * @param {any} _liquidEngine - Unused; engine reached via `this.liquid`
13
+ * @returns {any}
14
+ */
15
+ export function createIncludeWithTag(_liquidEngine) {
16
+ return {
17
+ parse(/** @type {any} */ tagToken) {
18
+ const tokenizer = new Tokenizer(
19
+ tagToken.args,
20
+ this.liquid.options.operatorsTrie,
21
+ );
22
+
23
+ this.pathToken = tokenizer.readValue();
24
+ if (!this.pathToken) {
25
+ throw new Error("includeWith: missing path argument");
26
+ }
27
+
28
+ tokenizer.skipBlank();
29
+ if (tokenizer.peek() !== ",") {
30
+ throw new Error("includeWith: expected comma separator");
31
+ }
32
+ tokenizer.advance();
33
+ tokenizer.skipBlank();
34
+
35
+ this.objectToken = tokenizer.readValue();
36
+ if (!this.objectToken) {
37
+ throw new Error("includeWith: missing object argument");
38
+ }
39
+ },
40
+
41
+ async render(/** @type {any} */ context) {
42
+ group("includeWith rendering");
43
+ const path = await toPromise(evalToken(this.pathToken, context));
44
+ log("Path resolved to:", path);
45
+
46
+ const obj = await toPromise(evalToken(this.objectToken, context));
47
+ log("Object resolved to:", obj);
48
+
49
+ if (!path || typeof path !== "string") {
50
+ groupEnd();
51
+ throw new Error(`includeWith: invalid path "${path}"`);
52
+ }
53
+ if (!obj || typeof obj !== "object") {
54
+ groupEnd();
55
+ return;
56
+ }
57
+
58
+ log(
59
+ "Including:",
60
+ path,
61
+ "with",
62
+ Object.keys(obj).length,
63
+ "props:",
64
+ Object.keys(obj),
65
+ );
66
+
67
+ context.push(obj);
68
+ try {
69
+ const templates = await this.liquid.parseFile(path);
70
+ const result = await this.liquid.render(templates, context);
71
+ log("Rendered result preview:", result?.substring?.(0, 200) || result);
72
+ groupEnd();
73
+ return result;
74
+ } catch (err) {
75
+ const error = /** @type {Error} */ (err);
76
+ log("Error during render:", error.message);
77
+ groupEnd();
78
+ throw enhanceLiquidError(err, `includeWith "${path}"`);
79
+ } finally {
80
+ context.pop();
81
+ }
82
+ },
83
+ };
84
+ }