@freelygive/canvas-utils 0.1.0

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.
@@ -0,0 +1,21 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * Helper to create canvas island props in the ["raw", value] format
5
+ * Canvas stores prop values as tuples: ["raw", actualValue]
6
+ */
7
+ declare const rawProp: (value: unknown) => [string, unknown];
8
+ /**
9
+ * Helper to create a canvas-island element HTML string
10
+ * Canvas uses these custom elements to represent components in slot content
11
+ */
12
+ declare const createCanvasIsland: (props: Record<string, unknown>) => string;
13
+ /**
14
+ * Helper to create a Canvas slot as a React element.
15
+ * The returned element has props.value containing the HTML string,
16
+ * which parseCanvasSlot reads to extract canvas-island data.
17
+ * When rendered directly (editor mode), it outputs the raw HTML.
18
+ */
19
+ declare const createCanvasSlot: (html: string) => React.ReactElement;
20
+
21
+ export { createCanvasIsland, createCanvasSlot, rawProp };
@@ -0,0 +1,19 @@
1
+ // src/testing/canvas-slots.ts
2
+ import React from "react";
3
+ var rawProp = (value) => ["raw", value];
4
+ var createCanvasIsland = (props) => {
5
+ const rawProps = {};
6
+ for (const [key, value] of Object.entries(props)) {
7
+ rawProps[key] = rawProp(value);
8
+ }
9
+ const propsJson = JSON.stringify(rawProps).replace(/"/g, "&quot;");
10
+ return `<canvas-island props="${propsJson}"></canvas-island>`;
11
+ };
12
+ var CanvasSlot = ({ value }) => React.createElement("div", { dangerouslySetInnerHTML: { __html: value } });
13
+ var createCanvasSlot = (html) => React.createElement(CanvasSlot, { value: html });
14
+ export {
15
+ createCanvasIsland,
16
+ createCanvasSlot,
17
+ rawProp
18
+ };
19
+ //# sourceMappingURL=canvas-slots.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/testing/canvas-slots.ts"],"sourcesContent":["import React from 'react';\n\n/**\n * Helper to create canvas island props in the [\"raw\", value] format\n * Canvas stores prop values as tuples: [\"raw\", actualValue]\n */\nexport const rawProp = (value: unknown): [string, unknown] => ['raw', value];\n\n/**\n * Helper to create a canvas-island element HTML string\n * Canvas uses these custom elements to represent components in slot content\n */\nexport const createCanvasIsland = (props: Record<string, unknown>): string => {\n const rawProps: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(props)) {\n rawProps[key] = rawProp(value);\n }\n const propsJson = JSON.stringify(rawProps).replace(/\"/g, '&quot;');\n return `<canvas-island props=\"${propsJson}\"></canvas-island>`;\n};\n\n/**\n * React component that simulates a Canvas slot element.\n * Renders the slot HTML via dangerouslySetInnerHTML so canvas-island\n * custom elements appear in the DOM when rendered directly (editor mode).\n * Also exposes `value` as a prop so parseCanvasSlot can read it.\n */\nconst CanvasSlot = ({ value }: { value: string }) =>\n React.createElement('div', { dangerouslySetInnerHTML: { __html: value } });\n\n/**\n * Helper to create a Canvas slot as a React element.\n * The returned element has props.value containing the HTML string,\n * which parseCanvasSlot reads to extract canvas-island data.\n * When rendered directly (editor mode), it outputs the raw HTML.\n */\nexport const createCanvasSlot = (html: string): React.ReactElement =>\n React.createElement(CanvasSlot, { value: html });\n"],"mappings":";AAAA,OAAO,WAAW;AAMX,IAAM,UAAU,CAAC,UAAsC,CAAC,OAAO,KAAK;AAMpE,IAAM,qBAAqB,CAAC,UAA2C;AAC5E,QAAM,WAAoC,CAAC;AAC3C,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,aAAS,GAAG,IAAI,QAAQ,KAAK;AAAA,EAC/B;AACA,QAAM,YAAY,KAAK,UAAU,QAAQ,EAAE,QAAQ,MAAM,QAAQ;AACjE,SAAO,yBAAyB,SAAS;AAC3C;AAQA,IAAM,aAAa,CAAC,EAAE,MAAM,MAC1B,MAAM,cAAc,OAAO,EAAE,yBAAyB,EAAE,QAAQ,MAAM,EAAE,CAAC;AAQpE,IAAM,mBAAmB,CAAC,SAC/B,MAAM,cAAc,YAAY,EAAE,OAAO,KAAK,CAAC;","names":[]}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Enable Canvas editor mode for a story
3
+ * Sets drupalSettings.canvas and cleans up on unmount
4
+ */
5
+ declare const enableEditorMode: () => void;
6
+ /**
7
+ * Disable Canvas editor mode
8
+ * Removes drupalSettings.canvas
9
+ */
10
+ declare const disableEditorMode: () => void;
11
+
12
+ export { disableEditorMode, enableEditorMode };
@@ -0,0 +1,18 @@
1
+ // src/testing/editor-mode.ts
2
+ var enableEditorMode = () => {
3
+ window.drupalSettings = {
4
+ ...window.drupalSettings,
5
+ canvas: {}
6
+ };
7
+ };
8
+ var disableEditorMode = () => {
9
+ const win = window;
10
+ if (win.drupalSettings?.canvas !== void 0) {
11
+ delete win.drupalSettings.canvas;
12
+ }
13
+ };
14
+ export {
15
+ disableEditorMode,
16
+ enableEditorMode
17
+ };
18
+ //# sourceMappingURL=editor-mode.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/testing/editor-mode.ts"],"sourcesContent":["/**\n * Enable Canvas editor mode for a story\n * Sets drupalSettings.canvas and cleans up on unmount\n */\nexport const enableEditorMode = () => {\n (\n window as Window & { drupalSettings?: Record<string, unknown> }\n ).drupalSettings = {\n ...(window as Window & { drupalSettings?: Record<string, unknown> })\n .drupalSettings,\n canvas: {},\n };\n};\n\n/**\n * Disable Canvas editor mode\n * Removes drupalSettings.canvas\n */\nexport const disableEditorMode = () => {\n const win = window as Window & { drupalSettings?: { canvas?: unknown } };\n if (win.drupalSettings?.canvas !== undefined) {\n delete win.drupalSettings.canvas;\n }\n};\n"],"mappings":";AAIO,IAAM,mBAAmB,MAAM;AACpC,EACE,OACA,iBAAiB;AAAA,IACjB,GAAI,OACD;AAAA,IACH,QAAQ,CAAC;AAAA,EACX;AACF;AAMO,IAAM,oBAAoB,MAAM;AACrC,QAAM,MAAM;AACZ,MAAI,IAAI,gBAAgB,WAAW,QAAW;AAC5C,WAAO,IAAI,eAAe;AAAA,EAC5B;AACF;","names":[]}
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@freelygive/canvas-utils",
3
+ "version": "0.1.0",
4
+ "description": "Canvas utility components and test helpers for Drupal Canvas projects.",
5
+ "type": "module",
6
+ "files": [
7
+ "src/components",
8
+ "dist"
9
+ ],
10
+ "exports": {
11
+ "./testing/canvas-slots": {
12
+ "types": "./dist/canvas-slots.d.ts",
13
+ "default": "./dist/canvas-slots.js"
14
+ },
15
+ "./testing/editor-mode": {
16
+ "types": "./dist/editor-mode.d.ts",
17
+ "default": "./dist/editor-mode.js"
18
+ }
19
+ },
20
+ "npmScaffold": {
21
+ "file-mapping": {
22
+ "components/utils_editor_note": "src/components/utils_editor_note",
23
+ "components/utils_slots": "src/components/utils_slots",
24
+ "components/utils_entity": "src/components/utils_entity"
25
+ }
26
+ },
27
+ "scripts": {
28
+ "build": "tsup",
29
+ "test": "echo \"No tests yet\""
30
+ },
31
+ "dependencies": {
32
+ "@freelygive/npm-scaffold": "^0.3.1"
33
+ },
34
+ "peerDependencies": {
35
+ "react": ">=18"
36
+ },
37
+ "devDependencies": {
38
+ "@types/react": "^19.0.0",
39
+ "react": "^19.2.0",
40
+ "tsup": "^8.0.0",
41
+ "typescript": "^5.0.0"
42
+ },
43
+ "license": "GPL-2.0-or-later"
44
+ }
@@ -0,0 +1,7 @@
1
+ name: 'Utility: Editor Note'
2
+ machineName: utils_editor_note
3
+ status: false
4
+ required: []
5
+ props:
6
+ properties: []
7
+ slots: []
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Detect if we're in Canvas page editor mode
4
+ * Returns true when the component is being edited in the Canvas UI
5
+ */ export const isCanvasEditorMode = ()=>{
6
+ var _window_drupalSettings;
7
+ if (typeof window === 'undefined') return false;
8
+ // Check for drupalSettings.canvas (set in editor mode)
9
+ if (((_window_drupalSettings = window.drupalSettings) === null || _window_drupalSettings === void 0 ? void 0 : _window_drupalSettings.canvas) !== undefined) {
10
+ return true;
11
+ }
12
+ try {
13
+ var _window_top_location, _window_top;
14
+ const pathname = ((_window_top = window.top) === null || _window_top === void 0 ? void 0 : (_window_top_location = _window_top.location) === null || _window_top_location === void 0 ? void 0 : _window_top_location.pathname) || '';
15
+ // Check if in Canvas editor (but not in component code editor)
16
+ if (pathname.startsWith('/canvas/')) {
17
+ return true;
18
+ }
19
+ } catch (unused) {
20
+ // Cross-origin access blocked - likely in an iframe
21
+ return false;
22
+ }
23
+ return false;
24
+ };
25
+ /**
26
+ * Editor note component for displaying messages only visible in Canvas editor mode
27
+ * Used to explain runtime behavior that differs from editor preview
28
+ */ const EditorNote = ({ children })=>/*#__PURE__*/ _jsx("div", {
29
+ className: "border-2 border-dashed border-black/30 bg-cream/50 px-6 py-4 text-sm text-black/70",
30
+ children: children
31
+ });
32
+ export default EditorNote;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Detect if we're in Canvas page editor mode
3
+ * Returns true when the component is being edited in the Canvas UI
4
+ */
5
+ export const isCanvasEditorMode = () => {
6
+ if (typeof window === 'undefined') return false;
7
+
8
+ // Check for drupalSettings.canvas (set in editor mode)
9
+ if (window.drupalSettings?.canvas !== undefined) {
10
+ return true;
11
+ }
12
+
13
+ try {
14
+ const pathname = window.top?.location?.pathname || '';
15
+ // Check if in Canvas editor (but not in component code editor)
16
+ if (pathname.startsWith('/canvas/')) {
17
+ return true;
18
+ }
19
+ } catch {
20
+ // Cross-origin access blocked - likely in an iframe
21
+ return false;
22
+ }
23
+
24
+ return false;
25
+ };
26
+
27
+ /**
28
+ * Editor note component for displaying messages only visible in Canvas editor mode
29
+ * Used to explain runtime behavior that differs from editor preview
30
+ */
31
+ const EditorNote = ({ children }) => (
32
+ <div className="border-2 border-dashed border-black/30 bg-cream/50 px-6 py-4 text-sm text-black/70">
33
+ {children}
34
+ </div>
35
+ );
36
+
37
+ export default EditorNote;
@@ -0,0 +1,7 @@
1
+ name: 'Utility: Entity'
2
+ machineName: utils_entity
3
+ status: false
4
+ required: []
5
+ props:
6
+ properties: []
7
+ slots: []
@@ -0,0 +1,74 @@
1
+ function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
2
+ try {
3
+ var info = gen[key](arg);
4
+ var value = info.value;
5
+ } catch (error) {
6
+ reject(error);
7
+ return;
8
+ }
9
+ if (info.done) {
10
+ resolve(value);
11
+ } else {
12
+ Promise.resolve(value).then(_next, _throw);
13
+ }
14
+ }
15
+ function _async_to_generator(fn) {
16
+ return function() {
17
+ var self = this, args = arguments;
18
+ return new Promise(function(resolve, reject) {
19
+ var gen = fn.apply(self, args);
20
+ function _next(value) {
21
+ asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value);
22
+ }
23
+ function _throw(err) {
24
+ asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err);
25
+ }
26
+ _next(undefined);
27
+ });
28
+ };
29
+ }
30
+ import { getPageData, JsonApiClient } from 'drupal-canvas';
31
+ import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
32
+ import useSWR from 'swr';
33
+ /**
34
+ * Fetch the main entity for the current page using SWR.
35
+ * Uses Canvas mainEntity (when available) with fallback to title-based lookup.
36
+ * @param {string} entityType - The JSON:API entity type (e.g., 'node--news_article')
37
+ * @param {object} options - Fetch options
38
+ * @param {string[]} options.includes - Relationships to include
39
+ * @param {object} options.fields - Sparse fieldset map (e.g., { 'node--news_article': ['title'] })
40
+ * @returns {{ data: object|undefined, error: Error|undefined, isLoading: boolean }}
41
+ */ export const useMainEntity = (entityType, { includes = [], fields = {} } = {})=>{
42
+ const pageData = getPageData();
43
+ const { pageTitle } = pageData;
44
+ // mainEntity will be available in future Canvas versions
45
+ const mainEntity = pageData.mainEntity;
46
+ const entityId = (mainEntity === null || mainEntity === void 0 ? void 0 : mainEntity.uuid) || pageTitle || null;
47
+ return useSWR(entityId ? [
48
+ 'mainEntity',
49
+ entityType,
50
+ entityId
51
+ ] : null, ()=>_async_to_generator(function*() {
52
+ const params = new DrupalJsonApiParams();
53
+ if (mainEntity === null || mainEntity === void 0 ? void 0 : mainEntity.uuid) {
54
+ params.addFilter('id', mainEntity.uuid);
55
+ } else {
56
+ params.addFilter('title', pageTitle);
57
+ }
58
+ if (includes.length > 0) {
59
+ params.addInclude(includes);
60
+ }
61
+ for (const [type, fieldList] of Object.entries(fields)){
62
+ params.addFields(type, fieldList);
63
+ }
64
+ const client = new JsonApiClient();
65
+ const queryString = params.getQueryString();
66
+ const data = yield client.getCollection(entityType, {
67
+ queryString
68
+ });
69
+ return (data === null || data === void 0 ? void 0 : data[0]) || null;
70
+ })());
71
+ };
72
+ // Empty default export for component compatibility
73
+ const UtilsEntity = ()=>null;
74
+ export default UtilsEntity;
@@ -0,0 +1,53 @@
1
+ import { getPageData, JsonApiClient } from 'drupal-canvas';
2
+ import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
3
+ import useSWR from 'swr';
4
+
5
+ /**
6
+ * Fetch the main entity for the current page using SWR.
7
+ * Uses Canvas mainEntity (when available) with fallback to title-based lookup.
8
+ * @param {string} entityType - The JSON:API entity type (e.g., 'node--news_article')
9
+ * @param {object} options - Fetch options
10
+ * @param {string[]} options.includes - Relationships to include
11
+ * @param {object} options.fields - Sparse fieldset map (e.g., { 'node--news_article': ['title'] })
12
+ * @returns {{ data: object|undefined, error: Error|undefined, isLoading: boolean }}
13
+ */
14
+ export const useMainEntity = (
15
+ entityType,
16
+ { includes = [], fields = {} } = {},
17
+ ) => {
18
+ const pageData = getPageData();
19
+ const { pageTitle } = pageData;
20
+ // mainEntity will be available in future Canvas versions
21
+ const mainEntity = pageData.mainEntity;
22
+ const entityId = mainEntity?.uuid || pageTitle || null;
23
+
24
+ return useSWR(
25
+ entityId ? ['mainEntity', entityType, entityId] : null,
26
+ async () => {
27
+ const params = new DrupalJsonApiParams();
28
+
29
+ if (mainEntity?.uuid) {
30
+ params.addFilter('id', mainEntity.uuid);
31
+ } else {
32
+ params.addFilter('title', pageTitle);
33
+ }
34
+
35
+ if (includes.length > 0) {
36
+ params.addInclude(includes);
37
+ }
38
+
39
+ for (const [type, fieldList] of Object.entries(fields)) {
40
+ params.addFields(type, fieldList);
41
+ }
42
+
43
+ const client = new JsonApiClient();
44
+ const queryString = params.getQueryString();
45
+ const data = await client.getCollection(entityType, { queryString });
46
+ return data?.[0] || null;
47
+ },
48
+ );
49
+ };
50
+
51
+ // Empty default export for component compatibility
52
+ const UtilsEntity = () => null;
53
+ export default UtilsEntity;
@@ -0,0 +1,7 @@
1
+ name: 'Utility: Slots'
2
+ machineName: utils_slots
3
+ status: false
4
+ required: []
5
+ props:
6
+ properties: []
7
+ slots: []
@@ -0,0 +1,79 @@
1
+ import React from 'react';
2
+ /**
3
+ * Flatten React children, handling nested fragments
4
+ * Useful for processing slot content that may contain fragments
5
+ * @param {React.ReactNode} children - React children to flatten
6
+ * @returns {Array} - Flattened array of children
7
+ */ const flattenChildren = (children)=>{
8
+ const result = [];
9
+ React.Children.forEach(children, (child)=>{
10
+ if (!child) return;
11
+ // Check if it's a Fragment (type is Symbol(react.fragment))
12
+ if ((child === null || child === void 0 ? void 0 : child.type) === React.Fragment) {
13
+ result.push(...flattenChildren(child.props.children));
14
+ } else {
15
+ result.push(child);
16
+ }
17
+ });
18
+ return result;
19
+ };
20
+ /**
21
+ * Parse Canvas slot format to extract component props
22
+ * Canvas slots contain HTML with canvas-island elements that hold component data
23
+ * @param {object} slot - The Canvas slot object
24
+ * @returns {Array|null} - Array of parsed component objects with props, or null
25
+ */ const parseCanvasSlot = (slot)=>{
26
+ var _slot_props;
27
+ // Check if this is Canvas slot format (object with props.value containing HTML)
28
+ if (typeof slot !== 'object' || !(slot === null || slot === void 0 ? void 0 : (_slot_props = slot.props) === null || _slot_props === void 0 ? void 0 : _slot_props.value)) {
29
+ return null;
30
+ }
31
+ const html = slot.props.value;
32
+ if (typeof html !== 'string' || typeof window === 'undefined') {
33
+ return null;
34
+ }
35
+ try {
36
+ // Use DOMParser for reliable HTML parsing
37
+ const parser = new DOMParser();
38
+ const doc = parser.parseFromString(html, 'text/html');
39
+ const islands = doc.querySelectorAll('canvas-island[props]');
40
+ if (islands.length === 0) return null;
41
+ const results = [];
42
+ islands.forEach((island)=>{
43
+ try {
44
+ const propsAttr = island.getAttribute('props');
45
+ if (!propsAttr) return;
46
+ const rawProps = JSON.parse(propsAttr);
47
+ // Convert from ["raw", value] format to just value
48
+ const props = {};
49
+ for (const [key, val] of Object.entries(rawProps)){
50
+ if (Array.isArray(val) && val[0] === 'raw') {
51
+ props[key] = val[1];
52
+ } else {
53
+ props[key] = val;
54
+ }
55
+ }
56
+ results.push({
57
+ props
58
+ });
59
+ } catch (unused) {
60
+ // Skip malformed entries
61
+ }
62
+ });
63
+ return results.length > 0 ? results : null;
64
+ } catch (unused) {
65
+ return null;
66
+ }
67
+ };
68
+ /**
69
+ * Get children from a slot - handles both Canvas slot format and React children
70
+ * @param {object|React.ReactNode} slot - The slot content
71
+ * @returns {Array} - Array of children (parsed objects or React elements)
72
+ */ export const getSlotChildren = (slot)=>{
73
+ if (!slot) return [];
74
+ const parsed = parseCanvasSlot(slot);
75
+ return parsed || flattenChildren(slot);
76
+ };
77
+ // Empty default export for component compatibility
78
+ const UtilsSlots = ()=>null;
79
+ export default UtilsSlots;
@@ -0,0 +1,91 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * Flatten React children, handling nested fragments
5
+ * Useful for processing slot content that may contain fragments
6
+ * @param {React.ReactNode} children - React children to flatten
7
+ * @returns {Array} - Flattened array of children
8
+ */
9
+ const flattenChildren = (children) => {
10
+ const result = [];
11
+ React.Children.forEach(children, (child) => {
12
+ if (!child) return;
13
+ // Check if it's a Fragment (type is Symbol(react.fragment))
14
+ if (child?.type === React.Fragment) {
15
+ result.push(...flattenChildren(child.props.children));
16
+ } else {
17
+ result.push(child);
18
+ }
19
+ });
20
+ return result;
21
+ };
22
+
23
+ /**
24
+ * Parse Canvas slot format to extract component props
25
+ * Canvas slots contain HTML with canvas-island elements that hold component data
26
+ * @param {object} slot - The Canvas slot object
27
+ * @returns {Array|null} - Array of parsed component objects with props, or null
28
+ */
29
+ const parseCanvasSlot = (slot) => {
30
+ // Check if this is Canvas slot format (object with props.value containing HTML)
31
+ if (typeof slot !== 'object' || !slot?.props?.value) {
32
+ return null;
33
+ }
34
+
35
+ const html = slot.props.value;
36
+ if (typeof html !== 'string' || typeof window === 'undefined') {
37
+ return null;
38
+ }
39
+
40
+ try {
41
+ // Use DOMParser for reliable HTML parsing
42
+ const parser = new DOMParser();
43
+ const doc = parser.parseFromString(html, 'text/html');
44
+ const islands = doc.querySelectorAll('canvas-island[props]');
45
+
46
+ if (islands.length === 0) return null;
47
+
48
+ const results = [];
49
+ islands.forEach((island) => {
50
+ try {
51
+ const propsAttr = island.getAttribute('props');
52
+ if (!propsAttr) return;
53
+
54
+ const rawProps = JSON.parse(propsAttr);
55
+
56
+ // Convert from ["raw", value] format to just value
57
+ const props = {};
58
+ for (const [key, val] of Object.entries(rawProps)) {
59
+ if (Array.isArray(val) && val[0] === 'raw') {
60
+ props[key] = val[1];
61
+ } else {
62
+ props[key] = val;
63
+ }
64
+ }
65
+
66
+ results.push({ props });
67
+ } catch {
68
+ // Skip malformed entries
69
+ }
70
+ });
71
+
72
+ return results.length > 0 ? results : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ };
77
+
78
+ /**
79
+ * Get children from a slot - handles both Canvas slot format and React children
80
+ * @param {object|React.ReactNode} slot - The slot content
81
+ * @returns {Array} - Array of children (parsed objects or React elements)
82
+ */
83
+ export const getSlotChildren = (slot) => {
84
+ if (!slot) return [];
85
+ const parsed = parseCanvasSlot(slot);
86
+ return parsed || flattenChildren(slot);
87
+ };
88
+
89
+ // Empty default export for component compatibility
90
+ const UtilsSlots = () => null;
91
+ export default UtilsSlots;