@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.
@@ -1,104 +1,64 @@
1
1
  import { log, warn } from "./logger.mjs";
2
2
 
3
3
  /**
4
- * In-memory filesystem for LiquidJS that reads from window.cc_files.
5
- *
6
- * @type {any} LiquidJS-compatible filesystem object
4
+ * In-memory filesystem for LiquidJS, reading from `window.cc_liquid_files`.
5
+ * @type {any}
7
6
  */
8
7
  export const inMemoryFs = {
9
8
  sep: "/",
10
9
 
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) {
10
+ dirname(/** @type {string} */ filePath) {
18
11
  const parts = filePath.split("/");
19
12
  parts.pop();
20
13
  return parts.join("/") || "/";
21
14
  },
22
15
 
23
- /**
24
- * Synchronously reads a file from the in-memory store.
25
- *
26
- * @param {string} filePath - The file path to read
27
- * @returns {string | undefined} The file contents or undefined if not found
28
- */
29
- readFileSync(filePath) {
30
- log("readFileSync:", filePath);
31
- const fileContents = window.cc_files?.[filePath];
16
+ readFileSync(/** @type {string} */ filePath) {
17
+ const fileContents = window.cc_liquid_files?.[filePath];
32
18
 
33
- if (fileContents === undefined) {
34
- const availableFiles = Object.keys(window.cc_files || {});
19
+ if (fileContents === undefined || fileContents === null) {
20
+ const availableFiles = Object.keys(window.cc_liquid_files || {});
35
21
  warn("File not found:", filePath);
36
22
  log("Available files:", availableFiles);
37
- } else {
38
- log("File found, length:", fileContents?.length || 0);
23
+ throw new Error(
24
+ `ENOENT: Failed to find "${filePath}" in the bundled template files. Please check that this file exists and is within your configured component directories.`,
25
+ );
39
26
  }
40
27
 
41
28
  return fileContents;
42
29
  },
43
30
 
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);
31
+ async readFile(/** @type {string} */ filePath) {
53
32
  if (!filePath) {
54
33
  throw new Error("readFile called with empty path");
55
34
  }
56
35
  return this.readFileSync(filePath);
57
36
  },
58
37
 
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) {
38
+ existsSync(/** @type {string} */ filePath) {
66
39
  if (!filePath || typeof filePath !== "string") {
67
- log("exists: invalid path", filePath);
68
40
  return false;
69
41
  }
70
- const result = this.existsSync(filePath);
71
- log("exists:", filePath, "=", result);
72
- return result;
42
+ const fileContents = window.cc_liquid_files?.[filePath];
43
+ return fileContents !== null && fileContents !== undefined;
73
44
  },
74
45
 
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) {
46
+ async exists(/** @type {string} */ filePath) {
82
47
  if (!filePath || typeof filePath !== "string") {
83
48
  return false;
84
49
  }
85
- const fileContents = window.cc_files?.[filePath];
86
- const exists = fileContents !== null && fileContents !== undefined;
87
- log("existsSync:", filePath, "=", exists);
88
- return exists;
50
+ return this.existsSync(filePath);
89
51
  },
90
52
 
91
53
  /**
92
- * Resolves a file path by joining the root with the file name and extension.
93
- * LiquidJS calls this once per root directory and checks exists() on the
54
+ * LiquidJS calls this once per root directory and checks `exists()` on the
94
55
  * 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
56
  */
101
- resolve(root, file, ext) {
57
+ resolve(
58
+ /** @type {string} */ root,
59
+ /** @type {string} */ file,
60
+ /** @type {string} */ ext,
61
+ ) {
102
62
  const extension = ext || ".liquid";
103
63
  const fileWithExt = file.endsWith(extension) ? file : `${file}${extension}`;
104
64
  const normalizedRoot = root.replace(/^\.\//, "").replace(/\/*$/, "/");
@@ -107,21 +67,12 @@ export const inMemoryFs = {
107
67
  return resolved;
108
68
  },
109
69
 
110
- /**
111
- * Returns file stat (always returns isFile: true for compatibility).
112
- *
113
- * @returns {Promise<{isFile: () => boolean}>}
114
- */
115
- async statAsync() {
70
+ // The store is flat, so anything stat'd is a file.
71
+ statSync() {
116
72
  return { isFile: () => true };
117
73
  },
118
74
 
119
- /**
120
- * Returns file stat synchronously (always returns isFile: true for compatibility).
121
- *
122
- * @returns {{isFile: () => boolean}}
123
- */
124
- statSync() {
75
+ async statAsync() {
125
76
  return { isFile: () => true };
126
77
  },
127
78
  };
@@ -0,0 +1,209 @@
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
+ // property access in templates is then synchronous on the resolved objects.
4
+
5
+ import { apiLoadedPromise, CloudCannon } from "../../helpers/cloudcannon.mjs";
6
+ import { getPageMap, normalizeInputPath } from "./page-map.mjs";
7
+
8
+ /** @type {{ directories?: { output?: string } } | null} */
9
+ let eleventyData = null;
10
+
11
+ /** @param {{ directories?: { output?: string } } | null} data */
12
+ export function setEleventyData(data) {
13
+ eleventyData = data;
14
+ }
15
+
16
+ /** Strips the file extension from a path. */
17
+ function stripExtension(/** @type {string} */ p) {
18
+ return p.replace(/\.[^./]+$/, "");
19
+ }
20
+
21
+ /**
22
+ * 11ty's folder-style permalink: trailing-slash URL, `index` files mapping to
23
+ * the parent dir. Last-resort fallback when neither a literal front-matter
24
+ * `permalink` nor the page map resolves a URL.
25
+ */
26
+ function deriveDefaultUrl(/** @type {string} */ inputPath) {
27
+ const stem = stripExtension(inputPath).replace(/^\.?\//, "/");
28
+ const withLeadingSlash = stem.startsWith("/") ? stem : `/${stem}`;
29
+ const withoutIndex = withLeadingSlash.replace(/\/index$/, "/");
30
+ return withoutIndex.endsWith("/") ? withoutIndex : `${withoutIndex}/`;
31
+ }
32
+
33
+ /**
34
+ * A front-matter `permalink` usable verbatim: a plain string with no Liquid
35
+ * templating. Templated permalinks (e.g. `"/{{ page.date }}/"`) need the full
36
+ * build context to render, so we return `undefined` and let the caller fall
37
+ * back to the page map's already-resolved value.
38
+ */
39
+ function literalPermalink(
40
+ /** @type {Record<string, any> | null | undefined} */ data,
41
+ ) {
42
+ const permalink = data?.permalink;
43
+ if (typeof permalink !== "string") return undefined;
44
+ if (permalink.includes("{{") || permalink.includes("{%")) return undefined;
45
+ return permalink;
46
+ }
47
+
48
+ /**
49
+ * Resolves the URL for an input file, in priority order: literal front-matter
50
+ * `permalink` (so editor edits show before a rebuild) → build-time page map →
51
+ * 11ty's folder-style default.
52
+ */
53
+ function resolveUrl(
54
+ /** @type {Record<string, any> | null | undefined} */ data,
55
+ /** @type {string} */ inputPath,
56
+ ) {
57
+ const permalink = literalPermalink(data);
58
+ if (permalink) return permalink;
59
+ const mapped = getPageMap()[normalizeInputPath(inputPath)];
60
+ if (mapped?.url) return mapped.url;
61
+ return deriveDefaultUrl(inputPath);
62
+ }
63
+
64
+ /** Same priority layering as `resolveUrl`, for the output path. */
65
+ function resolveOutputPath(
66
+ /** @type {Record<string, any> | null | undefined} */ data,
67
+ /** @type {string} */ inputPath,
68
+ ) {
69
+ const outputDir = eleventyData?.directories?.output;
70
+ const permalink = literalPermalink(data);
71
+ if (permalink) {
72
+ return outputDir ? joinOutputPath(outputDir, permalink) : undefined;
73
+ }
74
+ const mapped = getPageMap()[normalizeInputPath(inputPath)];
75
+ if (mapped?.outputPath) return mapped.outputPath;
76
+ if (!outputDir) return undefined;
77
+ return joinOutputPath(outputDir, deriveDefaultUrl(inputPath));
78
+ }
79
+
80
+ /** Basename minus extension. Matches 11ty's `fileSlug` derivation. */
81
+ function deriveFileSlug(/** @type {string} */ inputPath) {
82
+ const base = inputPath.split("/").pop() ?? "";
83
+ return stripExtension(base);
84
+ }
85
+
86
+ /** Full path minus extension, with a leading slash. */
87
+ function deriveFilePathStem(/** @type {string} */ inputPath) {
88
+ const stem = stripExtension(inputPath).replace(/^\.?\//, "/");
89
+ return stem.startsWith("/") ? stem : `/${stem}`;
90
+ }
91
+
92
+ /** Coerces a front-matter date value into a Date, or `undefined`. */
93
+ function toDate(/** @type {unknown} */ raw) {
94
+ if (!raw) return undefined;
95
+ const d = new Date(/** @type {any} */ (raw));
96
+ return Number.isNaN(d.getTime()) ? undefined : d;
97
+ }
98
+
99
+ /**
100
+ * Joins an output dir and URL the way 11ty does: trailing-slash URLs become
101
+ * `<dir><url>index.html`; others are appended as-is.
102
+ */
103
+ function joinOutputPath(
104
+ /** @type {string} */ outputDir,
105
+ /** @type {string} */ url,
106
+ ) {
107
+ const dir = outputDir.replace(/\/+$/, "");
108
+ const tail = url.endsWith("/") ? `${url}index.html` : url;
109
+ return `${dir}${tail}`;
110
+ }
111
+
112
+ /**
113
+ * Materialises a CC API file into the 11ty collection-item shape.
114
+ *
115
+ * @param {import("@cloudcannon/visual-editor-api").CloudCannonVisualEditorAPIV1File} file
116
+ */
117
+ async function materialiseFile(file) {
118
+ const data = (await file.data.get()) ?? {};
119
+ return {
120
+ url: resolveUrl(data, file.path),
121
+ outputPath: resolveOutputPath(data, file.path),
122
+ inputPath: file.path,
123
+ fileSlug: deriveFileSlug(file.path),
124
+ filePathStem: deriveFilePathStem(file.path),
125
+ date: toDate(/** @type {any} */ (data).date),
126
+ data,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Builds the `page` object for the file open in the Visual Editor. Called
132
+ * before every render so live front-matter edits are reflected immediately.
133
+ *
134
+ * @returns {Promise<Record<string, any>>}
135
+ */
136
+ export async function buildPageData() {
137
+ await apiLoadedPromise;
138
+ const file = CloudCannon?.currentFile?.();
139
+ if (!file) return {};
140
+ const inputPath = file.path;
141
+ const data = (await file.data.get()) ?? {};
142
+ return {
143
+ inputPath,
144
+ fileSlug: deriveFileSlug(inputPath),
145
+ filePathStem: deriveFilePathStem(inputPath),
146
+ outputFileExtension: "html",
147
+ url: resolveUrl(data, inputPath),
148
+ outputPath: resolveOutputPath(data, inputPath),
149
+ date: toDate(/** @type {any} */ (data).date),
150
+ };
151
+ }
152
+
153
+ /** @type {Promise<Record<string, Array<any>>> | null} */
154
+ let collectionsCache = null;
155
+
156
+ /** @type {Array<{ target: any, event: "change" | "delete", handler: () => void }>} */
157
+ let collectionsSubscriptions = [];
158
+
159
+ /**
160
+ * Builds (or returns cached) the `collections` object, keyed by collection
161
+ * name. Subscribes to `change`/`delete` on each collection and drops the cache
162
+ * when either fires, so edits during a session are picked up on the next render.
163
+ *
164
+ * @returns {Promise<Record<string, Array<any>>>}
165
+ */
166
+ export function buildCollectionsData() {
167
+ if (!collectionsCache) {
168
+ collectionsCache = (async () => {
169
+ await apiLoadedPromise;
170
+ const allCollections = await CloudCannon?.collections?.();
171
+ if (!allCollections?.length) return {};
172
+
173
+ for (const collection of allCollections) {
174
+ const handler = () => resetCollectionsCache();
175
+ collection.addEventListener("change", handler);
176
+ collection.addEventListener("delete", handler);
177
+ collectionsSubscriptions.push(
178
+ { target: collection, event: "change", handler },
179
+ { target: collection, event: "delete", handler },
180
+ );
181
+ }
182
+
183
+ const entries = await Promise.all(
184
+ allCollections.map(async (collection) => {
185
+ const key = collection.collectionKey;
186
+ let files;
187
+ try {
188
+ files = await collection.items();
189
+ } catch {
190
+ return /** @type {[string, any[]]} */ ([key, []]);
191
+ }
192
+ const items = await Promise.all(files.map(materialiseFile));
193
+ return /** @type {[string, any[]]} */ ([key, items]);
194
+ }),
195
+ );
196
+ return Object.fromEntries(entries);
197
+ })();
198
+ }
199
+ return collectionsCache;
200
+ }
201
+
202
+ /** Clears the collections cache and tears down its invalidation listeners. */
203
+ export function resetCollectionsCache() {
204
+ for (const { target, event, handler } of collectionsSubscriptions) {
205
+ target.removeEventListener(event, handler);
206
+ }
207
+ collectionsSubscriptions = [];
208
+ collectionsCache = null;
209
+ }
@@ -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
+ }