@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,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rewrites a LiquidJS error into an actionable message for the error card.
|
|
3
|
+
*
|
|
4
|
+
* @param {unknown} err
|
|
5
|
+
* @param {string} componentName
|
|
6
|
+
* @returns {Error}
|
|
7
|
+
*/
|
|
8
|
+
export function enhanceLiquidError(err, componentName) {
|
|
9
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
10
|
+
|
|
11
|
+
// LiquidJS appends its own position suffix ("undefined filter: foo, line:2,
|
|
12
|
+
// col:1"), so stop at the comma rather than at whitespace.
|
|
13
|
+
const unknownFilter = message.match(/undefined filter[:.]?\s*([^\s,]+)/i);
|
|
14
|
+
if (unknownFilter) {
|
|
15
|
+
const filterName = unknownFilter[1];
|
|
16
|
+
return new Error(
|
|
17
|
+
`Unknown filter "${filterName}" while rendering "${componentName}". ` +
|
|
18
|
+
`Please check your config and make sure you have registered "${filterName}" in the filters option.`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const missingTemplate = message.match(/ENOENT.*?"([^"]+)"/);
|
|
23
|
+
if (missingTemplate) {
|
|
24
|
+
const filePath = missingTemplate[1];
|
|
25
|
+
return new Error(
|
|
26
|
+
`Failed to find included template "${filePath}" while rendering "${componentName}". ` +
|
|
27
|
+
`Please check that the file exists and is within your configured component directories.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const missingTag = message.match(/tag "?(\S+?)"? not found/i);
|
|
32
|
+
if (missingTag) {
|
|
33
|
+
const tagName = missingTag[1];
|
|
34
|
+
return new Error(
|
|
35
|
+
`Unknown tag "${tagName}" while rendering "${componentName}". ` +
|
|
36
|
+
`Please check your config and make sure you have registered "${tagName}" in the tags, shortcodes, or pairedShortcodes option.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return new Error(`Error rendering "${componentName}": ${message}`);
|
|
41
|
+
}
|
|
@@ -1,127 +1,88 @@
|
|
|
1
1
|
import { log, warn } from "./logger.mjs";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
* Matches `path.extname`: last dot of the final segment, leading dot excluded.
|
|
5
|
+
*/
|
|
6
|
+
function hasExtension(/** @type {string} */ filePath) {
|
|
7
|
+
const basename = filePath.slice(filePath.lastIndexOf("/") + 1);
|
|
8
|
+
return basename.lastIndexOf(".") > 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* In-memory filesystem for LiquidJS, reading from `window.cc_liquid_files`.
|
|
13
|
+
* @type {any}
|
|
7
14
|
*/
|
|
8
15
|
export const inMemoryFs = {
|
|
9
16
|
sep: "/",
|
|
10
17
|
|
|
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
|
+
dirname(/** @type {string} */ filePath) {
|
|
18
19
|
const parts = filePath.split("/");
|
|
19
20
|
parts.pop();
|
|
20
21
|
return parts.join("/") || "/";
|
|
21
22
|
},
|
|
22
23
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
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];
|
|
24
|
+
readFileSync(/** @type {string} */ filePath) {
|
|
25
|
+
const fileContents = window.cc_liquid_files?.[filePath];
|
|
32
26
|
|
|
33
|
-
if (fileContents === undefined) {
|
|
34
|
-
const availableFiles = Object.keys(window.
|
|
27
|
+
if (fileContents === undefined || fileContents === null) {
|
|
28
|
+
const availableFiles = Object.keys(window.cc_liquid_files || {});
|
|
35
29
|
warn("File not found:", filePath);
|
|
36
30
|
log("Available files:", availableFiles);
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
throw new Error(
|
|
32
|
+
`ENOENT: Failed to find "${filePath}" in the bundled template files. Please check that this file exists and is within your configured component directories.`,
|
|
33
|
+
);
|
|
39
34
|
}
|
|
40
35
|
|
|
41
36
|
return fileContents;
|
|
42
37
|
},
|
|
43
38
|
|
|
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);
|
|
39
|
+
async readFile(/** @type {string} */ filePath) {
|
|
53
40
|
if (!filePath) {
|
|
54
41
|
throw new Error("readFile called with empty path");
|
|
55
42
|
}
|
|
56
43
|
return this.readFileSync(filePath);
|
|
57
44
|
},
|
|
58
45
|
|
|
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) {
|
|
46
|
+
existsSync(/** @type {string} */ filePath) {
|
|
66
47
|
if (!filePath || typeof filePath !== "string") {
|
|
67
|
-
log("exists: invalid path", filePath);
|
|
68
48
|
return false;
|
|
69
49
|
}
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
return result;
|
|
50
|
+
const fileContents = window.cc_liquid_files?.[filePath];
|
|
51
|
+
return fileContents !== null && fileContents !== undefined;
|
|
73
52
|
},
|
|
74
53
|
|
|
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) {
|
|
54
|
+
async exists(/** @type {string} */ filePath) {
|
|
82
55
|
if (!filePath || typeof filePath !== "string") {
|
|
83
56
|
return false;
|
|
84
57
|
}
|
|
85
|
-
|
|
86
|
-
const exists = fileContents !== null && fileContents !== undefined;
|
|
87
|
-
log("existsSync:", filePath, "=", exists);
|
|
88
|
-
return exists;
|
|
58
|
+
return this.existsSync(filePath);
|
|
89
59
|
},
|
|
90
60
|
|
|
91
61
|
/**
|
|
92
|
-
*
|
|
93
|
-
* LiquidJS calls this once per root directory and checks exists() on the
|
|
62
|
+
* LiquidJS calls this once per root directory and checks `exists()` on the
|
|
94
63
|
* 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
64
|
*/
|
|
101
|
-
resolve(
|
|
65
|
+
resolve(
|
|
66
|
+
/** @type {string} */ root,
|
|
67
|
+
/** @type {string} */ file,
|
|
68
|
+
/** @type {string} */ ext,
|
|
69
|
+
) {
|
|
102
70
|
const extension = ext || ".liquid";
|
|
103
|
-
|
|
71
|
+
// Only append when the file has none, as LiquidJS's Node fs does —
|
|
72
|
+
// otherwise `include "card.html"` becomes `card.html.liquid`.
|
|
73
|
+
const fileWithExt = hasExtension(file) ? file : `${file}${extension}`;
|
|
104
74
|
const normalizedRoot = root.replace(/^\.\//, "").replace(/\/*$/, "/");
|
|
105
75
|
const resolved = `${normalizedRoot}${fileWithExt}`;
|
|
106
76
|
log("resolve:", { root, file, ext }, "->", resolved);
|
|
107
77
|
return resolved;
|
|
108
78
|
},
|
|
109
79
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
*
|
|
113
|
-
* @returns {Promise<{isFile: () => boolean}>}
|
|
114
|
-
*/
|
|
115
|
-
async statAsync() {
|
|
80
|
+
// The store is flat, so anything stat'd is a file.
|
|
81
|
+
statSync() {
|
|
116
82
|
return { isFile: () => true };
|
|
117
83
|
},
|
|
118
84
|
|
|
119
|
-
|
|
120
|
-
* Returns file stat synchronously (always returns isFile: true for compatibility).
|
|
121
|
-
*
|
|
122
|
-
* @returns {{isFile: () => boolean}}
|
|
123
|
-
*/
|
|
124
|
-
statSync() {
|
|
85
|
+
async statAsync() {
|
|
125
86
|
return { isFile: () => true };
|
|
126
87
|
},
|
|
127
88
|
};
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Builders for the `page` and `collections` globals on the shared Liquid
|
|
2
|
+
// engine. Both return Promises that LiquidJS awaits at the globals level.
|
|
3
|
+
// `page` resolves to a plain object; `collections` resolves to an object whose
|
|
4
|
+
// keys are lazy getters, so a template only pays for the collections it reads.
|
|
5
|
+
|
|
6
|
+
import { apiLoadedPromise, CloudCannon } from "../../helpers/cloudcannon.mjs";
|
|
7
|
+
import { getPageMap, normalizeInputPath } from "./page-map.mjs";
|
|
8
|
+
|
|
9
|
+
/** @type {{ directories?: { output?: string } } | null} */
|
|
10
|
+
let eleventyData = null;
|
|
11
|
+
|
|
12
|
+
/** @param {{ directories?: { output?: string } } | null} data */
|
|
13
|
+
export function setEleventyData(data) {
|
|
14
|
+
eleventyData = data;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Strips the file extension from a path. */
|
|
18
|
+
function stripExtension(/** @type {string} */ p) {
|
|
19
|
+
return p.replace(/\.[^./]+$/, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 11ty's folder-style permalink: trailing-slash URL, `index` files mapping to
|
|
24
|
+
* the parent dir. Last-resort fallback when neither a literal front-matter
|
|
25
|
+
* `permalink` nor the page map resolves a URL.
|
|
26
|
+
*/
|
|
27
|
+
function deriveDefaultUrl(/** @type {string} */ inputPath) {
|
|
28
|
+
const stem = stripExtension(inputPath).replace(/^\.?\//, "/");
|
|
29
|
+
const withLeadingSlash = stem.startsWith("/") ? stem : `/${stem}`;
|
|
30
|
+
const withoutIndex = withLeadingSlash.replace(/\/index$/, "/");
|
|
31
|
+
return withoutIndex.endsWith("/") ? withoutIndex : `${withoutIndex}/`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A front-matter `permalink` usable verbatim: a plain string with no Liquid
|
|
36
|
+
* templating. Templated permalinks (e.g. `"/{{ page.date }}/"`) need the full
|
|
37
|
+
* build context to render, so we return `undefined` and let the caller fall
|
|
38
|
+
* back to the page map's already-resolved value.
|
|
39
|
+
*/
|
|
40
|
+
function literalPermalink(
|
|
41
|
+
/** @type {Record<string, any> | null | undefined} */ data,
|
|
42
|
+
) {
|
|
43
|
+
const permalink = data?.permalink;
|
|
44
|
+
if (typeof permalink !== "string") return undefined;
|
|
45
|
+
if (permalink.includes("{{") || permalink.includes("{%")) return undefined;
|
|
46
|
+
return permalink;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolves the URL for an input file, in priority order: literal front-matter
|
|
51
|
+
* `permalink` (so editor edits show before a rebuild) → build-time page map →
|
|
52
|
+
* 11ty's folder-style default.
|
|
53
|
+
*/
|
|
54
|
+
function resolveUrl(
|
|
55
|
+
/** @type {Record<string, any> | null | undefined} */ data,
|
|
56
|
+
/** @type {string} */ inputPath,
|
|
57
|
+
) {
|
|
58
|
+
const permalink = literalPermalink(data);
|
|
59
|
+
if (permalink) return permalink;
|
|
60
|
+
const mapped = getPageMap()[normalizeInputPath(inputPath)];
|
|
61
|
+
if (mapped?.url) return mapped.url;
|
|
62
|
+
return deriveDefaultUrl(inputPath);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Same priority layering as `resolveUrl`, for the output path. */
|
|
66
|
+
function resolveOutputPath(
|
|
67
|
+
/** @type {Record<string, any> | null | undefined} */ data,
|
|
68
|
+
/** @type {string} */ inputPath,
|
|
69
|
+
) {
|
|
70
|
+
const outputDir = eleventyData?.directories?.output;
|
|
71
|
+
const permalink = literalPermalink(data);
|
|
72
|
+
if (permalink) {
|
|
73
|
+
return outputDir ? joinOutputPath(outputDir, permalink) : undefined;
|
|
74
|
+
}
|
|
75
|
+
const mapped = getPageMap()[normalizeInputPath(inputPath)];
|
|
76
|
+
if (mapped?.outputPath) return mapped.outputPath;
|
|
77
|
+
if (!outputDir) return undefined;
|
|
78
|
+
return joinOutputPath(outputDir, deriveDefaultUrl(inputPath));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Basename minus extension. Matches 11ty's `fileSlug` derivation. */
|
|
82
|
+
function deriveFileSlug(/** @type {string} */ inputPath) {
|
|
83
|
+
const base = inputPath.split("/").pop() ?? "";
|
|
84
|
+
return stripExtension(base);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Full path minus extension, with a leading slash. */
|
|
88
|
+
function deriveFilePathStem(/** @type {string} */ inputPath) {
|
|
89
|
+
const stem = stripExtension(inputPath).replace(/^\.?\//, "/");
|
|
90
|
+
return stem.startsWith("/") ? stem : `/${stem}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Coerces a front-matter date value into a Date, or `undefined`. */
|
|
94
|
+
function toDate(/** @type {unknown} */ raw) {
|
|
95
|
+
if (!raw) return undefined;
|
|
96
|
+
const d = new Date(/** @type {any} */ (raw));
|
|
97
|
+
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Joins an output dir and URL the way 11ty does: trailing-slash URLs become
|
|
102
|
+
* `<dir><url>index.html`; others are appended as-is.
|
|
103
|
+
*/
|
|
104
|
+
function joinOutputPath(
|
|
105
|
+
/** @type {string} */ outputDir,
|
|
106
|
+
/** @type {string} */ url,
|
|
107
|
+
) {
|
|
108
|
+
const dir = outputDir.replace(/\/+$/, "");
|
|
109
|
+
const tail = url.endsWith("/") ? `${url}index.html` : url;
|
|
110
|
+
return `${dir}${tail}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Materialises a CC API file into the 11ty collection-item shape.
|
|
115
|
+
*
|
|
116
|
+
* @param {import("@cloudcannon/visual-editor-api").CloudCannonVisualEditorAPIV1File} file
|
|
117
|
+
*/
|
|
118
|
+
async function materialiseFile(file) {
|
|
119
|
+
const data = (await file.data.get()) ?? {};
|
|
120
|
+
return {
|
|
121
|
+
url: resolveUrl(data, file.path),
|
|
122
|
+
outputPath: resolveOutputPath(data, file.path),
|
|
123
|
+
inputPath: file.path,
|
|
124
|
+
fileSlug: deriveFileSlug(file.path),
|
|
125
|
+
filePathStem: deriveFilePathStem(file.path),
|
|
126
|
+
date: toDate(/** @type {any} */ (data).date),
|
|
127
|
+
data,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Builds the `page` object for the file open in the Visual Editor. Called
|
|
133
|
+
* before every render so live front-matter edits are reflected immediately.
|
|
134
|
+
*
|
|
135
|
+
* @returns {Promise<Record<string, any>>}
|
|
136
|
+
*/
|
|
137
|
+
export async function buildPageData() {
|
|
138
|
+
await apiLoadedPromise;
|
|
139
|
+
const file = CloudCannon?.currentFile?.();
|
|
140
|
+
if (!file) return {};
|
|
141
|
+
const inputPath = file.path;
|
|
142
|
+
const data = (await file.data.get()) ?? {};
|
|
143
|
+
return {
|
|
144
|
+
inputPath,
|
|
145
|
+
fileSlug: deriveFileSlug(inputPath),
|
|
146
|
+
filePathStem: deriveFilePathStem(inputPath),
|
|
147
|
+
outputFileExtension: "html",
|
|
148
|
+
url: resolveUrl(data, inputPath),
|
|
149
|
+
outputPath: resolveOutputPath(data, inputPath),
|
|
150
|
+
date: toDate(/** @type {any} */ (data).date),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Ceiling on concurrent `file.data.get()` calls. One call per file over a
|
|
156
|
+
* collection of thousands fails with `ERR_INSUFFICIENT_RESOURCES` — a net-stack
|
|
157
|
+
* error, so each resolves to a request somewhere behind the editor API.
|
|
158
|
+
*/
|
|
159
|
+
const MATERIALISE_CONCURRENCY = 24;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `Promise.all(items.map(fn))` with at most `limit` calls in flight. Results
|
|
163
|
+
* keep their input order.
|
|
164
|
+
*
|
|
165
|
+
* @template T, R
|
|
166
|
+
* @param {T[]} items
|
|
167
|
+
* @param {(item: T) => Promise<R>} fn
|
|
168
|
+
* @param {number} limit
|
|
169
|
+
* @returns {Promise<R[]>}
|
|
170
|
+
*/
|
|
171
|
+
async function mapWithConcurrency(items, fn, limit) {
|
|
172
|
+
/** @type {R[]} */
|
|
173
|
+
const results = new Array(items.length);
|
|
174
|
+
let cursor = 0;
|
|
175
|
+
|
|
176
|
+
const worker = async () => {
|
|
177
|
+
while (cursor < items.length) {
|
|
178
|
+
const index = cursor++;
|
|
179
|
+
results[index] = await fn(items[index]);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
await Promise.all(
|
|
184
|
+
Array.from({ length: Math.min(limit, items.length) }, worker),
|
|
185
|
+
);
|
|
186
|
+
return results;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** One `CloudCannon.collections()` call, keyed by name. @type {Promise<Map<string, any>> | null} */
|
|
190
|
+
let collectionIndexCache = null;
|
|
191
|
+
|
|
192
|
+
/** Materialised items, per collection name. @type {Map<string, Promise<any[]>>} */
|
|
193
|
+
const collectionItemsCache = new Map();
|
|
194
|
+
|
|
195
|
+
/** @type {Promise<Record<string, any>> | null} */
|
|
196
|
+
let collectionsCache = null;
|
|
197
|
+
|
|
198
|
+
/** @type {Array<{ target: any, event: "change" | "delete", handler: () => void }>} */
|
|
199
|
+
let collectionsSubscriptions = [];
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Enumerates the site's collections — one API call, cached — and subscribes to
|
|
203
|
+
* `change`/`delete` on each so an edit drops the caches. Never calls
|
|
204
|
+
* `collection.items()`: knowing the *names* is what lets the getters be
|
|
205
|
+
* enumerable without fetching behind them.
|
|
206
|
+
*
|
|
207
|
+
* @returns {Promise<Map<string, any>>}
|
|
208
|
+
*/
|
|
209
|
+
function loadCollectionIndex() {
|
|
210
|
+
if (!collectionIndexCache) {
|
|
211
|
+
collectionIndexCache = (async () => {
|
|
212
|
+
await apiLoadedPromise;
|
|
213
|
+
const allCollections = await CloudCannon?.collections?.();
|
|
214
|
+
|
|
215
|
+
/** @type {Map<string, any>} */
|
|
216
|
+
const index = new Map();
|
|
217
|
+
if (!allCollections?.length) return index;
|
|
218
|
+
|
|
219
|
+
for (const collection of allCollections) {
|
|
220
|
+
index.set(collection.collectionKey, collection);
|
|
221
|
+
|
|
222
|
+
const handler = () => resetCollectionsCache();
|
|
223
|
+
collection.addEventListener("change", handler);
|
|
224
|
+
collection.addEventListener("delete", handler);
|
|
225
|
+
collectionsSubscriptions.push(
|
|
226
|
+
{ target: collection, event: "change", handler },
|
|
227
|
+
{ target: collection, event: "delete", handler },
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return index;
|
|
231
|
+
})();
|
|
232
|
+
}
|
|
233
|
+
return collectionIndexCache;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Materialises one collection's files, memoised per name — the only place that
|
|
238
|
+
* issues per-file requests. An unknown name is `[]`, matching 11ty.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} key
|
|
241
|
+
* @returns {Promise<any[]>}
|
|
242
|
+
*/
|
|
243
|
+
function loadCollectionItems(key) {
|
|
244
|
+
let items = collectionItemsCache.get(key);
|
|
245
|
+
if (!items) {
|
|
246
|
+
items = (async () => {
|
|
247
|
+
const collection = (await loadCollectionIndex()).get(key);
|
|
248
|
+
if (!collection) return [];
|
|
249
|
+
|
|
250
|
+
let files;
|
|
251
|
+
try {
|
|
252
|
+
files = await collection.items();
|
|
253
|
+
} catch {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
return mapWithConcurrency(
|
|
257
|
+
files,
|
|
258
|
+
materialiseFile,
|
|
259
|
+
MATERIALISE_CONCURRENCY,
|
|
260
|
+
);
|
|
261
|
+
})();
|
|
262
|
+
collectionItemsCache.set(key, items);
|
|
263
|
+
}
|
|
264
|
+
return items;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Builds (or returns cached) the `collections` object. Every key is a lazy
|
|
269
|
+
* getter returning a `Promise` of its items, which LiquidJS awaits during
|
|
270
|
+
* expression evaluation — so a component that never mentions `collections`
|
|
271
|
+
* issues no per-file requests.
|
|
272
|
+
*
|
|
273
|
+
* Getters not a Proxy: LiquidJS probes `next` and `toLiquid` on every object
|
|
274
|
+
* it resolves, and a blanket-getter Proxy answers those with a Promise, which
|
|
275
|
+
* breaks the lookup entirely.
|
|
276
|
+
*
|
|
277
|
+
* @returns {Promise<Record<string, any>>}
|
|
278
|
+
*/
|
|
279
|
+
export function buildCollectionsData() {
|
|
280
|
+
if (!collectionsCache) {
|
|
281
|
+
collectionsCache = (async () => {
|
|
282
|
+
const index = await loadCollectionIndex();
|
|
283
|
+
|
|
284
|
+
/** @type {Record<string, any>} */
|
|
285
|
+
const collections = {};
|
|
286
|
+
for (const key of index.keys()) {
|
|
287
|
+
Object.defineProperty(collections, key, {
|
|
288
|
+
enumerable: true,
|
|
289
|
+
configurable: true,
|
|
290
|
+
get: () => loadCollectionItems(key),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return collections;
|
|
294
|
+
})();
|
|
295
|
+
}
|
|
296
|
+
return collectionsCache;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Clears every collections cache and tears down the invalidation listeners. */
|
|
300
|
+
export function resetCollectionsCache() {
|
|
301
|
+
for (const { target, event, handler } of collectionsSubscriptions) {
|
|
302
|
+
target.removeEventListener(event, handler);
|
|
303
|
+
}
|
|
304
|
+
collectionsSubscriptions = [];
|
|
305
|
+
collectionIndexCache = null;
|
|
306
|
+
collectionItemsCache.clear();
|
|
307
|
+
collectionsCache = null;
|
|
308
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// `includeWith` tag — spreads an object into a Liquid `{% include %}` the way
|
|
2
|
+
// Astro's `{...props}` does. Kept in its own file so the Node-side Eleventy
|
|
3
|
+
// plugin can import it without pulling in browser-runtime modules.
|
|
4
|
+
|
|
5
|
+
import { evalToken, Tokenizer, toPromise } from "liquidjs";
|
|
6
|
+
import { enhanceLiquidError } from "./errors.mjs";
|
|
7
|
+
import { group, groupEnd, log } from "./logger.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Usage: {% includeWith "path/to/partial", objectToSpread %}
|
|
11
|
+
*
|
|
12
|
+
* @param {any} _liquidEngine - Unused; engine reached via `this.liquid`
|
|
13
|
+
* @returns {any}
|
|
14
|
+
*/
|
|
15
|
+
export function createIncludeWithTag(_liquidEngine) {
|
|
16
|
+
return {
|
|
17
|
+
parse(/** @type {any} */ tagToken) {
|
|
18
|
+
const tokenizer = new Tokenizer(
|
|
19
|
+
tagToken.args,
|
|
20
|
+
this.liquid.options.operatorsTrie,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
this.pathToken = tokenizer.readValue();
|
|
24
|
+
if (!this.pathToken) {
|
|
25
|
+
throw new Error("includeWith: missing path argument");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
tokenizer.skipBlank();
|
|
29
|
+
if (tokenizer.peek() !== ",") {
|
|
30
|
+
throw new Error("includeWith: expected comma separator");
|
|
31
|
+
}
|
|
32
|
+
tokenizer.advance();
|
|
33
|
+
tokenizer.skipBlank();
|
|
34
|
+
|
|
35
|
+
this.objectToken = tokenizer.readValue();
|
|
36
|
+
if (!this.objectToken) {
|
|
37
|
+
throw new Error("includeWith: missing object argument");
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async render(/** @type {any} */ context) {
|
|
42
|
+
group("includeWith rendering");
|
|
43
|
+
const path = await toPromise(evalToken(this.pathToken, context));
|
|
44
|
+
log("Path resolved to:", path);
|
|
45
|
+
|
|
46
|
+
const obj = await toPromise(evalToken(this.objectToken, context));
|
|
47
|
+
log("Object resolved to:", obj);
|
|
48
|
+
|
|
49
|
+
if (!path || typeof path !== "string") {
|
|
50
|
+
groupEnd();
|
|
51
|
+
throw new Error(`includeWith: invalid path "${path}"`);
|
|
52
|
+
}
|
|
53
|
+
if (!obj || typeof obj !== "object") {
|
|
54
|
+
groupEnd();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
log(
|
|
59
|
+
"Including:",
|
|
60
|
+
path,
|
|
61
|
+
"with",
|
|
62
|
+
Object.keys(obj).length,
|
|
63
|
+
"props:",
|
|
64
|
+
Object.keys(obj),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
context.push(obj);
|
|
68
|
+
try {
|
|
69
|
+
const templates = await this.liquid.parseFile(path);
|
|
70
|
+
const result = await this.liquid.render(templates, context);
|
|
71
|
+
log("Rendered result preview:", result?.substring?.(0, 200) || result);
|
|
72
|
+
groupEnd();
|
|
73
|
+
return result;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
const error = /** @type {Error} */ (err);
|
|
76
|
+
log("Error during render:", error.message);
|
|
77
|
+
groupEnd();
|
|
78
|
+
throw enhanceLiquidError(err, `includeWith "${path}"`);
|
|
79
|
+
} finally {
|
|
80
|
+
context.pop();
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|