@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.
- package/integrations/astro/modules/content.js +2 -2
- package/integrations/eleventy.mjs +294 -0
- package/integrations/liquid/11ty-filters.mjs +69 -0
- package/integrations/liquid/fs.mjs +127 -0
- package/integrations/liquid/index.mjs +269 -0
- package/integrations/liquid/logger.mjs +108 -0
- package/integrations/liquid/shortcodes.mjs +165 -0
- package/nodes/editable-array-item.ts +11 -0
- package/nodes/editable-image.ts +10 -2
- package/nodes/editable-text.ts +5 -0
- package/nodes/editable.ts +37 -3
- package/package.json +15 -7
- package/types/liquid.d.ts +53 -0
|
@@ -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
|
}
|
package/nodes/editable-image.ts
CHANGED
|
@@ -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
|
}
|
package/nodes/editable-text.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
57
|
-
"@cloudcannon/javascript-api": "0.0.
|
|
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/
|
|
60
|
-
"@types/react
|
|
61
|
-
"
|
|
62
|
-
"
|
|
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 {};
|