@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.
- package/helpers/checks.ts +0 -22
- package/helpers/cloudcannon.mjs +7 -22
- package/helpers/hydrate-editable-regions.ts +2 -1
- package/integrations/astro/astro-integration.mjs +1 -4
- package/integrations/astro/index.mjs +15 -41
- package/integrations/astro/modules/assets.js +1 -4
- package/integrations/astro/modules/content.js +2 -11
- package/integrations/astro/react-renderer.mjs +98 -24
- package/integrations/astro/svelte-renderer.mjs +72 -15
- package/integrations/astro/vue-renderer.mjs +61 -0
- package/integrations/eleventy/browser/collect-config.mjs +249 -0
- package/integrations/eleventy/browser/index.mjs +9 -0
- package/integrations/eleventy/browser/inert.mjs +35 -0
- package/integrations/eleventy/browser/liquid-builtins.mjs +306 -0
- package/integrations/eleventy/browser/liquid-render.mjs +216 -0
- package/integrations/eleventy/browser/process-shim.mjs +32 -0
- package/integrations/eleventy/browser/stub-mode.mjs +61 -0
- package/integrations/eleventy/index.cjs +28 -0
- package/integrations/eleventy/index.mjs +634 -0
- package/integrations/liquid/README.md +677 -0
- package/integrations/liquid/errors.mjs +41 -0
- package/integrations/liquid/fs.mjs +36 -75
- package/integrations/liquid/globals.mjs +308 -0
- package/integrations/liquid/include-with-tag.mjs +84 -0
- package/integrations/liquid/index.mjs +166 -170
- package/integrations/liquid/logger.mjs +15 -78
- package/integrations/liquid/page-map.mjs +32 -0
- package/integrations/liquid/shortcodes.mjs +62 -99
- package/integrations/react.mjs +5 -9
- package/integrations/vue.mjs +28 -0
- package/nodes/editable-array-item.ts +6 -1
- package/nodes/editable-component.ts +2 -3
- package/nodes/editable-text.ts +6 -0
- package/nodes/editable.ts +6 -1
- package/package.json +120 -79
- package/types/astro.d.ts +4 -0
- package/types/eleventy.d.cts +20 -0
- package/types/eleventy.d.ts +88 -0
- package/types/liquid.d.ts +14 -12
- package/types/vue.d.ts +40 -0
- package/integrations/eleventy.mjs +0 -294
- 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
|
+
}
|