@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.
@@ -20,10 +20,7 @@ const extendedWindow = /** @type {any} */ (window);
20
20
  /** @type {CloudCannonVisualEditorAPIV1} */
21
21
  let _cloudcannon;
22
22
 
23
- /**
24
- * Promise that resolves when the CloudCannon API is loaded
25
- * @type {Promise<void>}
26
- */
23
+ /** @type {Promise<void>} */
27
24
  export const apiLoadedPromise = new Promise((resolve) => {
28
25
  if (extendedWindow.CloudCannonAPI) {
29
26
  _cloudcannon = /** @type {any} */ (
@@ -47,10 +44,8 @@ export const apiLoadedPromise = new Promise((resolve) => {
47
44
  });
48
45
 
49
46
  /**
50
- * Add a renderer for editable components
51
- * @param {string} key - The component key
52
- * @param {ComponentRenderer} renderer - The component renderer function
53
- * @returns {void}
47
+ * @param {string} key
48
+ * @param {ComponentRenderer} renderer
54
49
  */
55
50
  export const addEditableComponentRenderer = (key, renderer) => {
56
51
  extendedWindow.cc_components = extendedWindow.cc_components || {};
@@ -58,33 +53,23 @@ export const addEditableComponentRenderer = (key, renderer) => {
58
53
  };
59
54
 
60
55
  /**
61
- * Add a renderer for editable snippets
62
- * @param {string} key - The snippet key
63
- * @param {ComponentRenderer} renderer - The snippet renderer function
64
- * @returns {void}
56
+ * @param {string} key
57
+ * @param {ComponentRenderer} renderer
65
58
  */
66
59
  export const addEditableSnippetRenderer = (key, renderer) => {
67
60
  extendedWindow.cc_snippets = extendedWindow.cc_snippets || {};
68
61
  extendedWindow.cc_snippets[key] = renderer;
69
62
  };
70
63
 
71
- /**
72
- * Get all registered editable component renderers
73
- * @returns {Record<string, ComponentRenderer>}
74
- */
75
64
  export const getEditableComponentRenderers = () =>
76
65
  extendedWindow.cc_components ?? {};
77
66
 
78
- /**
79
- * Get all registered editable snippet renderers
80
- * @returns {Record<string, ComponentRenderer>}
81
- */
82
67
  export const getEditableSnippetRenderers = () =>
83
68
  extendedWindow.cc_snippets ?? {};
84
69
 
85
70
  /**
86
- * Realize API values by converting CloudCannon API objects to their data representations
87
- * @param {unknown} value - The value to realize
71
+ * Resolves CloudCannon API objects (collections, files, datasets) to plain data.
72
+ * @param {unknown} value
88
73
  * @returns {Promise<unknown>}
89
74
  */
90
75
  export const realizeAPIValue = async (value) => {
@@ -4,10 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  /** @type{string[]} */
5
5
  const SUPPORTED_VIRTUAL_MODULES = ["assets", "content"];
6
6
 
7
- /**
8
- * @param {*} original
9
- * @returns
10
- */
7
+ /** @param {*} original */
11
8
  function wrapTransform(original) {
12
9
  /**
13
10
  * @this {*}
@@ -4,30 +4,21 @@ import {
4
4
  } from "astro/runtime/server/index.js";
5
5
  import { addEditableComponentRenderer } from "../../helpers/cloudcannon.mjs";
6
6
 
7
- /**
8
- * Queue of React components waiting to be rendered
9
- * @type {((node: Element) => void)[]}
10
- */
7
+ /** @type {((node: Element) => void)[]} */
11
8
  const renderRoots = [];
12
9
 
13
10
  const renderers = [
14
11
  {
15
12
  name: "dynamic-tags",
16
13
  ssr: {
17
- /**
18
- * Checks if the component is a string (HTML tag name).
19
- * @param {any} Component - The component to check
20
- * @returns {boolean} True if component is a string tag name
21
- */
14
+ /** @param {any} Component */
22
15
  check: (Component) => {
23
16
  return typeof Component === "string";
24
17
  },
25
18
  /**
26
- * Renders a dynamic HTML tag with props and slots.
27
- * @param {string} Component - The HTML tag name
28
- * @param {Record<string, any>} props - Props to render as attributes
29
- * @param {Record<string, string>} slots - Slot content
30
- * @returns {Promise<string>} The rendered HTML string
19
+ * @param {string} Component - HTML tag name
20
+ * @param {Record<string, any>} props
21
+ * @param {Record<string, string>} slots
31
22
  */
32
23
  renderToStaticMarkup: async (Component, props, slots) => {
33
24
  const propsString = Object.entries(props)
@@ -57,25 +48,19 @@ export const queueForClientSideRender = (renderFunction) => {
57
48
  };
58
49
 
59
50
  /**
60
- * Registers an Astro component with the CloudCannon component system.
61
- * Creates a wrapper that handles Astro SSR rendering with React hydration support.
51
+ * Registers an Astro component, wrapping it to render via Astro SSR with
52
+ * React hydration support.
62
53
  *
63
- * @param {string} key - Unique identifier for the component
64
- * @param {unknown} component - The Astro component function to register
65
- * @returns {void}
54
+ * @param {string} key
55
+ * @param {unknown} component
66
56
  */
67
57
  export const registerAstroComponent = (key, component) => {
68
58
  /**
69
- * Wrapper function that renders the Astro component with SSR and client-side hydration.
70
- *
71
- * @param {any} props - Props to pass to the Astro component
72
- * @returns {Promise<HTMLElement>} The rendered component as an HTMLElement
59
+ * @param {any} props
60
+ * @returns {Promise<HTMLElement>}
73
61
  */
74
62
  const wrappedComponent = async (props) => {
75
- /**
76
- * Encryption key for Astro server islands
77
- * @type {CryptoKey | undefined}
78
- */
63
+ /** @type {CryptoKey | undefined} Encryption key for Astro server islands */
79
64
  let encryptionKey;
80
65
  try {
81
66
  encryptionKey = await window.crypto.subtle.generateKey(
@@ -130,9 +115,7 @@ export const registerAstroComponent = (key, component) => {
130
115
  slots: {},
131
116
  props,
132
117
  resolve: () => "editable-region-placeholder",
133
- /**
134
- * @param {*} args
135
- */
118
+ /** @param {*} args */
136
119
  createAstro(...args) {
137
120
  if (args.length < 2 || args.length > 3) {
138
121
  console.warn(
@@ -150,18 +133,12 @@ export const registerAstroComponent = (key, component) => {
150
133
  }
151
134
 
152
135
  const astroSlots = {
153
- /**
154
- * @param {string} name
155
- * @returns boolean
156
- */
136
+ /** @param {string} name */
157
137
  has: (name) => {
158
138
  if (!componentSlots) return false;
159
139
  return Boolean(componentSlots[name]);
160
140
  },
161
- /**
162
- * @param {string} name
163
- * @returns string
164
- */
141
+ /** @param {string} name */
165
142
  render: (name) => {
166
143
  return renderSlotToString(SSRResult, componentSlots[name]);
167
144
  },
@@ -174,7 +151,6 @@ export const registerAstroComponent = (key, component) => {
174
151
  };
175
152
  },
176
153
  };
177
- // Render the Astro component to HTML string
178
154
  const result = await renderToString(SSRResult, component, props, {});
179
155
  const doc = document.implementation.createHTMLDocument();
180
156
  doc.body.innerHTML = result;
@@ -184,7 +160,6 @@ export const registerAstroComponent = (key, component) => {
184
160
  renderRoots[csrId]?.(node);
185
161
  });
186
162
 
187
- // Clear the React roots queue
188
163
  renderRoots.length = 0;
189
164
 
190
165
  doc.querySelectorAll("link, [data-island-id]").forEach((node) => {
@@ -201,6 +176,5 @@ export const registerAstroComponent = (key, component) => {
201
176
  return doc.body;
202
177
  };
203
178
 
204
- // Register the wrapped component in the global registry
205
179
  addEditableComponentRenderer(key, wrappedComponent);
206
180
  };
@@ -4,10 +4,7 @@ import PictureInternal from "./picture.astro";
4
4
  export const Image = ImageInternal;
5
5
  export const Picture = PictureInternal;
6
6
 
7
- /**
8
- * @param {{src: any }} options
9
- * @returns
10
- */
7
+ /** @param {{src: any }} options */
11
8
  export const getImage = async (options) => {
12
9
  const resolvedSrc =
13
10
  typeof options.src === "object" && "then" in options.src
@@ -47,10 +47,8 @@ export const getCollection = async (collectionKey, filter) => {
47
47
  };
48
48
 
49
49
  /**
50
- *
51
50
  * @param {string | {collection: string, slug?: string, id?: string}} objOrString
52
51
  * @param {string} [maybeString]
53
- * @returns
54
52
  */
55
53
  export const getEntry = async (objOrString, maybeString) => {
56
54
  if (typeof objOrString === "object") {
@@ -85,10 +83,7 @@ export const getEntry = async (objOrString, maybeString) => {
85
83
  );
86
84
  };
87
85
 
88
- /**
89
- * @param {{collection: string, slug?: string, id?: string}[]} entries
90
- * @returns
91
- */
86
+ /** @param {{collection: string, slug?: string, id?: string}[]} entries */
92
87
  export const getEntries = (entries) => {
93
88
  return Promise.all(entries.map((entry) => getEntry(entry)));
94
89
  };
@@ -96,16 +91,12 @@ export const getEntries = (entries) => {
96
91
  /**
97
92
  * @param {string} collection
98
93
  * @param {string} slug
99
- * @returns
100
94
  */
101
95
  export const getEntryBySlug = (collection, slug) => {
102
96
  return getEntry({ collection, slug });
103
97
  };
104
98
 
105
- /**
106
- * @param {any} entry
107
- * @returns
108
- */
99
+ /** @param {any} entry */
109
100
  export const render = async (entry) => ({
110
101
  Content: () => entry?.body ?? "Content is not available when live editing",
111
102
  headings: [],
@@ -30,10 +30,10 @@ addFrameworkRenderer({
30
30
  }
31
31
  },
32
32
  /**
33
- * Renders a React component to static markup or queues for client-side rendering.
34
- * @param {any} Component - The React component function
35
- * @param {any} props - Props to pass to the component
36
- * @returns {Promise<{ html: string }>} Object containing the rendered HTML
33
+ * Renders to static markup, falling back to a client-side render queue.
34
+ * @param {any} Component
35
+ * @param {any} props
36
+ * @returns {Promise<{ html: string }>}
37
37
  */
38
38
  renderToStaticMarkup: async (Component, props) => {
39
39
  try {
@@ -45,7 +45,6 @@ addFrameworkRenderer({
45
45
  const root = createRoot(node);
46
46
  flushSync(() => root.render(reactNode));
47
47
  });
48
- // Queue for client-side rendering if SSR fails
49
48
  return {
50
49
  html: `<div data-editable-region-csr-id=${id}></div>`,
51
50
  };
@@ -0,0 +1,188 @@
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 {
22
+ builtinFilterNames,
23
+ builtinShortcodeNames,
24
+ } from "./liquid-builtins.mjs";
25
+
26
+ /** @type {Record<HelperKind, (name: string, fn: any) => void>} */
27
+ const KIND_REGISTRARS = {
28
+ filters: registerFilter,
29
+ shortcodes: registerShortcode,
30
+ pairedShortcodes: registerPairedShortcode,
31
+ tags: registerCustomTag,
32
+ };
33
+
34
+ /**
35
+ * Maps each 11ty registration method to its `[kind, layer]`. Universal and
36
+ * Liquid-specific siblings feed the same kind; the Liquid layer wins on a
37
+ * collision, mirroring 11ty's `{ ...universal, ...liquid }` precedence.
38
+ * Variants for other engines (JS/Handlebars/Nunjucks) aren't mirrored.
39
+ *
40
+ * @type {Record<string, [HelperKind, "universal" | "liquid"]>}
41
+ */
42
+ const METHOD_TARGETS = {
43
+ addFilter: ["filters", "universal"],
44
+ addAsyncFilter: ["filters", "universal"],
45
+ addLiquidFilter: ["filters", "liquid"],
46
+ addShortcode: ["shortcodes", "universal"],
47
+ addAsyncShortcode: ["shortcodes", "universal"],
48
+ addLiquidShortcode: ["shortcodes", "liquid"],
49
+ addPairedShortcode: ["pairedShortcodes", "universal"],
50
+ addPairedAsyncShortcode: ["pairedShortcodes", "universal"],
51
+ addPairedLiquidShortcode: ["pairedShortcodes", "liquid"],
52
+ addLiquidTag: ["tags", "liquid"],
53
+ };
54
+
55
+ /**
56
+ * Eleventy accepts a plugin as either a config function directly or as a
57
+ * `{ configFunction, ... }` object. Returns the underlying function, or `null`
58
+ * if it's neither (so the caller can skip it).
59
+ *
60
+ * @param {any} plugin
61
+ * @returns {((config: any, opts: any) => void) | null}
62
+ */
63
+ function resolvePluginFunction(plugin) {
64
+ if (typeof plugin === "function") return plugin;
65
+ if (typeof plugin?.configFunction === "function")
66
+ return plugin.configFunction;
67
+ return null;
68
+ }
69
+
70
+ /** @returns {Record<HelperKind, Map<string, any>>} */
71
+ function createEmptyLayer() {
72
+ return {
73
+ filters: new Map(),
74
+ shortcodes: new Map(),
75
+ pairedShortcodes: new Map(),
76
+ tags: new Map(),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Replays `configFn` against a recording stand-in and registers every
82
+ * collected helper that isn't skipped.
83
+ *
84
+ * @param {unknown} config - The config's default export (a function), or a
85
+ * module namespace whose `.default` is that function (ESM/CJS interop).
86
+ * @param {{ skip?: Partial<Record<HelperKind, string[]>> }} [options] - Per-kind
87
+ * override names to skip; builtin browser-port names are skipped automatically.
88
+ */
89
+ export function collectAndRegisterEleventyHelpers(config, options = {}) {
90
+ const configFn =
91
+ typeof config === "function"
92
+ ? config
93
+ : /** @type {any} */ (config)?.default;
94
+
95
+ if (typeof configFn !== "function") {
96
+ warnOnce(
97
+ "eleventy-config-shape",
98
+ "Could not auto-mirror Eleventy config helpers: the config's default " +
99
+ "export isn't a function. Filters/shortcodes defined in the config " +
100
+ "won't be available in live editing.",
101
+ );
102
+ return;
103
+ }
104
+
105
+ // Skip builtin browser-port names (derived from `liquid-builtins.mjs`) so a
106
+ // same-named config helper can't clobber our port, plus caller overrides.
107
+ /** @type {Record<HelperKind, Set<string>>} */
108
+ const skip = {
109
+ filters: new Set([...builtinFilterNames, ...(options.skip?.filters ?? [])]),
110
+ shortcodes: new Set([
111
+ ...builtinShortcodeNames,
112
+ ...(options.skip?.shortcodes ?? []),
113
+ ]),
114
+ pairedShortcodes: new Set(options.skip?.pairedShortcodes ?? []),
115
+ tags: new Set(options.skip?.tags ?? []),
116
+ };
117
+
118
+ const layers = { universal: createEmptyLayer(), liquid: createEmptyLayer() };
119
+
120
+ /** @type {Record<string, any>} */
121
+ const recorder = {};
122
+ for (const [method, [kind, layer]] of Object.entries(METHOD_TARGETS)) {
123
+ recorder[method] = (
124
+ /** @type {string} */ name,
125
+ /** @type {any} */ helperFn,
126
+ ) => {
127
+ if (typeof name === "string" && typeof helperFn === "function") {
128
+ layers[layer][kind].set(name, helperFn);
129
+ }
130
+ };
131
+ }
132
+
133
+ // Unrecorded methods are no-ops so running the real config (which calls
134
+ // `addPassthroughCopy`, `on`, sets `dir`, ...) doesn't throw.
135
+ const configRecorder = new Proxy(recorder, {
136
+ get(target, prop, receiver) {
137
+ if (prop in target) return Reflect.get(target, prop, receiver);
138
+ return () => {};
139
+ },
140
+ });
141
+
142
+ recorder.addPlugin = (/** @type {any} */ plugin, /** @type {any} */ opts) => {
143
+ const pluginFn = resolvePluginFunction(plugin);
144
+ if (!pluginFn) return;
145
+
146
+ // A plugin is itself a config function, so replay it against the same
147
+ // recorder to capture the helpers it registers.
148
+ try {
149
+ pluginFn(configRecorder, opts);
150
+ } catch {
151
+ // Node-only plugins are stubbed at bundle time and throw when called.
152
+ // Helpers registered before the throw are kept; the config continues.
153
+ }
154
+ };
155
+
156
+ try {
157
+ configFn(configRecorder);
158
+ } catch (err) {
159
+ warnOnce(
160
+ "eleventy-config-replay",
161
+ "Replaying the Eleventy config to mirror its helpers threw: " +
162
+ `${err instanceof Error ? err.message : err}. Some filters/` +
163
+ "shortcodes may be unavailable in live editing — define a browser " +
164
+ "override via `pluginOptions.liquid.<kind>` for any that are needed.",
165
+ );
166
+ }
167
+
168
+ for (const kind of /** @type {HelperKind[]} */ (
169
+ Object.keys(KIND_REGISTRARS)
170
+ )) {
171
+ const register = KIND_REGISTRARS[kind];
172
+ // Liquid layer spread last so it wins on a name collision.
173
+ const merged = new Map([...layers.universal[kind], ...layers.liquid[kind]]);
174
+
175
+ for (const [name, helperFn] of merged) {
176
+ if (skip[kind].has(name)) continue;
177
+ try {
178
+ register(name, helperFn);
179
+ } catch (err) {
180
+ warnOnce(
181
+ `eleventy-mirror:${kind}:${name}`,
182
+ `Failed to mirror Eleventy ${kind} "${name}" into live editing: ` +
183
+ `${err instanceof Error ? err.message : err}.`,
184
+ );
185
+ }
186
+ }
187
+ }
188
+ }
@@ -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";