@cloudcannon/editable-regions 0.0.17 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/helpers/checks.ts +0 -22
  2. package/helpers/cloudcannon.mjs +7 -22
  3. package/helpers/hydrate-editable-regions.ts +2 -1
  4. package/integrations/astro/astro-integration.mjs +1 -4
  5. package/integrations/astro/index.mjs +15 -41
  6. package/integrations/astro/modules/assets.js +1 -4
  7. package/integrations/astro/modules/content.js +2 -11
  8. package/integrations/astro/react-renderer.mjs +98 -24
  9. package/integrations/astro/svelte-renderer.mjs +72 -15
  10. package/integrations/astro/vue-renderer.mjs +61 -0
  11. package/integrations/eleventy/browser/collect-config.mjs +249 -0
  12. package/integrations/eleventy/browser/index.mjs +9 -0
  13. package/integrations/eleventy/browser/inert.mjs +35 -0
  14. package/integrations/eleventy/browser/liquid-builtins.mjs +306 -0
  15. package/integrations/eleventy/browser/liquid-render.mjs +216 -0
  16. package/integrations/eleventy/browser/process-shim.mjs +32 -0
  17. package/integrations/eleventy/browser/stub-mode.mjs +61 -0
  18. package/integrations/eleventy/index.cjs +28 -0
  19. package/integrations/eleventy/index.mjs +634 -0
  20. package/integrations/liquid/README.md +677 -0
  21. package/integrations/liquid/errors.mjs +41 -0
  22. package/integrations/liquid/fs.mjs +36 -75
  23. package/integrations/liquid/globals.mjs +308 -0
  24. package/integrations/liquid/include-with-tag.mjs +84 -0
  25. package/integrations/liquid/index.mjs +166 -170
  26. package/integrations/liquid/logger.mjs +15 -78
  27. package/integrations/liquid/page-map.mjs +32 -0
  28. package/integrations/liquid/shortcodes.mjs +62 -99
  29. package/integrations/react.mjs +5 -9
  30. package/integrations/vue.mjs +28 -0
  31. package/nodes/editable-array-item.ts +6 -1
  32. package/nodes/editable-component.ts +2 -3
  33. package/nodes/editable-text.ts +6 -0
  34. package/nodes/editable.ts +6 -1
  35. package/package.json +120 -79
  36. package/types/astro.d.ts +4 -0
  37. package/types/eleventy.d.cts +20 -0
  38. package/types/eleventy.d.ts +88 -0
  39. package/types/liquid.d.ts +14 -12
  40. package/types/vue.d.ts +40 -0
  41. package/integrations/eleventy.mjs +0 -294
  42. package/integrations/liquid/11ty-filters.mjs +0 -69
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Auto-mirrors an Eleventy config's helpers into the live-editing engine. The
3
+ * bundle imports the user's *real* config (so closures and imports survive,
4
+ * unlike `fn.toString()`) and replays it here against a recording stand-in for
5
+ * `eleventyConfig`, capturing every `addFilter`/`addShortcode`/etc. call.
6
+ *
7
+ * Node/build-time APIs the config imports are stubbed at bundle time (see
8
+ * `../index.mjs`), so importing them is harmless; only a helper that invokes
9
+ * one at render time fails.
10
+ *
11
+ * @typedef {"filters" | "shortcodes" | "pairedShortcodes" | "tags"} HelperKind
12
+ */
13
+
14
+ import {
15
+ registerCustomTag,
16
+ registerFilter,
17
+ registerPairedShortcode,
18
+ registerShortcode,
19
+ } from "../../liquid/index.mjs";
20
+ import { warnOnce } from "../../liquid/logger.mjs";
21
+ import { createInertValue } from "./inert.mjs";
22
+ import {
23
+ builtinFilterNames,
24
+ builtinShortcodeNames,
25
+ } from "./liquid-builtins.mjs";
26
+ import { setStubsStrict } from "./stub-mode.mjs";
27
+
28
+ /** @type {Record<HelperKind, (name: string, fn: any) => void>} */
29
+ const KIND_REGISTRARS = {
30
+ filters: registerFilter,
31
+ shortcodes: registerShortcode,
32
+ pairedShortcodes: registerPairedShortcode,
33
+ tags: registerCustomTag,
34
+ };
35
+
36
+ /**
37
+ * Maps each 11ty registration method to its `[kind, layer]`. Universal and
38
+ * Liquid-specific siblings feed the same kind; the Liquid layer wins on a
39
+ * collision, mirroring 11ty's `{ ...universal, ...liquid }` precedence.
40
+ * Variants for other engines (JS/Handlebars/Nunjucks) aren't mirrored.
41
+ *
42
+ * @type {Record<string, [HelperKind, "universal" | "liquid"]>}
43
+ */
44
+ const METHOD_TARGETS = {
45
+ addFilter: ["filters", "universal"],
46
+ addAsyncFilter: ["filters", "universal"],
47
+ addLiquidFilter: ["filters", "liquid"],
48
+ addShortcode: ["shortcodes", "universal"],
49
+ addAsyncShortcode: ["shortcodes", "universal"],
50
+ addLiquidShortcode: ["shortcodes", "liquid"],
51
+ addPairedShortcode: ["pairedShortcodes", "universal"],
52
+ addPairedAsyncShortcode: ["pairedShortcodes", "universal"],
53
+ addPairedLiquidShortcode: ["pairedShortcodes", "liquid"],
54
+ addLiquidTag: ["tags", "liquid"],
55
+ };
56
+
57
+ /**
58
+ * Eleventy accepts a plugin as either a config function directly or as a
59
+ * `{ configFunction, ... }` object. Returns the underlying function, or `null`
60
+ * if it's neither (so the caller can skip it).
61
+ *
62
+ * @param {any} plugin
63
+ * @returns {((config: any, opts: any) => any) | null} Async plugins return a
64
+ * promise the caller must await.
65
+ */
66
+ function resolvePluginFunction(plugin) {
67
+ if (typeof plugin === "function") return plugin;
68
+ if (typeof plugin?.configFunction === "function")
69
+ return plugin.configFunction;
70
+ return null;
71
+ }
72
+
73
+ /** @returns {Record<HelperKind, Map<string, any>>} */
74
+ function createEmptyLayer() {
75
+ return {
76
+ filters: new Map(),
77
+ shortcodes: new Map(),
78
+ pairedShortcodes: new Map(),
79
+ tags: new Map(),
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Mirrors the config's helpers into the live-editing engine.
85
+ *
86
+ * The mirror is async because configs commonly are — `await
87
+ * import("@11ty/eleventy")` is how a CommonJS config reaches the ESM-only
88
+ * exports, and such a config registers nothing until that settles. The bundle
89
+ * awaits the returned promise before registering anything else.
90
+ *
91
+ * @param {unknown} config - The config's default export (a function), or a
92
+ * module namespace whose `.default` is that function (ESM/CJS interop).
93
+ * @param {{ skip?: Partial<Record<HelperKind, string[]>> }} [options] - Per-kind
94
+ * override names to skip; builtin browser-port names are skipped automatically.
95
+ * @returns {Promise<void>} Resolves once every mirrored helper is registered.
96
+ */
97
+ export function collectAndRegisterEleventyHelpers(config, options = {}) {
98
+ // `finally` so a mirror that failed still leaves render-time stub calls
99
+ // throwing; see `stub-mode.mjs`.
100
+ return mirrorConfig(config, options).finally(setStubsStrict);
101
+ }
102
+
103
+ /**
104
+ * Replays `configFn` against a recording stand-in and registers every
105
+ * collected helper that isn't skipped.
106
+ *
107
+ * @param {unknown} config
108
+ * @param {{ skip?: Partial<Record<HelperKind, string[]>> }} options
109
+ * @returns {Promise<void>}
110
+ */
111
+ async function mirrorConfig(config, options) {
112
+ const configFn =
113
+ typeof config === "function"
114
+ ? config
115
+ : /** @type {any} */ (config)?.default;
116
+
117
+ if (typeof configFn !== "function") {
118
+ warnOnce(
119
+ "eleventy-config-shape",
120
+ "Could not auto-mirror Eleventy config helpers: the config's default " +
121
+ "export isn't a function. Filters/shortcodes defined in the config " +
122
+ "won't be available in live editing.",
123
+ );
124
+ return;
125
+ }
126
+
127
+ // Skip builtin browser-port names (derived from `liquid-builtins.mjs`) so a
128
+ // same-named config helper can't clobber our port, plus caller overrides.
129
+ /** @type {Record<HelperKind, Set<string>>} */
130
+ const skip = {
131
+ filters: new Set([...builtinFilterNames, ...(options.skip?.filters ?? [])]),
132
+ shortcodes: new Set([
133
+ ...builtinShortcodeNames,
134
+ ...(options.skip?.shortcodes ?? []),
135
+ ]),
136
+ pairedShortcodes: new Set(options.skip?.pairedShortcodes ?? []),
137
+ tags: new Set(options.skip?.tags ?? []),
138
+ };
139
+
140
+ const layers = { universal: createEmptyLayer(), liquid: createEmptyLayer() };
141
+
142
+ /** @type {Record<string, any>} */
143
+ const recorder = {};
144
+ for (const [method, [kind, layer]] of Object.entries(METHOD_TARGETS)) {
145
+ recorder[method] = (
146
+ /** @type {string} */ name,
147
+ /** @type {any} */ helperFn,
148
+ ) => {
149
+ if (typeof name === "string" && typeof helperFn === "function") {
150
+ layers[layer][kind].set(name, helperFn);
151
+ }
152
+ };
153
+ }
154
+
155
+ // Unrecorded members are inert so the real config (`addPassthroughCopy`,
156
+ // `on`, `ignores.add(…)`, setting `dir`, ...) doesn't throw. Chainable, so
157
+ // reaching through a property before calling still works.
158
+ const unrecorded = createInertValue();
159
+ const configRecorder = new Proxy(recorder, {
160
+ get(target, prop, receiver) {
161
+ if (prop in target) return Reflect.get(target, prop, receiver);
162
+ // Thenable-looking recorder would hang `pendingPlugins`; see `inert.mjs`.
163
+ if (prop === "then" || typeof prop === "symbol") return undefined;
164
+ return unrecorded;
165
+ },
166
+ });
167
+
168
+ /** Async plugins, drained before the registration pass. @type {Promise<void>[]} */
169
+ const pendingPlugins = [];
170
+
171
+ recorder.addPlugin = (/** @type {any} */ plugin, /** @type {any} */ opts) => {
172
+ const pluginFn = resolvePluginFunction(plugin);
173
+ if (!pluginFn) return;
174
+
175
+ // A plugin is itself a config function, so replay it against the same
176
+ // recorder to capture the helpers it registers.
177
+ try {
178
+ const result = pluginFn(configRecorder, opts);
179
+ if (typeof result?.then === "function") {
180
+ // `Promise.resolve` normalizes: a native promise is returned as-is,
181
+ // a bare thenable gains `.catch`. Async stubs reject; swallow both.
182
+ pendingPlugins.push(Promise.resolve(result).catch(() => {}));
183
+ }
184
+ } catch {
185
+ // Node-only plugins are stubbed at bundle time and throw when called.
186
+ // Helpers registered before the throw are kept; the config continues.
187
+ }
188
+ };
189
+
190
+ let replayFailed = false;
191
+
192
+ try {
193
+ await configFn(configRecorder);
194
+ // A plugin can register further plugins, so drain until nothing new lands.
195
+ while (pendingPlugins.length > 0) {
196
+ await Promise.all(pendingPlugins.splice(0));
197
+ }
198
+ } catch (err) {
199
+ replayFailed = true;
200
+ warnOnce(
201
+ "eleventy-config-replay",
202
+ "Replaying the Eleventy config to mirror its helpers threw: " +
203
+ `${err instanceof Error ? err.message : err}. Some filters/` +
204
+ "shortcodes may be unavailable in live editing — define a browser " +
205
+ "override via `pluginOptions.liquid.<kind>` for any that are needed.",
206
+ );
207
+ }
208
+
209
+ let mirroredCount = 0;
210
+
211
+ for (const kind of /** @type {HelperKind[]} */ (
212
+ Object.keys(KIND_REGISTRARS)
213
+ )) {
214
+ const register = KIND_REGISTRARS[kind];
215
+ // Liquid layer spread last so it wins on a name collision.
216
+ const merged = new Map([...layers.universal[kind], ...layers.liquid[kind]]);
217
+ mirroredCount += merged.size;
218
+
219
+ for (const [name, helperFn] of merged) {
220
+ if (skip[kind].has(name)) continue;
221
+ try {
222
+ register(name, helperFn);
223
+ } catch (err) {
224
+ warnOnce(
225
+ `eleventy-mirror:${kind}:${name}`,
226
+ `Failed to mirror Eleventy ${kind} "${name}" into live editing: ` +
227
+ `${err instanceof Error ? err.message : err}.`,
228
+ );
229
+ }
230
+ }
231
+ }
232
+
233
+ // An async config mirroring nothing otherwise surfaces as a `strictFilters`
234
+ // "unknown filter" error inside an unrelated template. A replay that threw
235
+ // has already warned, and explains the empty result.
236
+ if (
237
+ mirroredCount === 0 &&
238
+ !replayFailed &&
239
+ configFn.constructor?.name === "AsyncFunction"
240
+ ) {
241
+ warnOnce(
242
+ "eleventy-async-config",
243
+ "Your Eleventy config is async and registered no helpers when replayed " +
244
+ "for live editing. If it defines filters/shortcodes they won't be " +
245
+ "available — define a browser override via " +
246
+ "`pluginOptions.liquid.<kind>` for any that are needed.",
247
+ );
248
+ }
249
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Browser entry for the 11ty ports (`@cloudcannon/editable-regions/eleventy/browser`).
3
+ * The generated bundle imports both helpers from here. Kept as a thin barrel
4
+ * so `collect-config.mjs` can import the builtin name lists from
5
+ * `liquid-builtins.mjs` without the two forming an import cycle.
6
+ */
7
+
8
+ export { collectAndRegisterEleventyHelpers } from "./collect-config.mjs";
9
+ export { registerEleventyBuiltins } from "./liquid-builtins.mjs";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A stand-in that survives whatever a config does to it — property access,
3
+ * calls and `new` all return it again. Used for unrecorded config methods
4
+ * (`collect-config.mjs`) and stubbed Node modules (`stub-mode.mjs`).
5
+ *
6
+ * @returns {any}
7
+ */
8
+ export function createInertValue() {
9
+ // `function`, not an arrow: arrows have no [[Construct]], so `new inert()`
10
+ // would throw before the trap runs.
11
+ const handler = {
12
+ /** @param {any} _target @param {string | symbol} prop */
13
+ get(_target, prop) {
14
+ // Must not look thenable: `collect-config.mjs` treats a plugin result
15
+ // with a callable `.then` as a promise, and this one would never
16
+ // settle — hanging the mirror, so no component is ever published.
17
+ if (prop === "then") return undefined;
18
+
19
+ if (typeof prop === "symbol") {
20
+ // Else `String(inert)` walks toPrimitive → valueOf → toString,
21
+ // gets a proxy from each, and throws.
22
+ if (prop === Symbol.toPrimitive) return () => "";
23
+ // Else `for…of` / spread over a stubbed call throws.
24
+ if (prop === Symbol.iterator) return function* () {};
25
+ return undefined;
26
+ }
27
+ return inert;
28
+ },
29
+ apply: () => inert,
30
+ construct: () => inert,
31
+ };
32
+
33
+ const inert = new Proxy(function () {}, handler);
34
+ return inert;
35
+ }
@@ -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
+ }