@cloudcannon/editable-regions 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,306 @@
1
+ // Browser ports of Eleventy's built-in filters and shortcodes. Filters needing
2
+ // build-time internals are warn-once pass-through stubs so templates keep
3
+ // rendering. `registerEleventyBuiltins(engine)` wires everything up.
4
+
5
+ import sindresorhusSlugify from "@sindresorhus/slugify";
6
+ import simovSlugify from "slugify";
7
+ import { warnOnce } from "../../liquid/logger.mjs";
8
+ import { getPageMap, normalizeInputPath } from "../../liquid/page-map.mjs";
9
+ import { createShortcodeTag } from "../../liquid/shortcodes.mjs";
10
+ import {
11
+ createRenderContentFilter,
12
+ createRenderFileShortcode,
13
+ createRenderTemplateTag,
14
+ } from "./liquid-render.mjs";
15
+
16
+ /**
17
+ * @param {any} value
18
+ * @param {string} [prefix]
19
+ */
20
+ export function logFilter(value, prefix = "") {
21
+ if (prefix) {
22
+ console.log(`[${prefix}]`, value);
23
+ } else {
24
+ console.log(value);
25
+ }
26
+ return value;
27
+ }
28
+
29
+ /**
30
+ * Eleventy's `slug` filter (`Filters/Slug.js`), backed by `simov/slugify`
31
+ * (permissive: keeps `+`, `@`, `.`; substitutes `&`→`and`, `%`→`percent`).
32
+ *
33
+ * @param {unknown} str
34
+ * @param {Record<string, any>} [options]
35
+ */
36
+ export function slugFilter(str, options = {}) {
37
+ return simovSlugify(`${str}`, { replacement: "-", lower: true, ...options });
38
+ }
39
+
40
+ /**
41
+ * Eleventy's `slugify` filter (`Filters/Slugify.js`), backed by
42
+ * `@sindresorhus/slugify` (strict ASCII: non-alphanumerics become separators).
43
+ *
44
+ * @param {unknown} str
45
+ * @param {Record<string, any>} [options]
46
+ */
47
+ export function slugifyFilter(str, options = {}) {
48
+ return sindresorhusSlugify(`${str}`, { decamelize: false, ...options });
49
+ }
50
+
51
+ /**
52
+ * Eleventy's `url` filter (`Filters/Url.js`). Absolute and protocol-relative
53
+ * URLs pass through; root-relative URLs get `pathPrefix` prepended when given.
54
+ * Unlike upstream (which always has a `pathPrefix`), the no-prefix branch
55
+ * returns the input unchanged rather than throwing.
56
+ *
57
+ * @param {string} url
58
+ * @param {string} [pathPrefix]
59
+ */
60
+ export function urlFilter(url, pathPrefix = "") {
61
+ if (!url) return "";
62
+
63
+ const urlString = String(url);
64
+ if (isAbsoluteUrl(urlString)) return urlString;
65
+ if (urlString.startsWith("//") && urlString !== "//") return urlString;
66
+ if (!pathPrefix) return urlString;
67
+
68
+ const normalizedPrefix = `/${pathPrefix.replace(/^\/+|\/+$/g, "")}`;
69
+ if (urlString.startsWith("/")) return `${normalizedPrefix}${urlString}`;
70
+
71
+ return urlString;
72
+ }
73
+
74
+ /** Matches Eleventy's `Util/ValidUrl.js`: parseable by `new URL()` → absolute. */
75
+ function isAbsoluteUrl(/** @type {string} */ url) {
76
+ try {
77
+ new URL(url);
78
+ return true;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /** Coerces an input (Date, ISO string, epoch number) into a Date; `null` for unusable input. */
85
+ function toDate(/** @type {any} */ value) {
86
+ if (value instanceof Date) {
87
+ return Number.isNaN(value.getTime()) ? null : value;
88
+ }
89
+
90
+ if (value === null || value === undefined || value === "") return null;
91
+
92
+ const d = new Date(value);
93
+ return Number.isNaN(d.getTime()) ? null : d;
94
+ }
95
+
96
+ /** ISO 8601 / RFC 3339 (e.g. "2026-04-21T00:00:00.000Z"). */
97
+ export function dateToRfc3339(/** @type {Date | string | number} */ date) {
98
+ const d = toDate(date);
99
+
100
+ return d ? d.toISOString() : "";
101
+ }
102
+
103
+ /** RFC 822 / RFC 1123 (e.g. "Tue, 21 Apr 2026 00:00:00 GMT"). */
104
+ export function dateToRfc822(/** @type {Date | string | number} */ date) {
105
+ const d = toDate(date);
106
+
107
+ return d ? d.toUTCString() : "";
108
+ }
109
+
110
+ /** `YYYY-MM-DD` form, used for `<time datetime>` attributes. */
111
+ export function htmlDateString(/** @type {Date | string | number} */ date) {
112
+ const d = toDate(date);
113
+
114
+ return d ? d.toISOString().slice(0, 10) : "";
115
+ }
116
+
117
+ /**
118
+ * @param {Array<{date?: any}>} collection
119
+ * @param {Date | string | number} [emptyFallback]
120
+ */
121
+ export function getNewestCollectionItemDate(collection, emptyFallback) {
122
+ if (!Array.isArray(collection) || collection.length === 0) {
123
+ return toDate(emptyFallback) ?? new Date(0);
124
+ }
125
+
126
+ let newest = 0;
127
+ for (const item of collection) {
128
+ const d = toDate(item?.date);
129
+ if (d && d.getTime() > newest) newest = d.getTime();
130
+ }
131
+
132
+ return newest ? new Date(newest) : (toDate(emptyFallback) ?? new Date(0));
133
+ }
134
+
135
+ /**
136
+ * Index of `page` in `collection` by `inputPath`. The editor doesn't model
137
+ * pagination, so `inputPath` alone is unique (upstream also tie-breaks on
138
+ * `outputPath || url`). `await page.inputPath` handles both shapes: the `page`
139
+ * global is a Promise-returning Proxy; a collection item's is already a string.
140
+ *
141
+ * @param {Array<{inputPath?: string}>} collection
142
+ * @param {any} page
143
+ * @returns {Promise<number>}
144
+ */
145
+ async function indexInCollection(collection, page) {
146
+ if (!Array.isArray(collection)) return -1;
147
+
148
+ if (!page) {
149
+ warnOnce(
150
+ "collection-item-no-page",
151
+ "Eleventy collection-item filter called without a `page` argument. " +
152
+ "In live editing, pass the page/item explicitly (e.g. `collections.posts | getCollectionItem: page`).",
153
+ );
154
+
155
+ return -1;
156
+ }
157
+
158
+ const inputPath = await page.inputPath;
159
+ if (typeof inputPath !== "string" || !inputPath) return -1;
160
+
161
+ return collection.findIndex((item) => item?.inputPath === inputPath);
162
+ }
163
+
164
+ export async function getCollectionItem(
165
+ /** @type {any[]} */ collection,
166
+ /** @type {any} */ page,
167
+ ) {
168
+ const i = await indexInCollection(collection, page);
169
+
170
+ return i >= 0 ? collection[i] : undefined;
171
+ }
172
+
173
+ export async function getPreviousCollectionItem(
174
+ /** @type {any[]} */ collection,
175
+ /** @type {any} */ page,
176
+ ) {
177
+ const i = await indexInCollection(collection, page);
178
+
179
+ return i > 0 ? collection[i - 1] : undefined;
180
+ }
181
+
182
+ export async function getNextCollectionItem(
183
+ /** @type {any[]} */ collection,
184
+ /** @type {any} */ page,
185
+ ) {
186
+ const i = await indexInCollection(collection, page);
187
+
188
+ return i >= 0 && i < collection.length - 1 ? collection[i + 1] : undefined;
189
+ }
190
+
191
+ export async function getCollectionItemIndex(
192
+ /** @type {any[]} */ collection,
193
+ /** @type {any} */ page,
194
+ ) {
195
+ return indexInCollection(collection, page);
196
+ }
197
+
198
+ /**
199
+ * Pass-through filter that warns once — for Eleventy filters that depend on
200
+ * build-time internals we don't have in the browser.
201
+ *
202
+ * @param {string} filterName
203
+ * @param {string} reason
204
+ */
205
+ function passThroughStub(filterName, reason) {
206
+ return (/** @type {any} */ value) => {
207
+ warnOnce(
208
+ `filter-stub:${filterName}`,
209
+ `Eleventy filter "${filterName}" is not supported in live editing (${reason}). ` +
210
+ "Returning the input unchanged.",
211
+ );
212
+
213
+ return value;
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Browser port of the `inputPathToUrl` plugin filter, resolving against the
219
+ * build-time page map. Misses (e.g. a file not in the last build) warn-once
220
+ * and pass through rather than throwing.
221
+ *
222
+ * @param {unknown} inputPath
223
+ */
224
+ export function inputPathToUrlFilter(inputPath) {
225
+ if (typeof inputPath !== "string" || !inputPath) {
226
+ return typeof inputPath === "string" ? inputPath : "";
227
+ }
228
+
229
+ const entry = getPageMap()[normalizeInputPath(inputPath)];
230
+ if (entry?.url) return entry.url;
231
+
232
+ warnOnce(
233
+ `input-path-to-url-miss:${inputPath}`,
234
+ `inputPathToUrl: no build-time URL recorded for "${inputPath}". ` +
235
+ "This usually means the file wasn't in the last build. Returning the " +
236
+ "input unchanged.",
237
+ );
238
+
239
+ return inputPath;
240
+ }
241
+
242
+ /** @type {Record<string, any>} */
243
+ export const eleventyFilters = {
244
+ slug: slugFilter,
245
+ slugify: slugifyFilter,
246
+ log: logFilter,
247
+ url: urlFilter,
248
+ dateToRfc3339,
249
+ dateToRfc822,
250
+ htmlDateString,
251
+ getNewestCollectionItemDate,
252
+ getCollectionItem,
253
+ getPreviousCollectionItem,
254
+ getNextCollectionItem,
255
+ getCollectionItemIndex,
256
+ inputPathToUrl: inputPathToUrlFilter,
257
+ htmlBaseUrl: passThroughStub(
258
+ "htmlBaseUrl",
259
+ "it requires Eleventy's pathPrefix/HTML base config",
260
+ ),
261
+ serverlessUrl: passThroughStub(
262
+ "serverlessUrl",
263
+ "serverless routing is a build-time concept",
264
+ ),
265
+ };
266
+
267
+ /**
268
+ * Builtin names the config auto-mirror skips, so a same-named config helper
269
+ * doesn't clobber our port. Derived from the implementations so they can't
270
+ * drift; `renderContent`/`renderFile` are the RenderPlugin shims.
271
+ *
272
+ * @type {string[]}
273
+ */
274
+ export const builtinFilterNames = [
275
+ ...Object.keys(eleventyFilters),
276
+ "renderContent",
277
+ ];
278
+
279
+ /** @type {string[]} */
280
+ export const builtinShortcodeNames = ["renderFile"];
281
+
282
+ /**
283
+ * Wires `eleventyFilters` plus the RenderPlugin shims onto the shared engine.
284
+ *
285
+ * @param {import("liquidjs").Liquid} liquidEngine
286
+ */
287
+ export function registerEleventyBuiltins(liquidEngine) {
288
+ for (const [name, fn] of Object.entries(eleventyFilters)) {
289
+ liquidEngine.registerFilter(name, fn);
290
+ }
291
+
292
+ liquidEngine.registerTag(
293
+ "renderTemplate",
294
+ createRenderTemplateTag(liquidEngine),
295
+ );
296
+
297
+ liquidEngine.registerTag(
298
+ "renderFile",
299
+ createShortcodeTag("renderFile", createRenderFileShortcode(liquidEngine)),
300
+ );
301
+
302
+ liquidEngine.registerFilter(
303
+ "renderContent",
304
+ createRenderContentFilter(liquidEngine),
305
+ );
306
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Browser shims for Eleventy's RenderPlugin
3
+ * (https://v3.11ty.dev/docs/plugins/render/): `renderTemplate` (paired tag),
4
+ * `renderContent` (filter), `renderFile` (shortcode). Each compiles a body as
5
+ * `templateLang` and renders it with `data`. Only LiquidJS runs in the browser,
6
+ * so: "liquid"/unspecified → real render; "html" → passthrough; else →
7
+ * warn-once and return the body unchanged.
8
+ */
9
+
10
+ import {
11
+ apiLoadedPromise,
12
+ CloudCannon,
13
+ } from "../../../helpers/cloudcannon.mjs";
14
+ import { warnOnce } from "../../liquid/logger.mjs";
15
+ import { evaluateArgs, parseArgs } from "../../liquid/shortcodes.mjs";
16
+
17
+ const supportedEngines = new Set(["liquid", "html"]);
18
+
19
+ /**
20
+ * Tag factory for `{% renderTemplate ... %}…{% endrenderTemplate %}`. Captures
21
+ * the body as raw source (matching upstream — compiled in the requested engine,
22
+ * not pre-rendered) and renders it against `data`.
23
+ *
24
+ * @param {any} _liquidEngine - Unused; reached via `this.liquid`
25
+ * @returns {any}
26
+ */
27
+ export function createRenderTemplateTag(_liquidEngine) {
28
+ return {
29
+ parse(/** @type {any} */ tagToken, /** @type {any[]} */ remainTokens) {
30
+ this.name = tagToken.name;
31
+ this.argTokens = parseArgs(
32
+ tagToken.args,
33
+ this.liquid.options.operatorsTrie,
34
+ );
35
+ this.bodyTokens = [];
36
+
37
+ const endTagName = `end${this.name}`;
38
+ while (remainTokens.length) {
39
+ const token = remainTokens.shift();
40
+ if (token.name === endTagName) return;
41
+
42
+ this.bodyTokens.push(token);
43
+ }
44
+
45
+ throw new Error(`tag ${this.name} not closed`);
46
+ },
47
+
48
+ async render(/** @type {any} */ context) {
49
+ const args = await evaluateArgs(this.argTokens, context);
50
+ const { templateLang, data } = normalizeRenderArgs([args[0], args[1]]);
51
+ const body = this.bodyTokens
52
+ .map((/** @type {any} */ t) => t.getText())
53
+ .join("");
54
+
55
+ if (templateLang && !supportedEngines.has(templateLang)) {
56
+ warnOnce(
57
+ `render-template:${templateLang}`,
58
+ unsupportedEngineMessage(templateLang),
59
+ );
60
+ return body;
61
+ }
62
+
63
+ if (templateLang === "html") return body;
64
+ return await this.liquid.parseAndRender(body, data);
65
+ },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Builds the `renderContent` filter, capturing the shared engine.
71
+ *
72
+ * @param {any} liquidEngine
73
+ */
74
+ export function createRenderContentFilter(liquidEngine) {
75
+ return async function renderContent(
76
+ /** @type {any} */ content,
77
+ /** @type {any} */ templateLang,
78
+ /** @type {any} */ data,
79
+ ) {
80
+ const normalized = normalizeRenderArgs([templateLang, data]);
81
+ const body = content == null ? "" : String(content);
82
+
83
+ if (
84
+ normalized.templateLang &&
85
+ !supportedEngines.has(normalized.templateLang)
86
+ ) {
87
+ warnOnce(
88
+ `render-content:${normalized.templateLang}`,
89
+ unsupportedEngineMessage(normalized.templateLang),
90
+ );
91
+
92
+ return body;
93
+ }
94
+
95
+ if (normalized.templateLang === "html") return body;
96
+
97
+ return await liquidEngine.parseAndRender(body, normalized.data);
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Builds the `renderFile` shortcode. Fetches `inputPath` via the CloudCannon
103
+ * API and renders its body with `data`. Engine comes from `templateLang`, else
104
+ * inferred from the file extension.
105
+ *
106
+ * @param {any} liquidEngine
107
+ */
108
+ export function createRenderFileShortcode(liquidEngine) {
109
+ return async function renderFile(
110
+ /** @type {any} */ inputPath,
111
+ /** @type {any} */ data,
112
+ /** @type {any} */ templateLang,
113
+ ) {
114
+ if (typeof inputPath !== "string" || !inputPath) {
115
+ warnOnce(
116
+ "render-file:no-path",
117
+ "renderFile: missing or non-string path argument. Returning empty.",
118
+ );
119
+
120
+ return "";
121
+ }
122
+
123
+ // Normalise Eleventy-style paths (`./foo`, `/foo`) to the
124
+ // project-relative shape the CC API expects.
125
+ const normalizedPath = inputPath.replace(/^\.\/+/, "").replace(/^\/+/, "");
126
+
127
+ await apiLoadedPromise;
128
+
129
+ const file = CloudCannon?.file?.(normalizedPath);
130
+ if (!file) {
131
+ warnOnce(
132
+ `render-file-missing:${inputPath}`,
133
+ `renderFile: CloudCannon API not available; cannot load "${inputPath}".`,
134
+ );
135
+ return "";
136
+ }
137
+
138
+ // Mirror 11ty's data cascade: the file's own front matter is the base,
139
+ // the caller's `data` arg overrides on top. `content.get()` strips it.
140
+ let body;
141
+ let frontMatter;
142
+
143
+ try {
144
+ [body, frontMatter] = await Promise.all([
145
+ file.content.get(),
146
+ file.data.get(),
147
+ ]);
148
+ } catch (err) {
149
+ warnOnce(
150
+ `render-file-missing:${inputPath}`,
151
+ `renderFile: failed to load "${inputPath}" via the CloudCannon API ` +
152
+ `(${err instanceof Error ? err.message : String(err)}).`,
153
+ );
154
+
155
+ return "";
156
+ }
157
+
158
+ const engine =
159
+ (typeof templateLang === "string" && templateLang) ||
160
+ inferEngineFromPath(inputPath);
161
+
162
+ if (engine && !supportedEngines.has(engine)) {
163
+ warnOnce(`render-file:${engine}`, unsupportedEngineMessage(engine));
164
+
165
+ return body;
166
+ }
167
+
168
+ if (engine === "html") return body;
169
+
170
+ const mergedData = { ...(frontMatter ?? {}), ...(data ?? {}) };
171
+
172
+ return await liquidEngine.parseAndRender(body, mergedData);
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Normalises the `(templateLang, data)` pair, supporting the `(lang, data)`
178
+ * and lang-omitted `(data)` overloads.
179
+ *
180
+ * @param {[any, any]} args
181
+ * @returns {{templateLang: string | undefined, data: any}}
182
+ */
183
+ function normalizeRenderArgs([templateLang, data]) {
184
+ if (templateLang && typeof templateLang !== "string") {
185
+ data = templateLang;
186
+ templateLang = undefined;
187
+ }
188
+
189
+ return { templateLang, data: data ?? {} };
190
+ }
191
+
192
+ /**
193
+ * Guesses the engine from the extension so the warn-once message can name it;
194
+ * only "liquid"/"html" actually render, the rest fall through to passthrough.
195
+ *
196
+ * @param {string} inputPath
197
+ */
198
+ function inferEngineFromPath(inputPath) {
199
+ const dot = inputPath.lastIndexOf(".");
200
+ if (dot < 0) return undefined;
201
+
202
+ const ext = inputPath.slice(dot + 1).toLowerCase();
203
+ if (ext === "liquid") return "liquid";
204
+ if (ext === "html" || ext === "htm") return "html";
205
+ if (ext === "md") return "md";
206
+ if (ext === "njk") return "njk";
207
+ return undefined;
208
+ }
209
+
210
+ function unsupportedEngineMessage(/** @type {string} */ engineName) {
211
+ return (
212
+ `Eleventy RenderPlugin: engine "${engineName}" is not supported in ` +
213
+ `live editing (only "liquid" and "html" run in the browser). ` +
214
+ "Returning the body unchanged."
215
+ );
216
+ }
@@ -0,0 +1 @@
1
+ module.exports = require("./index.mjs").default;