@cloudcannon/editable-regions 0.0.20-rc.3 → 0.0.21

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.
@@ -14,24 +14,26 @@
14
14
  * }} ExtendedWindow
15
15
  */
16
16
 
17
- /** @type {ExtendedWindow} */
18
- const extendedWindow = /** @type {any} */ (window);
17
+ /** @type {ExtendedWindow | undefined} */
18
+ const extendedWindow = /** @type {any} */ (
19
+ typeof window === "undefined" ? undefined : window
20
+ );
19
21
 
20
- /** @type {CloudCannonVisualEditorAPIV1} */
22
+ /** @type {CloudCannonVisualEditorAPIV1 | undefined} */
21
23
  let _cloudcannon;
22
24
 
23
25
  /** @type {Promise<void>} */
24
26
  export const apiLoadedPromise = new Promise((resolve) => {
25
- if (extendedWindow.CloudCannonAPI) {
27
+ if (extendedWindow?.CloudCannonAPI) {
26
28
  _cloudcannon = /** @type {any} */ (
27
29
  extendedWindow.CloudCannonAPI.useVersion("v1", true)
28
30
  );
29
31
  resolve();
30
- } else {
32
+ } else if (typeof document !== "undefined") {
31
33
  document.addEventListener(
32
34
  "cloudcannon:load",
33
35
  () => {
34
- if (extendedWindow.CloudCannonAPI) {
36
+ if (extendedWindow?.CloudCannonAPI) {
35
37
  _cloudcannon = /** @type {any} */ (
36
38
  extendedWindow.CloudCannonAPI.useVersion("v1", true)
37
39
  );
@@ -48,6 +50,9 @@ export const apiLoadedPromise = new Promise((resolve) => {
48
50
  * @param {ComponentRenderer} renderer
49
51
  */
50
52
  export const addEditableComponentRenderer = (key, renderer) => {
53
+ if (!extendedWindow) {
54
+ return;
55
+ }
51
56
  extendedWindow.cc_components = extendedWindow.cc_components || {};
52
57
  extendedWindow.cc_components[key] = renderer;
53
58
  };
@@ -57,15 +62,18 @@ export const addEditableComponentRenderer = (key, renderer) => {
57
62
  * @param {ComponentRenderer} renderer
58
63
  */
59
64
  export const addEditableSnippetRenderer = (key, renderer) => {
65
+ if (!extendedWindow) {
66
+ return;
67
+ }
60
68
  extendedWindow.cc_snippets = extendedWindow.cc_snippets || {};
61
69
  extendedWindow.cc_snippets[key] = renderer;
62
70
  };
63
71
 
64
72
  export const getEditableComponentRenderers = () =>
65
- extendedWindow.cc_components ?? {};
73
+ extendedWindow?.cc_components ?? {};
66
74
 
67
75
  export const getEditableSnippetRenderers = () =>
68
- extendedWindow.cc_snippets ?? {};
76
+ extendedWindow?.cc_snippets ?? {};
69
77
 
70
78
  /**
71
79
  * Resolves CloudCannon API objects (collections, files, datasets) to plain data.
@@ -91,13 +99,16 @@ export const realizeAPIValue = async (value) => {
91
99
  };
92
100
 
93
101
  export const addCustomEditableRegion = (key, region) => {
102
+ if (!extendedWindow || typeof document === "undefined") {
103
+ return;
104
+ }
94
105
  extendedWindow.editableRegionMap ??= {};
95
106
  extendedWindow.editableRegionMap[key] = region;
96
107
  extendedWindow.hydrateDataEditableRegions(document.body);
97
108
  };
98
109
 
99
110
  export const getCustomEditableRegions = () => {
100
- return extendedWindow.editableRegionMap ?? {};
111
+ return extendedWindow?.editableRegionMap ?? {};
101
112
  };
102
113
 
103
114
  export { _cloudcannon as CloudCannon };
@@ -10,6 +10,8 @@ import {
10
10
  import { hasEditable } from "../nodes/editable.js";
11
11
  import { isEditableWebcomponent } from "./checks";
12
12
 
13
+ const DYNAMIC_EDITABLE_TYPE = "_dynamic";
14
+
13
15
  const baseEditableMap: Record<string, typeof Editable | undefined> = {
14
16
  array: EditableArray,
15
17
  "array-item": EditableArrayItem,
@@ -20,12 +22,23 @@ const baseEditableMap: Record<string, typeof Editable | undefined> = {
20
22
  };
21
23
 
22
24
  export const dehydrateDataEditableRegions = (root: Element) => {
23
- if (root instanceof HTMLElement && hasEditable(root)) {
25
+ if (
26
+ root instanceof HTMLElement &&
27
+ hasEditable(root) &&
28
+ root.dataset.editable !== DYNAMIC_EDITABLE_TYPE
29
+ ) {
24
30
  root.editable.disconnect();
25
31
  }
26
32
 
27
33
  root.querySelectorAll("[data-editable]").forEach((element) => {
28
- if (element instanceof HTMLElement && hasEditable(element)) {
34
+ if (
35
+ !(element instanceof HTMLElement) ||
36
+ element.dataset.editable === DYNAMIC_EDITABLE_TYPE
37
+ ) {
38
+ return;
39
+ }
40
+
41
+ if (hasEditable(element)) {
29
42
  element.editable.disconnect();
30
43
  }
31
44
  });
@@ -39,6 +52,7 @@ export const hydrateDataEditableRegions = (root: Element) => {
39
52
  if (
40
53
  root instanceof HTMLElement &&
41
54
  root.dataset.editable &&
55
+ root.dataset.editable !== DYNAMIC_EDITABLE_TYPE &&
42
56
  !isEditableWebcomponent(root)
43
57
  ) {
44
58
  if ("editable" in root && root.editable instanceof Editable) {
@@ -64,6 +78,10 @@ export const hydrateDataEditableRegions = (root: Element) => {
64
78
  return;
65
79
  }
66
80
 
81
+ if (element.dataset.editable === DYNAMIC_EDITABLE_TYPE) {
82
+ return;
83
+ }
84
+
67
85
  const Editable = editableMap[element.dataset.editable];
68
86
  if (!Editable) {
69
87
  const error = document.createElement("editable-region-error-card");
@@ -95,4 +113,19 @@ export const hydrateDataEditableRegions = (root: Element) => {
95
113
  });
96
114
  };
97
115
 
98
- (window as any).hydrateDataEditableRegions = hydrateDataEditableRegions;
116
+ const extendedWindow = /** @type {any} */ (window);
117
+
118
+ Object.assign(extendedWindow, {
119
+ editableRegions: {
120
+ Editable,
121
+ EditableArray,
122
+ EditableArrayItem,
123
+ EditableComponent,
124
+ EditableImage,
125
+ EditableSource,
126
+ EditableText,
127
+ },
128
+ hydrateDataEditableRegions,
129
+ });
130
+
131
+ document.dispatchEvent(new CustomEvent("editable-regions:load"));
@@ -368,9 +368,10 @@ async function generateLiveEditingSource(
368
368
 
369
369
  for (const [i, filePath] of allLiquidFiles.entries()) {
370
370
  const id = `liquidFile_${i}`;
371
- source += `import ${id} from "./${filePath}";
371
+ const importPath = toPosixPath(filePath);
372
+ source += `import ${id} from ${JSON.stringify(`./${importPath}`)};
372
373
 
373
- window.cc_liquid_files["${filePath}"] = ${id};
374
+ window.cc_liquid_files[${JSON.stringify(importPath)}] = ${id};
374
375
  `;
375
376
  }
376
377
 
@@ -537,6 +538,14 @@ async function findAllLiquidFiles(
537
538
  return allFiles;
538
539
  }
539
540
 
541
+ /**
542
+ * @param {string} filePath
543
+ * @returns {string}
544
+ */
545
+ function toPosixPath(filePath) {
546
+ return filePath.split(path.sep).join("/");
547
+ }
548
+
540
549
  /**
541
550
  * @param {Object} options
542
551
  * @param {string} options.directory
@@ -625,7 +634,8 @@ function emitImportRegistrations(liquidOptions) {
625
634
  liquidOptions?.[optionKey] ?? {},
626
635
  ).entries()) {
627
636
  const id = `${optionKey}_${i}`;
628
- imports += `\nimport ${id} from "./${file}";\n`;
637
+ const importPath = toPosixPath(String(file));
638
+ imports += `\nimport ${id} from ${JSON.stringify(`./${importPath}`)};\n`;
629
639
  body += `${registerFn}(${JSON.stringify(name)}, ${id});\n`;
630
640
  }
631
641
  }
@@ -43,7 +43,13 @@ export function parseHugoTemplateError(raw: string): ParsedHugoError {
43
43
  return { frames, call, message: rest };
44
44
  }
45
45
 
46
- export function enhanceHugoError(message: string, componentKey: string): Error {
46
+ /** An error enhanced with an optional editor-context hint. */
47
+ export type HugoError = Error & { hint?: string };
48
+
49
+ export function enhanceHugoError(
50
+ message: string,
51
+ componentKey: string,
52
+ ): HugoError {
47
53
  const parsed = parseHugoTemplateError(message);
48
54
 
49
55
  let hint = "";
@@ -76,7 +82,7 @@ export function enhanceHugoError(message: string, componentKey: string): Error {
76
82
 
77
83
  const error = new Error(
78
84
  `Failed to render Hugo component "${componentKey}": ${sentence}`,
79
- ) as Error & { hint?: string };
85
+ ) as HugoError;
80
86
 
81
87
  if (hint) {
82
88
  error.hint = hint;
@@ -334,7 +334,9 @@ func (builder *editorSiteBuilder) targetPage(target string) page.Page {
334
334
  if f == nil {
335
335
  continue
336
336
  }
337
- if filepath.Join(builder.Cfg.Base.ContentDir, f.Path()) != target {
337
+ // `target` is slash-normalized; `filepath.Join` uses the OS
338
+ // separator, so normalize it too or Windows never matches.
339
+ if filepath.ToSlash(filepath.Join(builder.Cfg.Base.ContentDir, f.Path())) != target {
338
340
  continue
339
341
  }
340
342
  return p
@@ -1,4 +1,6 @@
1
- import { createElement } from "react";
1
+ "use client";
2
+
3
+ import { createElement, useEffect, useRef } from "react";
2
4
  import { flushSync } from "react-dom";
3
5
  import { createRoot } from "react-dom/client";
4
6
  import { addEditableComponentRenderer } from "../helpers/cloudcannon.mjs";
@@ -26,3 +28,63 @@ export const registerReactComponent = (key, component) => {
26
28
 
27
29
  addEditableComponentRenderer(key, wrappedComponent);
28
30
  };
31
+
32
+ /**
33
+ * Root editable region component for React (e.g. Next.js App Router pages).
34
+ * Keeps editable regions from mutating the DOM before frameworks like Next.js finish hydrating.
35
+ *
36
+ * @param {{ tag?: string } & Record<string, any>} props
37
+ * All other props and attributes are forwarded to the rendered element.
38
+ */
39
+ export const EditableRegions = ({ tag: Tag = "div", ...attrs }) => {
40
+ const elementRef = useRef(null);
41
+ /** @type {{ current: any | null }} */
42
+ const editableRef = useRef(null);
43
+
44
+ useEffect(() => {
45
+ /** @param {any} regions */
46
+ const connectEditable = (regions) => {
47
+ if (!regions || editableRef.current) {
48
+ return;
49
+ }
50
+
51
+ editableRef.current = new regions.Editable(elementRef.current);
52
+ editableRef.current.connect();
53
+ };
54
+
55
+ const onLoad = () => {
56
+ connectEditable(/** @type {any} */ (window).editableRegions);
57
+ };
58
+
59
+ if (/** @type {any} */ (window).editableRegions) {
60
+ connectEditable(/** @type {any} */ (window).editableRegions);
61
+ } else {
62
+ document.addEventListener("editable-regions:load", onLoad, {
63
+ once: true,
64
+ });
65
+ }
66
+
67
+ return () => {
68
+ document.removeEventListener("editable-regions:load", onLoad);
69
+ editableRef.current?.disconnect();
70
+ editableRef.current = null;
71
+ };
72
+ }, []);
73
+
74
+ /** @type {Record<string, any>} */
75
+ const forwarded = {};
76
+ for (const [name, value] of Object.entries(attrs)) {
77
+ if (/^data-(prop|literal)/i.test(name)) {
78
+ continue;
79
+ }
80
+
81
+ forwarded[name] = value;
82
+ }
83
+
84
+ return createElement(Tag, {
85
+ ...forwarded,
86
+ ref: elementRef,
87
+ "data-editable": "_dynamic",
88
+ "data-prop": "",
89
+ });
90
+ };
@@ -0,0 +1,80 @@
1
+ <script>
2
+ import { onDestroy, onMount } from "svelte";
3
+
4
+ // Element type to render. Defaults to "div".
5
+ /** @type {string | undefined} */
6
+ export let tag = "div";
7
+
8
+ /** @type {any} */
9
+ let editable = null;
10
+ /** @type {HTMLElement | undefined} */
11
+ let element;
12
+ /** @type {(() => void) | null} */
13
+ let pendingLoadHandler = null;
14
+
15
+ // data-prop and data-literal attributes are controlled by this component
16
+ // itself; user-supplied ones are ignored.
17
+ $: restProps = filterProps($$restProps);
18
+
19
+ function filterProps(props) {
20
+ const filtered = {};
21
+ for (const [name, value] of Object.entries(props)) {
22
+ if (/^data-(prop|literal)/i.test(name)) {
23
+ continue;
24
+ }
25
+
26
+ filtered[name] = value;
27
+ }
28
+ return filtered;
29
+ }
30
+
31
+ onMount(() => {
32
+ /**
33
+ * @param {any} regions
34
+ */
35
+ const connectEditable = (regions) => {
36
+ if (!regions || editable) {
37
+ return;
38
+ }
39
+
40
+ editable = new regions.Editable(element);
41
+ editable.connect();
42
+ };
43
+
44
+ if (window.editableRegions) {
45
+ connectEditable(window.editableRegions);
46
+ } else {
47
+ pendingLoadHandler = () => {
48
+ connectEditable(window.editableRegions);
49
+ };
50
+ document.addEventListener(
51
+ "editable-regions:load",
52
+ pendingLoadHandler,
53
+ { once: true },
54
+ );
55
+ }
56
+ });
57
+
58
+ onDestroy(() => {
59
+ if (pendingLoadHandler) {
60
+ document.removeEventListener(
61
+ "editable-regions:load",
62
+ pendingLoadHandler,
63
+ );
64
+ pendingLoadHandler = null;
65
+ }
66
+
67
+ editable?.disconnect();
68
+ editable = null;
69
+ });
70
+ </script>
71
+
72
+ <svelte:element
73
+ this={tag}
74
+ {...restProps}
75
+ bind:this={element}
76
+ data-editable="_dynamic"
77
+ data-prop=""
78
+ >
79
+ <slot />
80
+ </svelte:element>
@@ -1,4 +1,6 @@
1
- import { addEditableComponentRenderer } from "../helpers/cloudcannon.mjs";
1
+ export { default as EditableRegions } from "./EditableRegions.svelte";
2
+
3
+ import { addEditableComponentRenderer } from "../../helpers/cloudcannon.mjs";
2
4
 
3
5
  /** @type{((component: any, args: { target: HTMLElement, props: unknown }) => void) | undefined} */
4
6
  let mount;
@@ -1,4 +1,11 @@
1
- import { createApp, h } from "vue";
1
+ import {
2
+ createApp,
3
+ defineComponent,
4
+ h,
5
+ onMounted,
6
+ onUnmounted,
7
+ ref,
8
+ } from "vue";
2
9
  import { addEditableComponentRenderer } from "../helpers/cloudcannon.mjs";
3
10
 
4
11
  /**
@@ -26,3 +33,92 @@ export const registerVueComponent = (key, component) => {
26
33
 
27
34
  addEditableComponentRenderer(key, wrappedComponent);
28
35
  };
36
+
37
+ /**
38
+ * Root editable region component for Vue.
39
+ * Keeps editable regions from mutating the DOM before frameworks like Nuxt finish hydrating.
40
+ *
41
+ * @param {string} [props.tag] - Element type to render. Defaults to "div".
42
+ * All other props and attributes are forwarded to the rendered element.
43
+ */
44
+ export const EditableRegions = defineComponent({
45
+ name: "EditableRegions",
46
+ props: {
47
+ tag: {
48
+ type: String,
49
+ default: "div",
50
+ },
51
+ },
52
+ inheritAttrs: false,
53
+ /**
54
+ * @param {{ tag: string }} props
55
+ * @param {{ attrs: any; slots: any }} context
56
+ */
57
+ setup(props, { attrs, slots }) {
58
+ const element = ref(null);
59
+ /** @type {any} */
60
+ let editable = null;
61
+ /** @type {(() => void) | null} */
62
+ let pendingLoadHandler = null;
63
+
64
+ /** @param {any} regions */
65
+ const connectEditable = (regions) => {
66
+ if (!regions || editable) {
67
+ return;
68
+ }
69
+
70
+ editable = new regions.Editable(element.value);
71
+ editable.connect();
72
+ };
73
+
74
+ onMounted(() => {
75
+ const regions = /** @type {any} */ (window).editableRegions;
76
+ if (regions) {
77
+ connectEditable(regions);
78
+ return;
79
+ }
80
+
81
+ pendingLoadHandler = () => {
82
+ connectEditable(/** @type {any} */ (window).editableRegions);
83
+ };
84
+ document.addEventListener("editable-regions:load", pendingLoadHandler, {
85
+ once: true,
86
+ });
87
+ });
88
+
89
+ onUnmounted(() => {
90
+ if (pendingLoadHandler) {
91
+ document.removeEventListener(
92
+ "editable-regions:load",
93
+ pendingLoadHandler,
94
+ );
95
+ pendingLoadHandler = null;
96
+ }
97
+
98
+ editable?.disconnect();
99
+ editable = null;
100
+ });
101
+
102
+ return () => {
103
+ /** @type {Record<string, any>} */
104
+ const forwarded = {};
105
+ for (const [name, value] of Object.entries(attrs)) {
106
+ if (/^data-(prop|literal)/i.test(name)) {
107
+ continue;
108
+ }
109
+ forwarded[name] = value;
110
+ }
111
+
112
+ return h(
113
+ props.tag,
114
+ {
115
+ ...forwarded,
116
+ ref: element,
117
+ "data-editable": "_dynamic",
118
+ "data-prop": "",
119
+ },
120
+ /** @type {any} */ (slots.default?.()),
121
+ );
122
+ };
123
+ },
124
+ });
@@ -18,12 +18,16 @@ export const hasEditableArrayItem = <T extends object>(
18
18
  export default class EditableArrayItem extends EditableComponent {
19
19
  parent: EditableArray | null = null;
20
20
 
21
- protected declare controlsElement?: EditableArrayItemControls;
21
+ declare protected controlsElement?: EditableArrayItemControls;
22
22
 
23
23
  private inputConfig?: any;
24
24
  private structureStrings: string[] = [];
25
25
 
26
26
  shouldMount(): boolean {
27
+ if (this.mounted) {
28
+ return false;
29
+ }
30
+
27
31
  return this.value !== undefined;
28
32
  }
29
33
 
@@ -25,13 +25,19 @@ export default class EditableComponent extends Editable {
25
25
  }
26
26
 
27
27
  shouldMount(): boolean {
28
- if (super.shouldMount()) {
29
- return true;
28
+ if (this.mounted) {
29
+ return false;
30
30
  }
31
31
 
32
- return !Object.keys(this.element.dataset).some((key) =>
33
- key.startsWith("prop"),
32
+ const hasNoProps = Object.keys(this.element.dataset).every(
33
+ (key) => !key.startsWith("prop"),
34
34
  );
35
+
36
+ if (hasNoProps) {
37
+ return true;
38
+ }
39
+
40
+ return super.shouldMount();
35
41
  }
36
42
 
37
43
  validateConfiguration(): boolean {