@cloudcannon/editable-regions 0.0.8 → 0.0.10

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.
@@ -9,7 +9,7 @@ export default class EditableSnippetComponent extends HTMLElement {
9
9
  }
10
10
 
11
11
  set snippetData(value: unknown) {
12
- this.editable.pushValue(value);
12
+ this.editable.pushValue(value, {}, { path: "", editable: this.editable });
13
13
  }
14
14
 
15
15
  connectedCallback(): void {
@@ -6,12 +6,10 @@ export { default as EditableSnippetComponent } from "./editable-snippet-componen
6
6
  export { default as EditableSourceComponent } from "./editable-source-component.js";
7
7
  export { default as EditableTextComponent } from "./editable-text-component.js";
8
8
 
9
- import { apiLoadedPromise } from "../helpers/cloudcannon.mjs";
10
9
  import {
11
10
  dehydrateDataEditableRegions,
12
11
  hydrateDataEditableRegions,
13
12
  } from "../helpers/hydrate-editable-regions";
14
- import { completeLoading } from "../helpers/loading.js";
15
13
 
16
14
  const observer = new MutationObserver((mutations) => {
17
15
  mutations.forEach((mutation) => {
@@ -32,14 +30,3 @@ const observer = new MutationObserver((mutations) => {
32
30
  hydrateDataEditableRegions(document.body);
33
31
 
34
32
  observer.observe(document, { childList: true, subtree: true });
35
-
36
- Promise.all([
37
- apiLoadedPromise,
38
- customElements.whenDefined("editable-array-item"),
39
- customElements.whenDefined("editable-array"),
40
- customElements.whenDefined("editable-text"),
41
- customElements.whenDefined("editable-component"),
42
- customElements.whenDefined("editable-image"),
43
- customElements.whenDefined("editable-source"),
44
- customElements.whenDefined("editable-snippet"),
45
- ]).then(() => completeLoading());
package/helpers/checks.ts CHANGED
@@ -2,32 +2,11 @@ import Editable from "../nodes/editable.js";
2
2
  import EditableArrayItem from "../nodes/editable-array-item.js";
3
3
  import EditableText from "../nodes/editable-text.js";
4
4
 
5
- const TAG_NAMES = [
6
- "EDITABLE-TEXT",
7
- "EDITABLE-COMPONENT",
8
- "EDITABLE-ARRAY-ITEM",
9
- "EDITABLE-ARRAY",
10
- "EDITABLE-IMAGE",
11
- "EDITABLE-SOURCE",
12
- "EDITABLE-SNIPPET",
13
- ];
14
-
15
- const EDITABLE_REGION_TYPES = [
16
- "text",
17
- "component",
18
- "array",
19
- "array-item",
20
- "image",
21
- "source",
22
- ];
23
-
24
- const CORRESPONDING_NAME: Record<string, string> = {
25
- "EDITABLE-TEXT": "text",
26
- "EDITABLE-COMPONENT": "component",
27
- "EDITABLE-ARRAY-ITEM": "array-item",
28
- "EDITABLE-ARRAY": "array",
29
- "EDITABLE-IMAGE": "image",
30
- "EDITABLE-SOURCE": "source",
5
+ const getEditableType = (el: HTMLElement): string | undefined => {
6
+ if (el.tagName.startsWith("EDITABLE-")) {
7
+ return el.tagName.slice(9).toLowerCase();
8
+ }
9
+ return el.dataset.editable;
31
10
  };
32
11
 
33
12
  export const hasEditable = <T extends object>(
@@ -53,7 +32,7 @@ export const isEditableWebcomponent = (el: unknown): boolean => {
53
32
  return false;
54
33
  }
55
34
 
56
- return TAG_NAMES.includes(el.tagName);
35
+ return el.tagName.startsWith("EDITABLE-");
57
36
  };
58
37
 
59
38
  export const isEditableElement = (el: unknown): boolean => {
@@ -62,19 +41,13 @@ export const isEditableElement = (el: unknown): boolean => {
62
41
  }
63
42
 
64
43
  return (
65
- TAG_NAMES.includes(el.tagName) ||
66
- (!!el.dataset.editable &&
67
- EDITABLE_REGION_TYPES.includes(el.dataset.editable))
44
+ el.tagName.startsWith("EDITABLE-") ||
45
+ typeof el.dataset.editable === "string"
68
46
  );
69
47
  };
70
48
 
71
49
  export const areEqualEditables = (a: HTMLElement, b: HTMLElement) => {
72
- if (
73
- a.tagName !== b.tagName &&
74
- a.dataset.editable !== b.dataset.editable &&
75
- CORRESPONDING_NAME[a.tagName] !== b.dataset.editable &&
76
- CORRESPONDING_NAME[b.tagName] !== a.dataset.editable
77
- ) {
50
+ if (getEditableType(a) !== getEditableType(b)) {
78
51
  return false;
79
52
  }
80
53
 
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Promise that resolves when the CloudCannon API is loaded
3
+ * @type {Promise<void>}
4
+ */
5
+ export const apiLoadedPromise: Promise<void>;
6
+ export function addEditableComponentRenderer(key: string, renderer: ComponentRenderer): void;
7
+ export function addEditableSnippetRenderer(key: string, renderer: ComponentRenderer): void;
8
+ export function getEditableComponentRenderers(): Record<string, ComponentRenderer>;
9
+ export function getEditableSnippetRenderers(): Record<string, ComponentRenderer>;
10
+ export function realizeAPIValue(value: unknown): Promise<unknown>;
11
+ export { _cloudcannon as CloudCannon };
12
+ export type CloudCannonEditorWindow = import("@cloudcannon/javascript-api").CloudCannonEditorWindow;
13
+ export type CloudCannonJavaScriptV1API = import("@cloudcannon/javascript-api").CloudCannonJavaScriptV1API;
14
+ export type ComponentRenderer = (props: any) => HTMLElement | Promise<HTMLElement>;
15
+ export type ExtendedWindow = CloudCannonEditorWindow & {
16
+ cc_components?: Record<string, ComponentRenderer>;
17
+ cc_snippets?: Record<string, ComponentRenderer>;
18
+ };
19
+ /** @type {CloudCannonJavaScriptV1API} */
20
+ declare let _cloudcannon: CloudCannonJavaScriptV1API;
@@ -107,4 +107,14 @@ export const realizeAPIValue = async (value) => {
107
107
  return value;
108
108
  };
109
109
 
110
+ export const addCustomEditableRegion = (key, region) => {
111
+ extendedWindow.editableRegionMap ??= {};
112
+ extendedWindow.editableRegionMap[key] = region;
113
+ extendedWindow.hydrateDataEditableRegions(document.body);
114
+ };
115
+
116
+ export const getCustomEditableRegions = () => {
117
+ return extendedWindow.editableRegionMap ?? {};
118
+ };
119
+
110
120
  export { _cloudcannon as CloudCannon };
@@ -9,7 +9,7 @@ import {
9
9
  } from "../nodes";
10
10
  import { hasEditable, isEditableWebcomponent } from "./checks";
11
11
 
12
- const editableMap: Record<string, typeof Editable | undefined> = {
12
+ const baseEditableMap: Record<string, typeof Editable | undefined> = {
13
13
  array: EditableArray,
14
14
  "array-item": EditableArrayItem,
15
15
  component: EditableComponent,
@@ -31,6 +31,10 @@ export const dehydrateDataEditableRegions = (root: Element) => {
31
31
  };
32
32
 
33
33
  export const hydrateDataEditableRegions = (root: Element) => {
34
+ const editableMap: Record<string, typeof Editable | undefined> = {
35
+ ...baseEditableMap,
36
+ ...((window as any).editableRegionMap ?? {}),
37
+ };
34
38
  if (
35
39
  root instanceof HTMLElement &&
36
40
  root.dataset.editable &&
@@ -89,3 +93,5 @@ export const hydrateDataEditableRegions = (root: Element) => {
89
93
  }
90
94
  });
91
95
  };
96
+
97
+ (window as any).hydrateDataEditableRegions = hydrateDataEditableRegions;
@@ -1,26 +1,17 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
 
4
- const SUPPORTED_VIRTUAL_MODULES = [
5
- "actions",
6
- "assets",
7
- "content",
8
- "i18n",
9
- "middleware",
10
- "transitions",
11
- ];
4
+ /** @type{string[]} */
5
+ const SUPPORTED_VIRTUAL_MODULES = ["assets", "content"];
12
6
 
13
7
  /**
14
8
  * @return {import("astro").AstroIntegration}
15
9
  */
16
10
  export default () => {
17
- /** @type {import("astro").AstroConfig} */
18
- let astroConfig;
19
-
20
11
  return {
21
12
  name: "editable-regions",
22
13
  hooks: {
23
- "astro:config:setup": ({ config, updateConfig }) => {
14
+ "astro:config:setup": ({ updateConfig }) => {
24
15
  updateConfig({
25
16
  vite: {
26
17
  define: {
@@ -28,7 +19,6 @@ export default () => {
28
19
  },
29
20
  },
30
21
  });
31
- astroConfig = config;
32
22
  },
33
23
  "astro:build:setup": async ({ target, vite }) => {
34
24
  if (target === "client") {
@@ -68,14 +58,6 @@ export default () => {
68
58
  .replace("/client", "")
69
59
  .replace("/server", "");
70
60
 
71
- if (type === "env") {
72
- return "\0editable-region:env";
73
- }
74
-
75
- if (!SUPPORTED_VIRTUAL_MODULES.includes(type)) {
76
- return;
77
- }
78
-
79
61
  let dir = "";
80
62
  if (typeof __dirname !== "undefined") {
81
63
  dir = __dirname;
@@ -83,41 +65,15 @@ export default () => {
83
65
  dir = dirname(fileURLToPath(import.meta.url));
84
66
  }
85
67
 
86
- return join(dir, "modules", `${type}.js`);
87
- }
88
- },
68
+ if (type === "env" && id.endsWith("/server")) {
69
+ return join(dir, "modules", "secrets.js");
70
+ }
89
71
 
90
- load(id) {
91
- if (id === "\0editable-region:env") {
92
- let contents = "";
93
- Object.entries(astroConfig?.env?.schema ?? {}).forEach(
94
- ([key, schema]) => {
95
- if (
96
- schema.context !== "client" ||
97
- schema.access !== "public"
98
- ) {
99
- return;
100
- }
72
+ if (!SUPPORTED_VIRTUAL_MODULES.includes(type)) {
73
+ return;
74
+ }
101
75
 
102
- try {
103
- switch (schema.type) {
104
- case "boolean":
105
- contents += `export const ${key} = ${!!process.env[key]};\n`;
106
- break;
107
- case "number":
108
- contents += `export const ${key} = ${Number(process.env[key])};\n`;
109
- break;
110
- default:
111
- contents += `export const ${key} = ${JSON.stringify(process.env[key] ?? "")};\n`;
112
- }
113
- } catch (_e) {
114
- //Error intentionally ignored
115
- }
116
- },
117
- );
118
- contents +=
119
- 'export const getSecret = () => console.log("[CloudCannon] getSecret is not supported in an editable component. Please use an editing fallback instead.");';
120
- return contents;
76
+ return join(dir, "modules", `${type}.js`);
121
77
  }
122
78
  },
123
79
  });
@@ -26,12 +26,18 @@ export const getCollection = async (collectionKey, filter) => {
26
26
  slug = data.slug;
27
27
  }
28
28
 
29
+ const body = await file.get();
29
30
  return {
30
31
  collection: collectionKey,
31
32
  id: id,
32
33
  data: data,
33
34
  slug: slug,
34
- body: await file.get(),
35
+ body: body,
36
+ render: () => ({
37
+ Content: () => body ?? "Content is not available when live editing",
38
+ headings: [],
39
+ remarkPluginFrontmatter: {},
40
+ }),
35
41
  };
36
42
  });
37
43
 
@@ -0,0 +1,4 @@
1
+ export const getSecret = () =>
2
+ console.log(
3
+ "[CloudCannon] getSecret is not supported in an editable component. Please use an editing fallback instead.",
4
+ );
@@ -249,8 +249,8 @@ export default class EditableArrayItem extends EditableComponent {
249
249
  );
250
250
  }
251
251
 
252
- async update(): Promise<void> {
253
- await super.update();
252
+ async update(partialSubtree?: ChildNode | null): Promise<void> {
253
+ await super.update(partialSubtree);
254
254
  this.updateControls();
255
255
  }
256
256
 
@@ -485,12 +485,12 @@ export default class EditableArrayItem extends EditableComponent {
485
485
  }
486
486
  }
487
487
 
488
- setupListeners(): void {
489
- super.setupListeners();
490
- if (!this.element.dataset.prop) {
491
- this.parent?.registerListener({
492
- editable: this,
493
- });
494
- }
488
+ getSpecialProps(
489
+ incomingSpecialProps: Record<string, unknown> = {},
490
+ ): Record<string, unknown> {
491
+ return {
492
+ ...super.getSpecialProps(incomingSpecialProps),
493
+ "@index": Number(this.element.dataset.prop),
494
+ };
495
495
  }
496
496
  }
@@ -37,13 +37,16 @@ export default class EditableArray extends Editable {
37
37
  private updatePromise: Promise<void> | undefined;
38
38
  private needsReupdate = false;
39
39
  private addButton?: EditableRegionButton;
40
+ private pendingPartialSubtree?: ChildNode | null;
40
41
 
41
42
  async registerListener(listener: EditableListener): Promise<void> {
42
43
  if (!this.value) {
43
44
  return;
44
45
  }
45
46
 
46
- const __base_context = { ...this.contextBase };
47
+ const __base_context = {
48
+ ...this.contextBase,
49
+ };
47
50
  let value: unknown[] | CloudCannonJavaScriptV1APIFile[];
48
51
  if (CloudCannon.isAPICollection(this.value)) {
49
52
  value = await this.value.items();
@@ -90,7 +93,7 @@ export default class EditableArray extends Editable {
90
93
  }
91
94
 
92
95
  if (listener.path) {
93
- listener.editable.pushValue(value, listener, {
96
+ listener.editable.pushValue(value, this.specialProps, listener, {
94
97
  __base_context,
95
98
  });
96
99
  }
@@ -147,24 +150,29 @@ export default class EditableArray extends Editable {
147
150
  return value;
148
151
  }
149
152
 
150
- update(): Promise<void> {
153
+ update(partialSubtree?: ChildNode | null): Promise<void> {
151
154
  if (this.updatePromise) {
152
155
  this.needsReupdate = true;
156
+ this.pendingPartialSubtree = partialSubtree;
153
157
  return this.updatePromise;
154
158
  }
155
- this.updatePromise = this._update().then(() => {
159
+ this.updatePromise = this._update(partialSubtree).then(() => {
156
160
  this.updatePromise = undefined;
157
161
  if (this.needsReupdate) {
158
162
  this.needsReupdate = false;
159
- return this.update();
163
+ const savedPartialSubtree = this.pendingPartialSubtree;
164
+ this.pendingPartialSubtree = undefined;
165
+ return this.update(savedPartialSubtree);
160
166
  }
161
167
  });
162
168
  return this.updatePromise;
163
169
  }
164
170
 
165
- private async _update(): Promise<void> {
171
+ private async _update(partialSubtree?: ChildNode | null): Promise<void> {
166
172
  let value: unknown[] | CloudCannonJavaScriptV1APIFile[];
167
- const __base_context = { ...this.contextBase };
173
+ const __base_context = {
174
+ ...this.contextBase,
175
+ };
168
176
  if (CloudCannon.isAPICollection(this.value)) {
169
177
  value = await this.value.items();
170
178
  } else if (CloudCannon.isAPIDataset(this.value)) {
@@ -186,6 +194,24 @@ export default class EditableArray extends Editable {
186
194
  value = [];
187
195
  }
188
196
 
197
+ const partialChildren: (HTMLElement & { editable?: EditableArrayItem })[] =
198
+ [];
199
+ if (partialSubtree instanceof HTMLElement) {
200
+ for (const child of partialSubtree.querySelectorAll(
201
+ "editable-array-item,[data-editable='array-item']",
202
+ )) {
203
+ let parent = child.parentElement;
204
+ while (parent instanceof HTMLElement && !isEditableElement(parent)) {
205
+ parent = parent.parentElement;
206
+ }
207
+ if (parent !== partialSubtree) {
208
+ continue;
209
+ }
210
+
211
+ partialChildren.push(child as any);
212
+ }
213
+ }
214
+
189
215
  const templates: {
190
216
  keyed: Record<
191
217
  string,
@@ -241,23 +267,6 @@ export default class EditableArray extends Editable {
241
267
  children.push(child as any);
242
268
  }
243
269
 
244
- if (
245
- children.length === 0 &&
246
- !this.element.dataset.component &&
247
- !this.element.dataset.componentKey &&
248
- !templates.unkeyed &&
249
- Object.keys(templates.keyed).length === 0
250
- ) {
251
- const error = document.createElement("editable-region-error-card");
252
- error.setAttribute("heading", "Failed to render array editable region");
253
- error.setAttribute(
254
- "message",
255
- "Array editable regions with no child array items must have either a 'data-component' attribute or a 'data-component-key' attribute. Please add an item to this array then save and rebuild to see your changes or add a 'data-component' or 'data-component-key' attribute to this element.",
256
- );
257
- this.element.replaceChildren(error);
258
- return;
259
- }
260
-
261
270
  const key = this.element.dataset.idKey ?? this.element.dataset.componentKey;
262
271
 
263
272
  if (!key) {
@@ -274,8 +283,14 @@ export default class EditableArray extends Editable {
274
283
 
275
284
  for (let i = 0; i < value.length; i++) {
276
285
  let child = children[i];
286
+ const partialChild = partialChildren[i];
287
+
277
288
  if (!child) {
278
- if (templates.unkeyed) {
289
+ if (partialChild) {
290
+ child = partialChild.cloneNode(true) as HTMLElement & {
291
+ editable?: EditableArrayItem;
292
+ };
293
+ } else if (templates.unkeyed) {
279
294
  child = templates.unkeyed.cloneNode(true) as HTMLElement & {
280
295
  editable?: EditableArrayItem;
281
296
  };
@@ -284,11 +299,22 @@ export default class EditableArray extends Editable {
284
299
  child = document.createElement(
285
300
  "editable-array-item",
286
301
  ) as EditableArrayItemComponent;
287
- } else {
288
- // Empty arrays should be caught by the error case above so children[0] should always exist
302
+ } else if (children[0]) {
289
303
  child = children[0].cloneNode(true) as HTMLElement & {
290
304
  editable?: EditableArrayItem;
291
305
  };
306
+ } else {
307
+ const error = document.createElement("editable-region-error-card");
308
+ error.setAttribute(
309
+ "heading",
310
+ "Failed to render array editable region",
311
+ );
312
+ error.setAttribute(
313
+ "message",
314
+ "Array editable region contains an array item that cannot be rendered. Either a 'data-component' attribute or a 'data-component-key' attribute is required to render array items that weren't previously on the page. Please save and rebuild to see your changes or add a 'data-component' or 'data-component-key' attribute to this element.",
315
+ );
316
+ this.element.replaceChildren(error);
317
+ return;
292
318
  }
293
319
  this.element.appendChild(child);
294
320
  }
@@ -300,8 +326,10 @@ export default class EditableArray extends Editable {
300
326
  child.dataset.length = `${children.length}`;
301
327
  child.editable?.pushValue(
302
328
  value,
329
+ this.specialProps,
303
330
  { path: `${i}`, editable: child.editable },
304
331
  { __base_context },
332
+ partialChild,
305
333
  );
306
334
  }
307
335
  return;
@@ -351,8 +379,10 @@ export default class EditableArray extends Editable {
351
379
 
352
380
  child.editable?.pushValue(
353
381
  value,
382
+ this.specialProps,
354
383
  { path: `${index}`, editable: child.editable },
355
384
  { __base_context },
385
+ partialChildren[index],
356
386
  );
357
387
  });
358
388
 
@@ -380,6 +410,7 @@ export default class EditableArray extends Editable {
380
410
  const existingElement = children[i];
381
411
  const templateElement = templates.keyed[key] ?? templates.unkeyed;
382
412
  const componentKey = componentKeys[i] || this.element.dataset.component;
413
+ const partialChild = partialChildren[i];
383
414
 
384
415
  const matchingChildIndex = children.findIndex(
385
416
  (child, i) => child.dataset.id === key && !moved[i],
@@ -388,7 +419,9 @@ export default class EditableArray extends Editable {
388
419
  let matchingChild = children[matchingChildIndex];
389
420
  if (!matchingChild) {
390
421
  const clone = children.find((child) => child.dataset.id === key);
391
- if (templateElement) {
422
+ if (partialChild) {
423
+ matchingChild = partialChild.cloneNode(true) as any;
424
+ } else if (templateElement) {
392
425
  matchingChild = templateElement.cloneNode(true) as any;
393
426
  matchingChild.dataset.editable = "array-item";
394
427
  } else if (componentKey) {
@@ -448,8 +481,10 @@ export default class EditableArray extends Editable {
448
481
  matchingChild.editable.parent = this;
449
482
  matchingChild.editable.pushValue(
450
483
  value,
484
+ this.specialProps,
451
485
  { path: `${i}`, editable: matchingChild.editable },
452
486
  { __base_context },
487
+ partialChild,
453
488
  );
454
489
  }
455
490
  });
@@ -502,4 +537,15 @@ export default class EditableArray extends Editable {
502
537
  );
503
538
  });
504
539
  }
540
+
541
+ getSpecialProps(
542
+ incomingSpecialProps: Record<string, unknown> = {},
543
+ ): Record<string, unknown> {
544
+ return {
545
+ ...super.getSpecialProps(incomingSpecialProps),
546
+ "@length": Array.isArray(this.propsBase)
547
+ ? this.propsBase.length
548
+ : undefined,
549
+ };
550
+ }
505
551
  }