@cloudcannon/editable-regions 0.0.12 → 0.0.13

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.
@@ -36,7 +36,7 @@ export const getCollection = async (collectionKey, filter) => {
36
36
  render: () => ({
37
37
  Content: () => body ?? "Content is not available when live editing",
38
38
  headings: [],
39
- remarkPluginFrontmatter: {},
39
+ remarkPluginFrontmatter: data ?? {},
40
40
  }),
41
41
  };
42
42
  });
@@ -109,7 +109,7 @@ export const getEntryBySlug = (collection, slug) => {
109
109
  export const render = async (entry) => ({
110
110
  Content: () => entry?.body ?? "Content is not available when live editing",
111
111
  headings: [],
112
- remarkPluginFrontmatter: {},
112
+ remarkPluginFrontmatter: entry?.data ?? {},
113
113
  });
114
114
 
115
115
  export const defineCollection = () =>
@@ -0,0 +1,294 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import esbuild from "esbuild";
4
+ import { createBindIncludeTag } from "./liquid/index.mjs";
5
+
6
+ /**
7
+ * @typedef {Object} ComponentRegistration
8
+ * @property {string} name - Component name
9
+ * @property {string} file - Path to component file
10
+ */
11
+
12
+ /**
13
+ * @typedef {Object} LiquidOptions
14
+ * @property {string[]} [componentDirs] - Defaults to Eleventy's configured directories.includes
15
+ * @property {string[]} [extensions] - Defaults to [".liquid", ".html"]
16
+ * @property {string[]} [ignoreDirectories] - Directory names to skip (e.g., ["_drafts", "node_modules"])
17
+ * @property {ComponentRegistration[]} [components] - Registered components
18
+ * @property {ComponentRegistration[]} [filters] - Custom Liquid filters
19
+ * @property {ComponentRegistration[]} [shortcodes] - Custom shortcodes
20
+ * @property {ComponentRegistration[]} [pairedShortcodes] - Custom paired shortcodes
21
+ * @property {ComponentRegistration[]} [tags] - Custom tags
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} PluginOptions
26
+ * @property {string} [output] - Output path for live-editing.js
27
+ * @property {boolean} [verbose] - Enable verbose browser logging
28
+ * @property {LiquidOptions} [liquid] - Liquid template options
29
+ */
30
+
31
+ /**
32
+ * @typedef {Object} EleventyDirectories
33
+ * @property {string} input - Input directory
34
+ * @property {string} includes - Includes directory (normalized, relative to project root)
35
+ * @property {string} data - Data directory
36
+ * @property {string} output - Output directory
37
+ */
38
+
39
+ /**
40
+ * @typedef {Object} EleventyConfig
41
+ * @property {function(string, function): void} addLiquidTag - Register a custom Liquid tag
42
+ * @property {function(string, function({ directories: EleventyDirectories }): Promise<void>): void} on - Register an event handler
43
+ * @property {EleventyDirectories} dir - Directory configuration
44
+ */
45
+
46
+ /**
47
+ * Eleventy plugin for CloudCannon editable regions.
48
+ * Registers Liquid tags and builds live-editing client bundle.
49
+ *
50
+ * @param {EleventyConfig} eleventyConfig - Eleventy configuration object
51
+ * @param {PluginOptions} pluginOptions - Plugin configuration options
52
+ * @returns {void}
53
+ */
54
+ export default function (eleventyConfig, pluginOptions) {
55
+ if (pluginOptions.liquid) {
56
+ eleventyConfig.addLiquidTag("bind_include", createBindIncludeTag);
57
+ }
58
+
59
+ eleventyConfig.on("eleventy.before", async ({ directories }) => {
60
+ const liveEditingSource = createLiveEditingSource(
61
+ pluginOptions,
62
+ directories,
63
+ );
64
+
65
+ // Build dynamic loader config from extensions
66
+ // esbuild only looks at the final extension, so .bookshop.liquid -> .liquid
67
+ /** @type {Record<string, import('esbuild').Loader>} */
68
+ const loader = {};
69
+ const extensions = pluginOptions.liquid?.extensions ?? [".liquid", ".html"];
70
+ extensions.forEach((ext) => {
71
+ const normalized = ext.startsWith(".") ? ext : `.${ext}`;
72
+ // Extract the final extension (e.g., ".bookshop.liquid" -> ".liquid")
73
+ const lastDotIndex = normalized.lastIndexOf(".");
74
+ loader[normalized.slice(lastDotIndex)] = "text";
75
+ });
76
+
77
+ await esbuild.build({
78
+ stdin: {
79
+ contents: await liveEditingSource,
80
+ resolveDir: process.cwd(),
81
+ },
82
+ loader,
83
+ bundle: true,
84
+ outfile: pluginOptions.output ?? `${directories.output}/live-editing.js`,
85
+ });
86
+ });
87
+ }
88
+
89
+ /**
90
+ * Creates the JavaScript source code for the live-editing client bundle.
91
+ * Generates imports for components, filters, shortcodes, and tags.
92
+ *
93
+ * @param {PluginOptions} pluginOptions - Plugin configuration options
94
+ * @param {EleventyDirectories} directories - Eleventy directory configuration
95
+ * @returns {Promise<string>} Generated JavaScript source code
96
+ */
97
+ const createLiveEditingSource = async (pluginOptions, directories) => {
98
+ let source = "";
99
+
100
+ if (pluginOptions.liquid) {
101
+ const componentDirs = pluginOptions.liquid.componentDirs ?? [
102
+ directories.includes,
103
+ directories.input,
104
+ ];
105
+ const extensions = pluginOptions.liquid.extensions ?? [".liquid", ".html"];
106
+ const ignoreDirectories = pluginOptions.liquid.ignoreDirectories ?? [
107
+ directories.output,
108
+ "node_modules",
109
+ ];
110
+
111
+ const normalizedExtensions = extensions.map((ext) =>
112
+ ext.startsWith(".") ? ext.toLowerCase() : `.${ext.toLowerCase()}`,
113
+ );
114
+ const normalizedIgnoreDirs = ignoreDirectories.map((dir) =>
115
+ dir.toLowerCase(),
116
+ );
117
+
118
+ source += `
119
+ import { createSharedLiquidEngine, registerLiquidComponent, registerCustomFilter, registerCustomShortcode, registerCustomPairedShortcode, registerCustomTag, setVerbose } from '@cloudcannon/editable-regions/liquid';
120
+
121
+ setVerbose(${Boolean(pluginOptions.verbose)});
122
+
123
+ // Configure the Liquid engine with component directories
124
+ createSharedLiquidEngine({
125
+ root: ${JSON.stringify(componentDirs)},
126
+ extname: ".liquid",
127
+ strictFilters: true,
128
+ });
129
+
130
+ window.cc_files = {};
131
+ `;
132
+
133
+ // Add files we'll need to window.cc_files -
134
+ // Then in our liquid file system we can grab them from window.cc_files during readFile
135
+ let i = 0;
136
+ const allLiquidFiles = await findAllLiquidFiles(
137
+ componentDirs,
138
+ normalizedExtensions,
139
+ normalizedIgnoreDirs,
140
+ );
141
+ allLiquidFiles.forEach((path) => {
142
+ const id = `liquidFile_${i++}`;
143
+ source += `import ${id} from "./${path}";
144
+
145
+ window.cc_files["${path}"] = ${id};
146
+ `;
147
+ });
148
+
149
+ // Register custom filters
150
+ const customFilters = pluginOptions.liquid?.filters;
151
+ if (customFilters?.length) {
152
+ let filterIdx = 0;
153
+ for (const { name, file } of customFilters) {
154
+ const filterName = `customFilter_${filterIdx++}`;
155
+ source += `
156
+ import ${filterName} from "./${file}";
157
+ registerCustomFilter("${name}", ${filterName});
158
+ `;
159
+ }
160
+ }
161
+
162
+ // Register custom shortcodes
163
+ const customShortcodes = pluginOptions.liquid?.shortcodes;
164
+ if (customShortcodes?.length) {
165
+ let shortcodeIdx = 0;
166
+ for (const { name, file } of customShortcodes) {
167
+ const shortcodeName = `customShortcode_${shortcodeIdx++}`;
168
+ source += `
169
+ import ${shortcodeName} from "./${file}";
170
+ registerCustomShortcode("${name}", ${shortcodeName});
171
+ `;
172
+ }
173
+ }
174
+
175
+ // Register custom paired shortcodes
176
+ const customPairedShortcodes = pluginOptions.liquid?.pairedShortcodes;
177
+ if (customPairedShortcodes?.length) {
178
+ let pairedIdx = 0;
179
+ for (const { name, file } of customPairedShortcodes) {
180
+ const pairedShortcodeName = `customPairedShortcode_${pairedIdx++}`;
181
+ source += `
182
+ import ${pairedShortcodeName} from "./${file}";
183
+ registerCustomPairedShortcode("${name}", ${pairedShortcodeName});
184
+ `;
185
+ }
186
+ }
187
+
188
+ // Register custom tags
189
+ const tags = pluginOptions.liquid?.tags;
190
+ if (tags?.length) {
191
+ let tagIdx = 0;
192
+ for (const { name, file } of tags) {
193
+ const tagName = `tag_${tagIdx++}`;
194
+ source += `
195
+ import ${tagName} from "./${file}";
196
+ registerCustomTag("${name}", ${tagName});
197
+ `;
198
+ }
199
+ }
200
+
201
+ // Register components
202
+ let componentIdx = 0;
203
+ pluginOptions.liquid?.components?.forEach(({ name, file }) => {
204
+ const componentName = `customComponent_${componentIdx++}`;
205
+
206
+ source += `
207
+ import ${componentName} from "./${file}";
208
+ registerLiquidComponent("${name}", ${componentName});
209
+ `;
210
+ });
211
+ }
212
+ return source;
213
+ };
214
+
215
+ /**
216
+ * Find all component files across multiple directories.
217
+ *
218
+ * @param {string[]} componentDirs - Directories to search
219
+ * @param {string[]} extensions - File extensions to match
220
+ * @param {string[]} ignoreDirectories - Directory names to skip
221
+ * @returns {Promise<string[]>} Array of file paths
222
+ */
223
+ async function findAllLiquidFiles(
224
+ componentDirs,
225
+ extensions,
226
+ ignoreDirectories,
227
+ ) {
228
+ const allFiles = [];
229
+
230
+ for (const dir of componentDirs) {
231
+ const files = await findFilesInDirectory({
232
+ directory: dir,
233
+ extensions,
234
+ ignoreDirectories,
235
+ });
236
+
237
+ allFiles.push(...files);
238
+ }
239
+ return allFiles;
240
+ }
241
+
242
+ /**
243
+ * Recursively find all matching files in a single directory.
244
+ *
245
+ * @param {Object} options - Search options
246
+ * @param {string} options.directory - Directory to search
247
+ * @param {string[]} [options.extensions] - File extensions to match
248
+ * @param {string[]} [options.ignoreDirectories] - Directory names to skip
249
+ * @returns {Promise<string[]>} Array of file paths
250
+ */
251
+ async function findFilesInDirectory({
252
+ directory,
253
+ extensions = [".html", ".liquid"],
254
+ ignoreDirectories = [],
255
+ }) {
256
+ const files = [];
257
+
258
+ try {
259
+ const entries = await fs.promises.readdir(directory, {
260
+ withFileTypes: true,
261
+ });
262
+
263
+ for (const entry of entries) {
264
+ const fullPath = path.join(directory, entry.name);
265
+
266
+ if (entry.isDirectory()) {
267
+ if (ignoreDirectories.includes(entry.name.toLowerCase())) {
268
+ continue;
269
+ }
270
+ const subFiles = await findFilesInDirectory({
271
+ directory: fullPath,
272
+ extensions,
273
+ ignoreDirectories,
274
+ });
275
+ files.push(...subFiles);
276
+ } else if (entry.isFile()) {
277
+ // Check if filename ends with any of the configured extensions
278
+ // This handles both simple (.liquid) and compound (.bookshop.liquid) extensions
279
+ const filenameLower = entry.name.toLowerCase();
280
+ const hasValidExtension = extensions.some((ext) =>
281
+ filenameLower.endsWith(ext),
282
+ );
283
+ if (hasValidExtension) {
284
+ files.push(fullPath);
285
+ }
286
+ }
287
+ }
288
+ } catch (error) {
289
+ console.error("ERROR reading directory:", directory, error);
290
+ throw error;
291
+ }
292
+
293
+ return files;
294
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Browser-compatible implementations of Eleventy's built-in filters.
3
+ * Some filters (get*CollectionItem, inputPathToUrl, renderTransforms) are not
4
+ * included as they require build-time context.
5
+ */
6
+
7
+ import slugify from "@sindresorhus/slugify";
8
+
9
+ /**
10
+ * Logs value to console (pass-through filter).
11
+ *
12
+ * @param {any} value - Value to log
13
+ * @param {string} [prefix] - Optional prefix for the log message
14
+ * @returns {any} The original value (for chaining)
15
+ */
16
+ export function logFilter(value, prefix = "") {
17
+ if (prefix) {
18
+ console.log(`[${prefix}]`, value);
19
+ } else {
20
+ console.log(value);
21
+ }
22
+ // Return the original value so it can be chained or used in output
23
+ return value;
24
+ }
25
+
26
+ /**
27
+ * Normalizes URL paths (simplified browser version of Eleventy's url filter).
28
+ *
29
+ * @param {string} url - URL to normalize
30
+ * @param {string} [pathPrefix] - Optional path prefix to prepend
31
+ * @returns {string} Normalized URL
32
+ */
33
+ export function urlFilter(url, pathPrefix = "") {
34
+ if (!url) {
35
+ return "";
36
+ }
37
+
38
+ const urlString = String(url);
39
+
40
+ // If there's a pathPrefix, prepend it
41
+ if (pathPrefix) {
42
+ // Ensure pathPrefix starts with / and doesn't end with /
43
+ const normalizedPrefix = `/${pathPrefix.replace(/^\/+|\/+$/g, "")}`;
44
+
45
+ // If url is absolute (starts with /), prepend pathPrefix
46
+ if (urlString.startsWith("/")) {
47
+ return normalizedPrefix + urlString;
48
+ }
49
+ // If url is relative, just return it
50
+ return urlString;
51
+ }
52
+
53
+ // Basic normalization: ensure single slashes, remove trailing slash (except root)
54
+ const normalized = urlString.replace(/\/+/g, "/");
55
+
56
+ // Remove trailing slash unless it's the root path
57
+ if (normalized.length > 1 && normalized.endsWith("/")) {
58
+ return normalized.slice(0, -1);
59
+ }
60
+
61
+ return normalized;
62
+ }
63
+
64
+ /** @type {Record<string, any>} */
65
+ export const eleventyFilters = {
66
+ slugify,
67
+ log: logFilter,
68
+ url: urlFilter,
69
+ };
@@ -0,0 +1,127 @@
1
+ import { log, warn } from "./logger.mjs";
2
+
3
+ /**
4
+ * In-memory filesystem for LiquidJS that reads from window.cc_files.
5
+ *
6
+ * @type {any} LiquidJS-compatible filesystem object
7
+ */
8
+ export const inMemoryFs = {
9
+ sep: "/",
10
+
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
+ const parts = filePath.split("/");
19
+ parts.pop();
20
+ return parts.join("/") || "/";
21
+ },
22
+
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];
32
+
33
+ if (fileContents === undefined) {
34
+ const availableFiles = Object.keys(window.cc_files || {});
35
+ warn("File not found:", filePath);
36
+ log("Available files:", availableFiles);
37
+ } else {
38
+ log("File found, length:", fileContents?.length || 0);
39
+ }
40
+
41
+ return fileContents;
42
+ },
43
+
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);
53
+ if (!filePath) {
54
+ throw new Error("readFile called with empty path");
55
+ }
56
+ return this.readFileSync(filePath);
57
+ },
58
+
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) {
66
+ if (!filePath || typeof filePath !== "string") {
67
+ log("exists: invalid path", filePath);
68
+ return false;
69
+ }
70
+ const result = this.existsSync(filePath);
71
+ log("exists:", filePath, "=", result);
72
+ return result;
73
+ },
74
+
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) {
82
+ if (!filePath || typeof filePath !== "string") {
83
+ return false;
84
+ }
85
+ const fileContents = window.cc_files?.[filePath];
86
+ const exists = fileContents !== null && fileContents !== undefined;
87
+ log("existsSync:", filePath, "=", exists);
88
+ return exists;
89
+ },
90
+
91
+ /**
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
94
+ * 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
+ */
101
+ resolve(root, file, ext) {
102
+ const extension = ext || ".liquid";
103
+ const fileWithExt = file.endsWith(extension) ? file : `${file}${extension}`;
104
+ const normalizedRoot = root.replace(/^\.\//, "").replace(/\/*$/, "/");
105
+ const resolved = `${normalizedRoot}${fileWithExt}`;
106
+ log("resolve:", { root, file, ext }, "->", resolved);
107
+ return resolved;
108
+ },
109
+
110
+ /**
111
+ * Returns file stat (always returns isFile: true for compatibility).
112
+ *
113
+ * @returns {Promise<{isFile: () => boolean}>}
114
+ */
115
+ async statAsync() {
116
+ return { isFile: () => true };
117
+ },
118
+
119
+ /**
120
+ * Returns file stat synchronously (always returns isFile: true for compatibility).
121
+ *
122
+ * @returns {{isFile: () => boolean}}
123
+ */
124
+ statSync() {
125
+ return { isFile: () => true };
126
+ },
127
+ };
@@ -0,0 +1,269 @@
1
+ import { evalToken, Liquid, Tokenizer, toPromise } from "liquidjs";
2
+ import { eleventyFilters } from "./11ty-filters.mjs";
3
+ import { inMemoryFs } from "./fs.mjs";
4
+ import { group, groupEnd, log } from "./logger.mjs";
5
+ import { createPairedShortcodeTag, createShortcodeTag } from "./shortcodes.mjs";
6
+
7
+ // Re-export logger utilities for external use
8
+ export { group, groupEnd, log, setVerbose } from "./logger.mjs";
9
+
10
+ /** @type {import("liquidjs").Liquid | null} */
11
+ let sharedLiquidEngine = null;
12
+
13
+ /**
14
+ * Creates and configures the shared Liquid engine instance.
15
+ *
16
+ * @param {{componentDirs?: string[]}} options - Liquid engine options
17
+ * @returns {void}
18
+ */
19
+ export function createSharedLiquidEngine(options) {
20
+ log("Creating shared Liquid engine");
21
+
22
+ sharedLiquidEngine = new Liquid({
23
+ fs: inMemoryFs,
24
+ globals: {
25
+ ENV_CLIENT: true,
26
+ },
27
+ ...options,
28
+ });
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
+
41
+ log(
42
+ "Available files in window.cc_files:",
43
+ Object.keys(window.cc_files || {}),
44
+ );
45
+
46
+ sharedLiquidEngine.registerTag(
47
+ "bind_include",
48
+ createBindIncludeTag(sharedLiquidEngine),
49
+ );
50
+ log("bind_include tag registered");
51
+ }
52
+
53
+ /**
54
+ * Registers a Liquid component with the CloudCannon component system.
55
+ * Creates a wrapper that renders the Liquid template to an HTMLElement.
56
+ *
57
+ * @param {string} key - Unique identifier for the component
58
+ * @param {string} contents - The Liquid template contents
59
+ * @returns {void}
60
+ */
61
+ export function registerLiquidComponent(key, contents) {
62
+ log("Registering component:", key);
63
+ log("Component contents preview:", contents?.substring?.(0, 200) || contents);
64
+
65
+ if (!sharedLiquidEngine) {
66
+ throw new Error(
67
+ `sharedLiquidEngine not defined when registering component ${key}`,
68
+ );
69
+ }
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
+
93
+ window.cc_components = window.cc_components || {};
94
+ window.cc_components[key] = wrappedComponent;
95
+ log(`Component registered, ${key}`);
96
+ }
97
+
98
+ /**
99
+ * Registers a custom Liquid filter.
100
+ *
101
+ * @param {string} name - The filter name
102
+ * @param {any} fn - The filter function
103
+ * @returns {void}
104
+ */
105
+ export function registerCustomFilter(name, fn) {
106
+ log("Registering filter:", name);
107
+ if (!sharedLiquidEngine) {
108
+ throw new Error(
109
+ `sharedLiquidEngine not defined when registering custom filter ${name}`,
110
+ );
111
+ }
112
+ sharedLiquidEngine.registerFilter(name, fn);
113
+ }
114
+
115
+ /**
116
+ * Registers a custom shortcode.
117
+ *
118
+ * Usage in templates: {% shortcodeName arg1, arg2 %}
119
+ *
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}
123
+ */
124
+ export function registerCustomShortcode(name, fn) {
125
+ log("Registering shortcode:", name);
126
+ if (!sharedLiquidEngine) {
127
+ throw new Error(
128
+ `sharedLiquidEngine not defined when registering custom shortcode ${name}`,
129
+ );
130
+ }
131
+ sharedLiquidEngine.registerTag(name, createShortcodeTag(fn, name));
132
+ }
133
+
134
+ /**
135
+ * Registers a custom paired shortcode (with content between tags).
136
+ *
137
+ * Usage in templates: {% shortcodeName arg %}content{% endshortcodeName %}
138
+ *
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}
142
+ */
143
+ export function registerCustomPairedShortcode(name, fn) {
144
+ log("Registering paired shortcode:", name);
145
+ if (!sharedLiquidEngine) {
146
+ throw new Error(
147
+ `sharedLiquidEngine not defined when registering custom paired shortcode ${name}`,
148
+ );
149
+ }
150
+ sharedLiquidEngine.registerTag(name, createPairedShortcodeTag(name, fn));
151
+ }
152
+
153
+ /**
154
+ * Registers a custom tag with full LiquidJS parser access.
155
+ *
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}
164
+ */
165
+ export function registerCustomTag(name, factory) {
166
+ log("Registering custom tag:", name);
167
+ if (!sharedLiquidEngine) {
168
+ throw new Error(
169
+ `sharedLiquidEngine not defined when registering custom tag ${name}`,
170
+ );
171
+ }
172
+ sharedLiquidEngine.registerTag(name, factory(sharedLiquidEngine));
173
+ }
174
+
175
+ /**
176
+ * Creates a bind_include tag for spreading object props into includes.
177
+ * Like Astro's {...props} spread for Liquid includes.
178
+ *
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
183
+ */
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,
195
+ );
196
+
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),
245
+ );
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
+ },
268
+ };
269
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Simple logger for live editing integration.
3
+ * Enable verbose mode to see detailed logs in browser console.
4
+ */
5
+
6
+ let verboseEnabled = false;
7
+
8
+ /**
9
+ * Enables or disables verbose logging.
10
+ *
11
+ * @param {boolean} enabled - Whether to enable verbose logging
12
+ * @returns {void}
13
+ */
14
+ export function setVerbose(enabled) {
15
+ verboseEnabled = enabled;
16
+ if (enabled) {
17
+ console.log("Live editing verbose logging enabled");
18
+ }
19
+ }
20
+
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) {
38
+ if (verboseEnabled) {
39
+ console.log(...args);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Always log warnings.
45
+ *
46
+ * @param {...any} args - Arguments to log
47
+ * @returns {void}
48
+ */
49
+ export function warn(...args) {
50
+ console.warn(...args);
51
+ }
52
+
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);
61
+ }
62
+
63
+ /**
64
+ * Group logs (only in verbose mode).
65
+ *
66
+ * @param {string} label - Group label
67
+ * @returns {void}
68
+ */
69
+ export function group(label) {
70
+ if (verboseEnabled) {
71
+ console.group(label);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * End a console group (only in verbose mode).
77
+ *
78
+ * @returns {void}
79
+ */
80
+ export function groupEnd() {
81
+ if (verboseEnabled) {
82
+ console.groupEnd();
83
+ }
84
+ }
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,165 @@
1
+ /**
2
+ * Shortcode-to-LiquidJS-Tag wrapper utilities.
3
+ * Converts Eleventy-style shortcode functions into LiquidJS custom tags.
4
+ *
5
+ * Eleventy shortcodes: simple functions that return HTML
6
+ * LiquidJS tags: objects with parse() and render() methods
7
+ */
8
+
9
+ import { evalToken, Tokenizer, toPromise } from "liquidjs";
10
+ import { group, groupEnd, log } from "./logger.mjs";
11
+
12
+ /**
13
+ * Parses comma-separated arguments from a tag's args string.
14
+ * Handles quoted strings and variable references.
15
+ *
16
+ * @param {string} argsString - Raw arguments string from tagToken.args
17
+ * @param {any} operatorsTrie - Liquid options operatorsTrie
18
+ * @returns {any[]} Array of parsed tokens
19
+ */
20
+ function parseArgs(argsString, operatorsTrie) {
21
+ if (!argsString || !argsString.trim()) {
22
+ return [];
23
+ }
24
+
25
+ const tokenizer = new Tokenizer(argsString, operatorsTrie);
26
+ const tokens = [];
27
+
28
+ while (true) {
29
+ tokenizer.skipBlank();
30
+ const token = tokenizer.readValue();
31
+ if (!token) break;
32
+ tokens.push(token);
33
+
34
+ tokenizer.skipBlank();
35
+ if (tokenizer.peek() === ",") {
36
+ tokenizer.advance();
37
+ } else {
38
+ break;
39
+ }
40
+ }
41
+
42
+ return tokens;
43
+ }
44
+
45
+ /**
46
+ * Evaluates parsed tokens against the render context.
47
+ *
48
+ * @param {any[]} tokens - Array of parsed tokens
49
+ * @param {any} context - LiquidJS render context
50
+ * @returns {Promise<any[]>} Array of evaluated values
51
+ */
52
+ async function evaluateArgs(tokens, context) {
53
+ const values = [];
54
+ for (const token of tokens) {
55
+ const value = await toPromise(evalToken(token, context));
56
+ values.push(value);
57
+ }
58
+ return values;
59
+ }
60
+
61
+ /**
62
+ * Creates a LiquidJS tag implementation for a regular (non-paired) shortcode.
63
+ *
64
+ * Usage: {% shortcodeName arg1, arg2, "literal" %}
65
+ *
66
+ * @param {any} shortcodeFn - The shortcode function (arg1, arg2, ...) => string
67
+ * @param {string} shortcodeName - The shortcode name for logging
68
+ * @returns {import('liquidjs/dist/template/tag-options-adapter').TagImplOptions} LiquidJS tag implementation
69
+ */
70
+ export function createShortcodeTag(shortcodeFn, shortcodeName) {
71
+ /** @type {any} */
72
+ const tag = {
73
+ /**
74
+ * @param {any} tagToken - The tag token from LiquidJS parser
75
+ */
76
+ parse(tagToken) {
77
+ this.argTokens = parseArgs(
78
+ tagToken.args,
79
+ this.liquid.options.operatorsTrie,
80
+ );
81
+ },
82
+
83
+ /**
84
+ * @param {any} context - The LiquidJS render context
85
+ */
86
+ async render(context) {
87
+ log(`Executing shortcode "${shortcodeName}"`);
88
+ const args = await evaluateArgs(this.argTokens, context);
89
+ log("Shortcode args:", args);
90
+ const result = await shortcodeFn(...args);
91
+ log("Shortcode returned:", result?.substring?.(0, 100) || result);
92
+ return result ?? "";
93
+ },
94
+ };
95
+ return tag;
96
+ }
97
+
98
+ /**
99
+ * Creates a LiquidJS tag implementation for a paired shortcode.
100
+ *
101
+ * Usage: {% shortcodeName arg1 %}content{% endshortcodeName %}
102
+ *
103
+ * @param {string} tagName - The shortcode/tag name (needed to find end tag)
104
+ * @param {any} shortcodeFn - The shortcode function (content, arg1, ...) => string
105
+ * @returns {import('liquidjs/dist/template/tag-options-adapter').TagImplOptions} LiquidJS tag implementation
106
+ */
107
+ export function createPairedShortcodeTag(tagName, shortcodeFn) {
108
+ const endTagName = `end${tagName}`;
109
+
110
+ /** @type {any} */
111
+ const tag = {
112
+ /**
113
+ * @param {any} tagToken - The tag token from LiquidJS parser
114
+ * @param {any} remainTokens - Remaining tokens to parse
115
+ */
116
+ parse(tagToken, remainTokens) {
117
+ this.argTokens = parseArgs(
118
+ tagToken.args,
119
+ this.liquid.options.operatorsTrie,
120
+ );
121
+ this.templates = [];
122
+
123
+ // Consume tokens until we find the end tag
124
+ while (remainTokens.length) {
125
+ const token = remainTokens.shift();
126
+
127
+ // Check if this is our end tag
128
+ if (token.name === endTagName) {
129
+ break;
130
+ }
131
+
132
+ // Parse this token into a template and add to our templates
133
+ const template = this.liquid.parser.parseToken(token, remainTokens);
134
+ this.templates.push(template);
135
+ }
136
+ },
137
+
138
+ /**
139
+ * @param {any} context - The LiquidJS render context
140
+ */
141
+ async render(context) {
142
+ group(`Paired shortcode "${tagName}"`);
143
+ log("Inner templates to render:", this.templates.length);
144
+
145
+ // Render the content between the tags
146
+ // NOTE: renderTemplates returns a generator, must use toPromise() to resolve it
147
+ const content = await toPromise(
148
+ this.liquid.renderer.renderTemplates(this.templates, context),
149
+ );
150
+ log("Content resolved:", content);
151
+
152
+ // Evaluate arguments
153
+ const args = await evaluateArgs(this.argTokens, context);
154
+ log("Args:", args);
155
+
156
+ // Call shortcode with content as first argument, then other args
157
+ const result = await shortcodeFn(content, ...args);
158
+ log("Final HTML:", result?.substring?.(0, 100) || result);
159
+ groupEnd();
160
+
161
+ return result ?? "";
162
+ },
163
+ };
164
+ return tag;
165
+ }
@@ -493,4 +493,15 @@ export default class EditableArrayItem extends EditableComponent {
493
493
  "@index": Number(this.element.dataset.prop),
494
494
  };
495
495
  }
496
+
497
+ handleApiEvent(e: any): void {
498
+ if (this.connected) {
499
+ return super.handleApiEvent(e);
500
+ }
501
+ }
502
+
503
+ async disconnect(): Promise<void> {
504
+ await super.disconnect();
505
+ this.hardDisconnect();
506
+ }
496
507
  }
@@ -6,6 +6,7 @@ export default class EditableImage extends Editable {
6
6
  undefined;
7
7
  inputConfig: { src?: any; alt?: any; title?: any } = {};
8
8
  imageEl?: HTMLImageElement;
9
+ panelId?: string;
9
10
 
10
11
  configuredSrc = false;
11
12
  configuredAlt = false;
@@ -185,7 +186,7 @@ export default class EditableImage extends Editable {
185
186
  !!this.element.dataset.propTitle || !!this.element.dataset.prop;
186
187
 
187
188
  this.loadInputConfig().then(() => {
188
- this.imageEl?.addEventListener("click", (e) => {
189
+ this.imageEl?.addEventListener("click", async (e) => {
189
190
  e.preventDefault();
190
191
 
191
192
  if (!this.value) {
@@ -203,7 +204,7 @@ export default class EditableImage extends Editable {
203
204
  data.title = this.value.title;
204
205
  }
205
206
 
206
- CloudCannon.createCustomDataPanel({
207
+ this.panelId = await CloudCannon.createCustomDataPanel({
207
208
  title: "Edit Image",
208
209
  data,
209
210
  position: this.imageEl?.getBoundingClientRect(),
@@ -268,4 +269,11 @@ export default class EditableImage extends Editable {
268
269
  });
269
270
  });
270
271
  }
272
+
273
+ hardDisconnect(): void {
274
+ if (this.panelId) {
275
+ CloudCannon.destroyCustomDataPanel(this.panelId);
276
+ }
277
+ super.hardDisconnect();
278
+ }
271
279
  }
@@ -138,6 +138,10 @@ export default class EditableText extends Editable {
138
138
  ? { type: "markdown" }
139
139
  : await this.dispatchGetInputConfig(this.element.dataset.prop);
140
140
 
141
+ const extension = this.contextBase?.isContent
142
+ ? this.contextBase.file?.path.split(".").pop()
143
+ : undefined;
144
+
141
145
  this.editor = await CloudCannon.createTextEditableRegion(
142
146
  this.element,
143
147
  this.onChange.bind(this),
@@ -145,6 +149,7 @@ export default class EditableText extends Editable {
145
149
  elementType: this.element.dataset.type,
146
150
  editableType: this.contextBase?.isContent ? "content" : undefined,
147
151
  inputConfig,
152
+ extension,
148
153
  },
149
154
  );
150
155
 
package/nodes/editable.ts CHANGED
@@ -42,6 +42,7 @@ export interface APIListener {
42
42
 
43
43
  export default class Editable {
44
44
  APIListeners: APIListener[] = [];
45
+ handleAPIEventsListeners?: this["handleApiEvent"];
45
46
  listeners: EditableListener[] = [];
46
47
  domListeners: DOMListener[] = [];
47
48
  value: unknown = undefined;
@@ -371,6 +372,12 @@ export default class Editable {
371
372
  }
372
373
  }
373
374
 
375
+ hardDisconnect(): void {
376
+ this.listeners.forEach(({ editable }) => {
377
+ editable.hardDisconnect();
378
+ });
379
+ }
380
+
374
381
  async disconnect(): Promise<void> {
375
382
  if (this.disconnecting) {
376
383
  return;
@@ -382,7 +389,6 @@ export default class Editable {
382
389
  }
383
390
 
384
391
  this.parent?.deregisterListener(this);
385
- this.parent = null;
386
392
  if (this.pendingParentElement) {
387
393
  const pending = this.pendingParentElement.__pendingEditableListeners;
388
394
  if (pending) {
@@ -413,6 +419,8 @@ export default class Editable {
413
419
  }
414
420
 
415
421
  connect(): void {
422
+ this.parent = null;
423
+
416
424
  if (!this.validateConfiguration()) {
417
425
  return;
418
426
  }
@@ -528,12 +536,23 @@ export default class Editable {
528
536
  this.queueListenerOnParent(this.pendingParentElement, { editable: this });
529
537
  }
530
538
 
531
- this.addEventListener("cloudcannon-api", this.handleApiEvent.bind(this));
539
+ if (this.handleAPIEventsListeners) {
540
+ this.element.removeEventListener(
541
+ "cloudcannon-api",
542
+ this.handleAPIEventsListeners,
543
+ );
544
+ }
545
+ this.handleAPIEventsListeners = this.handleApiEvent.bind(this);
546
+ this.element.addEventListener(
547
+ "cloudcannon-api",
548
+ this.handleAPIEventsListeners,
549
+ );
532
550
  this.replayPendingListeners();
533
551
  }
534
552
 
535
553
  handleApiEvent(e: any): void {
536
- if (e.target !== this.element) {
554
+ const target = e.detail.forwardedTarget ?? e.target;
555
+ if (target !== this.element) {
537
556
  if (!e.detail.source) {
538
557
  e.detail.source = this.element.dataset.prop;
539
558
  } else {
@@ -551,15 +570,30 @@ export default class Editable {
551
570
  }
552
571
  }
553
572
 
573
+ let propagating = true;
554
574
  const { absolute } = this.parseSource(e.detail.source);
555
575
  if (!this.parent || absolute) {
556
576
  if (this.executeApiCall(e.detail)) {
577
+ propagating = false;
557
578
  e.stopPropagation();
558
579
  }
559
580
  }
581
+
582
+ if (!this.connected && propagating) {
583
+ this.parent?.element.dispatchEvent(
584
+ new CustomEvent("cloudcannon-api", {
585
+ bubbles: true,
586
+ detail: { ...e.detail, forwardedTarget: target },
587
+ }),
588
+ );
589
+ }
560
590
  }
561
591
 
562
592
  executeApiCall(options: any): boolean {
593
+ if (!this.connected) {
594
+ return false;
595
+ }
596
+
563
597
  let { file, collection, source, dataset } = this.parseSource(
564
598
  options.source,
565
599
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudcannon/editable-regions",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "type": "module",
5
5
  "description": "Visual Editing for the CloudCannon CMS.",
6
6
  "keywords": [
@@ -49,17 +49,25 @@
49
49
  "default": "./integrations/react.mjs",
50
50
  "types": "./types/react.d.ts"
51
51
  },
52
+ "./liquid": "./integrations/liquid/index.mjs",
53
+ "./eleventy": "./integrations/eleventy.mjs",
52
54
  "./internal/components": "./components/index.js",
53
55
  "./internal/styles": "./styles/index.js"
54
56
  },
55
57
  "devDependencies": {
56
- "@biomejs/biome": "2.4.4",
57
- "@cloudcannon/javascript-api": "0.0.12",
58
+ "@biomejs/biome": "2.4.10",
59
+ "@cloudcannon/javascript-api": "0.0.14",
60
+ "@sindresorhus/slugify": "3.0.0",
58
61
  "@types/js-beautify": "1.14.3",
59
- "@types/react": "19.2.8",
60
- "@types/react-dom": "18.3.1",
61
- "astro": "^5.14.1",
62
- "js-beautify": "^1.15.4",
62
+ "@types/node": "25.5.0",
63
+ "@types/react": "19.2.14",
64
+ "@types/react-dom": "19.2.3",
65
+ "astro": "6.1.2",
66
+ "js-beautify": "1.15.4",
67
+ "liquidjs": "10.25.2",
63
68
  "typescript": "5.9.3"
69
+ },
70
+ "dependencies": {
71
+ "esbuild": "0.27.4"
64
72
  }
65
73
  }
@@ -0,0 +1,53 @@
1
+ declare module "@cloudcannon/editable-regions/liquid" {
2
+ import type { Liquid } from "liquidjs";
3
+
4
+ interface LiquidConfig {
5
+ componentDirs?: string[];
6
+ }
7
+
8
+ export function setVerbose(value: boolean): void;
9
+ export function log(...args: any[]): void;
10
+ export function group(label?: string): void;
11
+ export function groupEnd(): void;
12
+
13
+ export function configureLiquid(options: LiquidConfig): void;
14
+ export function getLiquidEngine(options?: Record<string, any>): Liquid;
15
+ export function registerLiquidComponent(key: string, contents: string): void;
16
+
17
+ export function createBindIncludeTag(liquidEngine: Liquid): {
18
+ parse(tagToken: any): void;
19
+ render(context: any): Promise<string>;
20
+ };
21
+
22
+ export function registerCustomFilter(
23
+ name: string,
24
+ fn: (...args: any[]) => any,
25
+ ): void;
26
+ export function registerCustomShortcode(
27
+ name: string,
28
+ fn: (...args: any[]) => any,
29
+ ): void;
30
+ export function registerCustomPairedShortcode(
31
+ name: string,
32
+ fn: (...args: any[]) => any,
33
+ ): void;
34
+ export function registerCustomTag(
35
+ name: string,
36
+ factory: (liquidEngine: Liquid) => any,
37
+ ): void;
38
+ }
39
+
40
+ /** Window globals used by the liquid integration */
41
+ declare global {
42
+ interface Window {
43
+ /** Registered liquid components keyed by name */
44
+ cc_components?: Record<
45
+ string,
46
+ (props: Record<string, any>) => Promise<HTMLElement>
47
+ >;
48
+ /** Liquid template files keyed by path */
49
+ cc_files?: Record<string, string>;
50
+ }
51
+ }
52
+
53
+ export {};