@batterai/strapi-plugin-canvas 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.
Files changed (152) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +16 -0
  3. package/README.md +131 -0
  4. package/dist/admin/appearance.d.ts +12 -0
  5. package/dist/admin/appearance.d.ts.map +1 -0
  6. package/dist/admin/appearance.js +44 -0
  7. package/dist/admin/appearance.js.map +1 -0
  8. package/dist/admin/block-packages.d.ts +4 -0
  9. package/dist/admin/block-packages.d.ts.map +1 -0
  10. package/dist/admin/block-packages.js +14 -0
  11. package/dist/admin/block-packages.js.map +1 -0
  12. package/dist/admin/client/content-model.d.ts +3 -0
  13. package/dist/admin/client/content-model.d.ts.map +1 -0
  14. package/dist/admin/client/content-model.js +76 -0
  15. package/dist/admin/client/content-model.js.map +1 -0
  16. package/dist/admin/client/host-services.d.ts +4 -0
  17. package/dist/admin/client/host-services.d.ts.map +1 -0
  18. package/dist/admin/client/host-services.js +141 -0
  19. package/dist/admin/client/host-services.js.map +1 -0
  20. package/dist/admin/index.d.ts +37 -0
  21. package/dist/admin/index.d.ts.map +1 -0
  22. package/dist/admin/index.js +34 -0
  23. package/dist/admin/index.js.map +1 -0
  24. package/dist/admin/pages/App.d.ts +4 -0
  25. package/dist/admin/pages/App.d.ts.map +1 -0
  26. package/dist/admin/pages/App.js +50 -0
  27. package/dist/admin/pages/App.js.map +1 -0
  28. package/dist/admin/translations.d.ts +4 -0
  29. package/dist/admin/translations.d.ts.map +1 -0
  30. package/dist/admin/translations.js +16 -0
  31. package/dist/admin/translations.js.map +1 -0
  32. package/dist/constants.d.ts +14 -0
  33. package/dist/constants.d.ts.map +1 -0
  34. package/dist/constants.js +18 -0
  35. package/dist/constants.js.map +1 -0
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/server/bootstrap.d.ts +4 -0
  41. package/dist/server/bootstrap.d.ts.map +1 -0
  42. package/dist/server/bootstrap.js +37 -0
  43. package/dist/server/bootstrap.js.map +1 -0
  44. package/dist/server/config/index.d.ts +24 -0
  45. package/dist/server/config/index.d.ts.map +1 -0
  46. package/dist/server/config/index.js +33 -0
  47. package/dist/server/config/index.js.map +1 -0
  48. package/dist/server/content-types/canvas-page/schema.d.ts +45 -0
  49. package/dist/server/content-types/canvas-page/schema.d.ts.map +1 -0
  50. package/dist/server/content-types/canvas-page/schema.js +21 -0
  51. package/dist/server/content-types/canvas-page/schema.js.map +1 -0
  52. package/dist/server/content-types/canvas-site/schema.d.ts +41 -0
  53. package/dist/server/content-types/canvas-site/schema.d.ts.map +1 -0
  54. package/dist/server/content-types/canvas-site/schema.js +20 -0
  55. package/dist/server/content-types/canvas-site/schema.js.map +1 -0
  56. package/dist/server/content-types/index.d.ts +90 -0
  57. package/dist/server/content-types/index.d.ts.map +1 -0
  58. package/dist/server/content-types/index.js +7 -0
  59. package/dist/server/content-types/index.js.map +1 -0
  60. package/dist/server/controllers/canvas.d.ts +48 -0
  61. package/dist/server/controllers/canvas.d.ts.map +1 -0
  62. package/dist/server/controllers/canvas.js +409 -0
  63. package/dist/server/controllers/canvas.js.map +1 -0
  64. package/dist/server/controllers/index.d.ts +448 -0
  65. package/dist/server/controllers/index.d.ts.map +1 -0
  66. package/dist/server/controllers/index.js +5 -0
  67. package/dist/server/controllers/index.js.map +1 -0
  68. package/dist/server/index.d.ts +645 -0
  69. package/dist/server/index.d.ts.map +1 -0
  70. package/dist/server/index.js +21 -0
  71. package/dist/server/index.js.map +1 -0
  72. package/dist/server/license.d.ts +13 -0
  73. package/dist/server/license.d.ts.map +1 -0
  74. package/dist/server/license.js +24 -0
  75. package/dist/server/license.js.map +1 -0
  76. package/dist/server/policies/is-admin-authenticated.d.ts +2 -0
  77. package/dist/server/policies/is-admin-authenticated.d.ts.map +1 -0
  78. package/dist/server/policies/is-admin-authenticated.js +4 -0
  79. package/dist/server/policies/is-admin-authenticated.js.map +1 -0
  80. package/dist/server/register.d.ts +2 -0
  81. package/dist/server/register.d.ts.map +1 -0
  82. package/dist/server/register.js +4 -0
  83. package/dist/server/register.js.map +1 -0
  84. package/dist/server/routes/admin.d.ts +18 -0
  85. package/dist/server/routes/admin.d.ts.map +1 -0
  86. package/dist/server/routes/admin.js +38 -0
  87. package/dist/server/routes/admin.js.map +1 -0
  88. package/dist/server/routes/index.d.ts +31 -0
  89. package/dist/server/routes/index.d.ts.map +1 -0
  90. package/dist/server/routes/index.js +7 -0
  91. package/dist/server/routes/index.js.map +1 -0
  92. package/dist/server/routes/public.d.ts +13 -0
  93. package/dist/server/routes/public.d.ts.map +1 -0
  94. package/dist/server/routes/public.js +7 -0
  95. package/dist/server/routes/public.js.map +1 -0
  96. package/dist/server/services/collaboration-service.d.ts +24 -0
  97. package/dist/server/services/collaboration-service.d.ts.map +1 -0
  98. package/dist/server/services/collaboration-service.js +164 -0
  99. package/dist/server/services/collaboration-service.js.map +1 -0
  100. package/dist/server/services/document-command-service.d.ts +34 -0
  101. package/dist/server/services/document-command-service.d.ts.map +1 -0
  102. package/dist/server/services/document-command-service.js +418 -0
  103. package/dist/server/services/document-command-service.js.map +1 -0
  104. package/dist/server/services/document-service.d.ts +14 -0
  105. package/dist/server/services/document-service.d.ts.map +1 -0
  106. package/dist/server/services/document-service.js +144 -0
  107. package/dist/server/services/document-service.js.map +1 -0
  108. package/dist/server/services/index.d.ts +68 -0
  109. package/dist/server/services/index.d.ts.map +1 -0
  110. package/dist/server/services/index.js +28 -0
  111. package/dist/server/services/index.js.map +1 -0
  112. package/dist/server/services/page-doc-store.d.ts +59 -0
  113. package/dist/server/services/page-doc-store.d.ts.map +1 -0
  114. package/dist/server/services/page-doc-store.js +285 -0
  115. package/dist/server/services/page-doc-store.js.map +1 -0
  116. package/dist/server/services/site-store.d.ts +43 -0
  117. package/dist/server/services/site-store.d.ts.map +1 -0
  118. package/dist/server/services/site-store.js +212 -0
  119. package/dist/server/services/site-store.js.map +1 -0
  120. package/dist/server-cjs/index.js +17381 -0
  121. package/dist/server-cjs/package.json +3 -0
  122. package/package.json +77 -0
  123. package/src/admin/appearance.ts +56 -0
  124. package/src/admin/block-packages.ts +16 -0
  125. package/src/admin/client/content-model.ts +88 -0
  126. package/src/admin/client/host-services.ts +172 -0
  127. package/src/admin/index.tsx +56 -0
  128. package/src/admin/pages/App.tsx +56 -0
  129. package/src/admin/strapi-admin.d.ts +1 -0
  130. package/src/admin/translations.ts +23 -0
  131. package/src/constants.ts +21 -0
  132. package/src/index.ts +11 -0
  133. package/src/server/bootstrap.ts +45 -0
  134. package/src/server/config/index.ts +46 -0
  135. package/src/server/content-types/canvas-page/schema.ts +20 -0
  136. package/src/server/content-types/canvas-site/schema.ts +19 -0
  137. package/src/server/content-types/index.ts +7 -0
  138. package/src/server/controllers/canvas.ts +468 -0
  139. package/src/server/controllers/index.ts +5 -0
  140. package/src/server/index.ts +21 -0
  141. package/src/server/license.ts +42 -0
  142. package/src/server/policies/is-admin-authenticated.ts +3 -0
  143. package/src/server/register.ts +3 -0
  144. package/src/server/routes/admin.ts +40 -0
  145. package/src/server/routes/index.ts +7 -0
  146. package/src/server/routes/public.ts +6 -0
  147. package/src/server/services/collaboration-service.ts +180 -0
  148. package/src/server/services/document-command-service.ts +495 -0
  149. package/src/server/services/document-service.ts +162 -0
  150. package/src/server/services/index.ts +41 -0
  151. package/src/server/services/page-doc-store.ts +348 -0
  152. package/src/server/services/site-store.ts +243 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@batterai/strapi-plugin-canvas",
3
+ "version": "0.1.0",
4
+ "license": "BUSL-1.1",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ },
11
+ "./strapi-admin": {
12
+ "source": "./src/admin/index.tsx",
13
+ "import": "./dist/admin/index.js",
14
+ "default": "./dist/admin/index.js"
15
+ },
16
+ "./strapi-server": {
17
+ "source": "./src/server/index.ts",
18
+ "import": "./dist/server/index.js",
19
+ "require": "./dist/server-cjs/index.js",
20
+ "default": "./dist/server/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "scripts": {
25
+ "check": "tsc --noEmit",
26
+ "build": "node ../../scripts/build-package.mjs",
27
+ "build:artifacts": "bun run build:server-cjs",
28
+ "build:server-cjs": "bun build src/server/index.ts --format=cjs --target=node --outfile=dist/server-cjs/index.js && node -e \"require('fs').writeFileSync('dist/server-cjs/package.json', JSON.stringify({ type: 'commonjs' }, null, 2) + '\\n')\"",
29
+ "prepack": "bun run build"
30
+ },
31
+ "dependencies": {
32
+ "@batterai/bind-core": "^0.1.0",
33
+ "@batterai/canvas-blocks": "^0.1.0",
34
+ "@batterai/collab-protocol": "^0.1.0",
35
+ "@batterai/collab-server": "^0.1.0",
36
+ "@batterai/contract": "^0.1.0",
37
+ "@batterai/editor-shell": "^0.1.0",
38
+ "@batterai/host": "^0.1.0",
39
+ "@batterai/trust": "^0.1.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@strapi/admin": ">=5 <6",
43
+ "@strapi/strapi": ">=5 <6",
44
+ "react": ">=18.2 <20",
45
+ "react-dom": ">=18.2 <20",
46
+ "styled-components": ">=6 <7"
47
+ },
48
+ "devDependencies": {
49
+ "@batterai/block-sdk": "^0.1.0",
50
+ "@types/node": "^22.13.0",
51
+ "@types/react": "^19.0.0",
52
+ "@types/react-dom": "^19.0.0",
53
+ "react": "19.2.7",
54
+ "react-dom": "19.2.7"
55
+ },
56
+ "main": "./dist/index.js",
57
+ "types": "./dist/index.d.ts",
58
+ "files": [
59
+ "dist",
60
+ "src",
61
+ "LICENSE",
62
+ "README.md",
63
+ "CHANGELOG.md"
64
+ ],
65
+ "engines": {
66
+ "node": ">=20.11"
67
+ },
68
+ "publishConfig": {
69
+ "access": "public"
70
+ },
71
+ "strapi": {
72
+ "displayName": "Canvas",
73
+ "name": "canvas",
74
+ "description": "Same-origin Canvas authoring for Strapi admin.",
75
+ "kind": "plugin"
76
+ }
77
+ }
@@ -0,0 +1,56 @@
1
+ import type { EditorShellAppearance } from "@batterai/editor-shell";
2
+
3
+ export type StrapiCanvasTheme = Readonly<{
4
+ colors?: Readonly<Record<string, unknown>>;
5
+ }>;
6
+
7
+ export type StrapiCanvasAppearance =
8
+ | EditorShellAppearance
9
+ | ((theme: StrapiCanvasTheme) => EditorShellAppearance);
10
+
11
+ let registeredAppearance: StrapiCanvasAppearance | undefined;
12
+
13
+ /**
14
+ * Register host-owned Canvas editor chrome colors from `src/admin/app.tsx`.
15
+ * A resolver is recommended: Strapi calls it again when its light/dark theme changes.
16
+ */
17
+ export function registerCanvasAppearance(appearance: StrapiCanvasAppearance): void {
18
+ registeredAppearance = appearance;
19
+ }
20
+
21
+ export function canvasAppearance(theme: StrapiCanvasTheme): EditorShellAppearance {
22
+ const colors = theme.colors ?? {};
23
+ const inherited: EditorShellAppearance = compact({
24
+ accent: color(colors, "buttonPrimary600", "primary600"),
25
+ accentStrong: color(colors, "buttonPrimary700", "primary700"),
26
+ accentSoft: color(colors, "primary100"),
27
+ background: color(colors, "neutral100"),
28
+ border: color(colors, "neutral200"),
29
+ borderSubtle: color(colors, "neutral150", "neutral200"),
30
+ ink: color(colors, "neutral800"),
31
+ muted: color(colors, "neutral600"),
32
+ mutedDisabled: color(colors, "neutral500"),
33
+ panel: color(colors, "neutral0"),
34
+ panelStrong: color(colors, "neutral100"),
35
+ });
36
+ const custom = typeof registeredAppearance === "function"
37
+ ? registeredAppearance(theme)
38
+ : registeredAppearance;
39
+ const merged = { ...inherited, ...custom };
40
+ return {
41
+ ...merged,
42
+ selection: merged.selection ?? merged.accent ?? "#5d71c7",
43
+ };
44
+ }
45
+
46
+ function color(colors: Readonly<Record<string, unknown>>, ...names: string[]): string | undefined {
47
+ for (const name of names) {
48
+ const value = colors[name];
49
+ if (typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value)) return value;
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ function compact(value: Record<string, string | undefined>): EditorShellAppearance {
55
+ return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => Boolean(entry[1])));
56
+ }
@@ -0,0 +1,16 @@
1
+ import type { BlockPackage } from "@batterai/contract";
2
+
3
+ const packages: BlockPackage[] = [];
4
+
5
+ export function registerCanvasBlockPackages(next: readonly BlockPackage[]): void {
6
+ const existing = new Set(packages.map((pkg) => pkg.manifest.type));
7
+ for (const pkg of next) {
8
+ if (existing.has(pkg.manifest.type)) throw new Error(`Duplicate Canvas block package: ${pkg.manifest.type}`);
9
+ existing.add(pkg.manifest.type);
10
+ packages.push(pkg);
11
+ }
12
+ }
13
+
14
+ export function configuredCanvasBlockPackages(): readonly BlockPackage[] {
15
+ return Object.freeze([...packages]);
16
+ }
@@ -0,0 +1,88 @@
1
+ import type {
2
+ EditorCollectionItemImage,
3
+ EditorContentModel,
4
+ EditorReferenceOption,
5
+ EditorShellCollectionField,
6
+ EditorShellCollectionItem,
7
+ EditorShellContent,
8
+ } from "@batterai/editor-shell";
9
+
10
+ const RESERVED_CONTENT_KEYS = new Set([
11
+ "media",
12
+ "routes",
13
+ "sharedBlocks",
14
+ "siteMetadata",
15
+ "theme",
16
+ "translations",
17
+ ]);
18
+
19
+ function contentCollections(content: EditorShellContent): string[] {
20
+ return Object.keys(content).filter((key) => {
21
+ if (RESERVED_CONTENT_KEYS.has(key)) return false;
22
+ return Array.isArray(content[key]);
23
+ });
24
+ }
25
+
26
+ function itemTitle(item: EditorShellCollectionItem): string {
27
+ return item.title ?? item.slug ?? item.id;
28
+ }
29
+
30
+ export const strapiContentModel: EditorContentModel<
31
+ EditorShellContent,
32
+ EditorShellCollectionItem,
33
+ EditorShellCollectionField,
34
+ EditorReferenceOption<string>
35
+ > = {
36
+ editableCollections: [],
37
+ defaultSiteTitle: "Strapi site",
38
+ detailBlock: {
39
+ blockType: "collection.detail",
40
+ collectionProp: "collection",
41
+ defaultCollection: "pages",
42
+ },
43
+ listBlock: {
44
+ blockType: "collection.list",
45
+ collectionProp: "collection",
46
+ defaultCollection: "pages",
47
+ },
48
+ collectionLabel(collection) {
49
+ return collection;
50
+ },
51
+ collectionItems(content, collection) {
52
+ const rows = content[collection];
53
+ return Array.isArray(rows) ? (rows as EditorShellCollectionItem[]) : [];
54
+ },
55
+ collectionItemsForBlock(content, blockConfig) {
56
+ const collection = typeof blockConfig.collection === "string"
57
+ ? blockConfig.collection
58
+ : contentCollections(content)[0] ?? "";
59
+ return collection ? this.collectionItems(content, collection) : [];
60
+ },
61
+ collectionItemImage(content, item): EditorCollectionItemImage | null {
62
+ if (typeof item.imageId !== "string") return null;
63
+ const media = content.media.find((asset) => asset.id === item.imageId);
64
+ return media ? { url: media.url, alt: media.alt } : null;
65
+ },
66
+ collectionItemHref(_content, item) {
67
+ return typeof item.slug === "string" ? `/${item.slug}` : `/${item.id}`;
68
+ },
69
+ createCollectionItem(collection) {
70
+ const id = `${collection}-${Date.now().toString(36)}`;
71
+ return { id, title: "Untitled" };
72
+ },
73
+ collectionFieldDef(_content, _collection, fieldName) {
74
+ return { name: fieldName, label: { en: fieldName } };
75
+ },
76
+ resolveOptionItems(content, field) {
77
+ if (field.valueType !== "reference") return [];
78
+ const rows = content[String(field.refType ?? "")];
79
+ if (!Array.isArray(rows)) return [];
80
+ return (rows as EditorShellCollectionItem[]).map((item) => ({
81
+ value: item.id,
82
+ label: itemTitle(item),
83
+ }));
84
+ },
85
+ optionLabel(_content, _field, value) {
86
+ return value;
87
+ },
88
+ };
@@ -0,0 +1,172 @@
1
+ import type {
2
+ EditorHostServices,
3
+ EditorMediaListRequest,
4
+ EditorMediaListResult,
5
+ EditorMediaUploadRequest,
6
+ EditorMediaUploadResult,
7
+ EditorPresetSaveResult,
8
+ EditorSaveResult,
9
+ EditorShellBootstrap,
10
+ EditorShellMediaAsset,
11
+ EditorShellRoute,
12
+ EditorShellSharedBlock,
13
+ EditorShellSiteMetadata,
14
+ EditorShellTranslations,
15
+ BlockPreset,
16
+ BlockLibrary,
17
+ } from "@batterai/editor-shell";
18
+ import {
19
+ canvasDocumentRef,
20
+ type CanvasHostError,
21
+ type CanvasHostResult,
22
+ type HostPublishReceipt,
23
+ type HostSaveReceipt,
24
+ type HostStageReceipt,
25
+ type HostPageRevisionSnapshot,
26
+ } from "@batterai/contract";
27
+ import type { FetchClient } from "@strapi/strapi/admin";
28
+
29
+ function unwrap<T>(result: Promise<{ data: T }>): Promise<T> {
30
+ return result.then((response) => response.data);
31
+ }
32
+
33
+ function stableSaveResult(): EditorSaveResult {
34
+ return { saved: true };
35
+ }
36
+
37
+ export function createStrapiCanvasHostServices(fetchClient: FetchClient): EditorHostServices {
38
+
39
+ const capabilities = {
40
+ canSave: true,
41
+ canPublish: true,
42
+ supportsPageStaging: true,
43
+ canUploadAssets: false,
44
+ canEditRoutes: true,
45
+ supportsPreview: true,
46
+ supportsAtomicMultiPageSave: true,
47
+ } as const;
48
+
49
+ return {
50
+ documentRef: canvasDocumentRef("strapi:canvas"),
51
+ document: {
52
+ async saveDocument(_ref, request) {
53
+ try {
54
+ return await unwrap<CanvasHostResult<HostSaveReceipt>>(
55
+ fetchClient.put("/canvas/document", { changes: request.changes, expected: request.expected }),
56
+ );
57
+ } catch (error) {
58
+ return { ok: false, error: normalizeHostError(error) };
59
+ }
60
+ },
61
+ async publishDocument(_ref, request) {
62
+ try {
63
+ return await unwrap<CanvasHostResult<HostPublishReceipt>>(
64
+ fetchClient.post("/canvas/document/publish", { expected: request.expected }),
65
+ );
66
+ } catch (error) {
67
+ return { ok: false, error: normalizeHostError(error) };
68
+ }
69
+ },
70
+ async stagePage(_ref, request) {
71
+ try {
72
+ return await unwrap<CanvasHostResult<HostStageReceipt>>(
73
+ fetchClient.post(`/canvas/pages/${encodeURIComponent(request.pageId)}/stage`, { expected: request.expected }),
74
+ );
75
+ } catch (error) {
76
+ return { ok: false, error: normalizeHostError(error) };
77
+ }
78
+ },
79
+ async unstagePage(_ref, request) {
80
+ try {
81
+ return await unwrap<CanvasHostResult<HostStageReceipt>>(
82
+ fetchClient.post(`/canvas/pages/${encodeURIComponent(request.pageId)}/unstage`, { expected: request.expected }),
83
+ );
84
+ } catch (error) {
85
+ return { ok: false, error: normalizeHostError(error) };
86
+ }
87
+ },
88
+ async loadPageRevision(_ref, request) {
89
+ try {
90
+ return await unwrap<CanvasHostResult<HostPageRevisionSnapshot>>(
91
+ fetchClient.get(`/canvas/pages/${encodeURIComponent(request.pageId)}/revisions/${request.revision}`),
92
+ );
93
+ } catch (error) {
94
+ return { ok: false, error: normalizeHostError(error) };
95
+ }
96
+ },
97
+ async getCapabilities() {
98
+ return { ok: true, value: capabilities };
99
+ },
100
+ },
101
+ readLaunchContext() {
102
+ return {};
103
+ },
104
+ createSessionHandshake() {
105
+ return undefined;
106
+ },
107
+ resolveLaunchPageId(pages, _context, fallbackPageId) {
108
+ return pages.some((page) => page.id === fallbackPageId) ? fallbackPageId : pages[0]?.id ?? fallbackPageId;
109
+ },
110
+ async loadBootstrap() {
111
+ return unwrap<EditorShellBootstrap>(fetchClient.get("/canvas/bootstrap"));
112
+ },
113
+ async listMediaAssets(request: EditorMediaListRequest): Promise<EditorMediaListResult<EditorShellMediaAsset>> {
114
+ const params = new URLSearchParams();
115
+ params.set("pageSize", String(request.pageSize));
116
+ if (request.cursor) params.set("cursor", request.cursor);
117
+ for (const mime of request.allowedMimeTypes ?? []) params.append("allowedMimeTypes", mime);
118
+ for (const id of request.selectedIds ?? []) params.append("selectedIds", id);
119
+ return unwrap(fetchClient.get(`/canvas/media?${params}`));
120
+ },
121
+ async uploadMediaAsset(request: EditorMediaUploadRequest): Promise<EditorMediaUploadResult<EditorShellMediaAsset>> {
122
+ const formData = new FormData();
123
+ formData.set("files", request.file);
124
+ formData.set("alt", request.alt);
125
+ for (const mime of request.allowedMimeTypes ?? []) formData.append("allowedMimeTypes", mime);
126
+ return unwrap(fetchClient.post("/canvas/media", formData));
127
+ },
128
+ async saveContentCollection(collection: string, items: unknown[]) {
129
+ await unwrap(fetchClient.put(`/canvas/content/${encodeURIComponent(collection)}`, { items }));
130
+ return stableSaveResult();
131
+ },
132
+ async saveMediaAssets(media: EditorShellMediaAsset[]) {
133
+ await unwrap(fetchClient.put("/canvas/media", { media }));
134
+ return stableSaveResult();
135
+ },
136
+ async saveRoutes(routes: EditorShellRoute[]) {
137
+ await unwrap(fetchClient.put("/canvas/routes", { routes }));
138
+ return stableSaveResult();
139
+ },
140
+ async saveSharedBlocks(sharedBlocks: EditorShellSharedBlock[]) {
141
+ await unwrap(fetchClient.put("/canvas/shared-blocks", { sharedBlocks }));
142
+ return stableSaveResult();
143
+ },
144
+ async savePreset(preset: BlockPreset): Promise<EditorPresetSaveResult> {
145
+ const response = await unwrap<{ library: BlockLibrary; preset: BlockPreset }>(
146
+ fetchClient.post("/canvas/presets", { preset }),
147
+ );
148
+ return response;
149
+ },
150
+ async saveTranslations(translations: EditorShellTranslations) {
151
+ await unwrap(fetchClient.put("/canvas/translations", { translations }));
152
+ return stableSaveResult();
153
+ },
154
+ async saveSiteMetadata(siteMetadata: EditorShellSiteMetadata) {
155
+ await unwrap(fetchClient.put("/canvas/site-metadata", { siteMetadata }));
156
+ return stableSaveResult();
157
+ },
158
+ };
159
+ }
160
+
161
+ function normalizeHostError(error: unknown): CanvasHostError {
162
+ const responseError = (error as { readonly response?: { readonly data?: { readonly error?: unknown } } })
163
+ ?.response?.data?.error;
164
+ if (responseError && typeof responseError === "object" && "kind" in responseError) {
165
+ return responseError as CanvasHostError;
166
+ }
167
+ const message = error instanceof Error ? error.message : String(error);
168
+ if (/409|conflict/i.test(message)) return { kind: "conflict" };
169
+ if (/401|403|forbidden/i.test(message)) return { kind: "forbidden", reason: message };
170
+ if (/404|not found/i.test(message)) return { kind: "not-found" };
171
+ return { kind: "unavailable", retryable: true };
172
+ }
@@ -0,0 +1,56 @@
1
+ import { PLUGIN_ID, PLUGIN_NAME } from "../constants";
2
+ import { translationForLocale } from "./translations";
3
+
4
+ export { configuredCanvasBlockPackages, registerCanvasBlockPackages } from "./block-packages";
5
+ export {
6
+ canvasAppearance,
7
+ registerCanvasAppearance,
8
+ type StrapiCanvasAppearance,
9
+ type StrapiCanvasTheme,
10
+ } from "./appearance";
11
+
12
+ type StrapiAdminApp = {
13
+ addMenuLink: (link: {
14
+ to: string;
15
+ icon?: React.ComponentType;
16
+ intlLabel: { id: string; defaultMessage: string };
17
+ permissions?: Array<{ action: string; subject?: string | null }>;
18
+ Component: () => Promise<{ default: React.ComponentType }>;
19
+ }) => void;
20
+ registerPlugin: (plugin: { id: string; name: string }) => void;
21
+ };
22
+
23
+ function CanvasIcon() {
24
+ return (
25
+ <span aria-hidden="true" style={{ color: "currentColor", fontWeight: 800, fontSize: "18px", lineHeight: 1 }}>
26
+ Ç
27
+ </span>
28
+ );
29
+ }
30
+
31
+ export default {
32
+ register(app: StrapiAdminApp) {
33
+ app.addMenuLink({
34
+ to: `plugins/${PLUGIN_ID}`,
35
+ icon: CanvasIcon,
36
+ intlLabel: {
37
+ id: `${PLUGIN_ID}.menu.label`,
38
+ defaultMessage: PLUGIN_NAME,
39
+ },
40
+ permissions: [{ action: "plugin::canvas.editor.access", subject: null }],
41
+ Component: () => import("./pages/App"),
42
+ });
43
+
44
+ app.registerPlugin({
45
+ id: PLUGIN_ID,
46
+ name: PLUGIN_NAME,
47
+ });
48
+ },
49
+ bootstrap() {},
50
+ async registerTrads({ locales }: { locales: string[] }) {
51
+ return locales.map((locale) => ({
52
+ locale,
53
+ data: translationForLocale(locale),
54
+ }));
55
+ },
56
+ };
@@ -0,0 +1,56 @@
1
+ import { createEditorElement } from "@batterai/editor-shell";
2
+ import { canvasBlockPackages } from "@batterai/canvas-blocks";
3
+ import "@batterai/editor-shell/styles.css";
4
+ import { useFetchClient } from "@strapi/strapi/admin";
5
+ import { useMemo } from "react";
6
+ import { useTheme } from "styled-components";
7
+ import { strapiContentModel } from "../client/content-model";
8
+ import { createStrapiCanvasHostServices } from "../client/host-services";
9
+ import { configuredCanvasBlockPackages } from "../block-packages";
10
+ import { canvasAppearance, type StrapiCanvasTheme } from "../appearance";
11
+
12
+ function CanvasPage() {
13
+ const fetchClient = useFetchClient();
14
+ const strapiTheme = useTheme() as unknown as StrapiCanvasTheme;
15
+ const hostServices = useMemo(() => createStrapiCanvasHostServices(fetchClient), [fetchClient]);
16
+ const appearance = useMemo(() => canvasAppearance(strapiTheme), [strapiTheme]);
17
+ const collaboration = useMemo(() => {
18
+ const collabFetch: typeof fetch = async (input, init) => {
19
+ const url = new URL(String(input), window.location.origin);
20
+ const requestBody = init?.body ? JSON.parse(String(init.body)) : {};
21
+ const response = await fetchClient.post(url.pathname, { ...requestBody, __canvasOrigin: window.location.origin });
22
+ return Response.json(response.data);
23
+ };
24
+ return {
25
+ baseUrl: "/canvas/collab",
26
+ fetch: collabFetch,
27
+ tokenProvider: async () => {
28
+ const response = await fetchClient.post("/canvas/collab-token", {});
29
+ return (response.data as { token: string }).token;
30
+ },
31
+ auditProvider: async (pageId: string) => {
32
+ const response = await fetchClient.get(`/canvas/collab-audit/${encodeURIComponent(pageId)}`);
33
+ return (response.data as { events: readonly import("@batterai/editor-shell").EditorCollaborationAuditEvent[] }).events;
34
+ },
35
+ };
36
+ }, [fetchClient]);
37
+
38
+ return (
39
+ <main style={{ height: "100vh", overflow: "hidden", width: "100%" }}>
40
+ {createEditorElement({
41
+ contentModel: strapiContentModel,
42
+ defaultPageId: "home",
43
+ hostServices,
44
+ appearance,
45
+ collaboration,
46
+ blockPackages: [...canvasBlockPackages, ...configuredCanvasBlockPackages()],
47
+ })}
48
+ </main>
49
+ );
50
+ }
51
+
52
+ export function ProtectedCanvasPage() {
53
+ return <CanvasPage />;
54
+ }
55
+
56
+ export default ProtectedCanvasPage;
@@ -0,0 +1 @@
1
+ declare module "*.css";
@@ -0,0 +1,23 @@
1
+ import { PLUGIN_ID } from "../constants";
2
+
3
+ type TranslationMap = Record<string, string>;
4
+
5
+ const en: TranslationMap = {
6
+ "plugin.name": "Canvas",
7
+ "menu.label": "Canvas",
8
+ };
9
+
10
+ const nl: TranslationMap = {
11
+ "plugin.name": "Canvas",
12
+ "menu.label": "Canvas",
13
+ };
14
+
15
+ function prefixPluginTranslations(data: TranslationMap): TranslationMap {
16
+ return Object.fromEntries(
17
+ Object.entries(data).map(([key, value]) => [`${PLUGIN_ID}.${key}`, value]),
18
+ );
19
+ }
20
+
21
+ export function translationForLocale(locale: string): TranslationMap {
22
+ return prefixPluginTranslations(locale === "nl" ? nl : en);
23
+ }
@@ -0,0 +1,21 @@
1
+ export const PLUGIN_ID = "canvas";
2
+ export const PLUGIN_NAME = "Canvas";
3
+
4
+ export const CANVAS_PAGE_UID = "plugin::canvas.canvas-page";
5
+ export const CANVAS_SITE_UID = "plugin::canvas.canvas-site";
6
+ export const DEFAULT_SITE_KEY = "default";
7
+ export const CANVAS_PAGE_PREFIX = "canvas:";
8
+
9
+ export const CANVAS_PERMISSIONS = {
10
+ access: "plugin::canvas.editor.access",
11
+ write: "plugin::canvas.editor.write",
12
+ audit: "plugin::canvas.editor.audit",
13
+ } as const;
14
+
15
+ export function canvasPageKey(pageId: string): string {
16
+ return `${CANVAS_PAGE_PREFIX}${pageId}`;
17
+ }
18
+
19
+ export function pageIdFromCanvasKey(pageKey: string): string {
20
+ return pageKey.startsWith(CANVAS_PAGE_PREFIX) ? pageKey.slice(CANVAS_PAGE_PREFIX.length) : pageKey;
21
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export {
2
+ CANVAS_PAGE_PREFIX,
3
+ CANVAS_PAGE_UID,
4
+ CANVAS_PERMISSIONS,
5
+ CANVAS_SITE_UID,
6
+ DEFAULT_SITE_KEY,
7
+ PLUGIN_ID,
8
+ PLUGIN_NAME,
9
+ canvasPageKey,
10
+ pageIdFromCanvasKey,
11
+ } from "./constants";
@@ -0,0 +1,45 @@
1
+ import { CANVAS_PERMISSIONS, PLUGIN_ID } from "../constants";
2
+
3
+ const actions = [
4
+ {
5
+ section: "plugins",
6
+ displayName: "Access",
7
+ uid: "editor.access",
8
+ subCategory: "editor",
9
+ pluginName: PLUGIN_ID,
10
+ },
11
+ {
12
+ section: "plugins",
13
+ displayName: "Write",
14
+ uid: "editor.write",
15
+ subCategory: "editor",
16
+ pluginName: PLUGIN_ID,
17
+ },
18
+ {
19
+ section: "plugins",
20
+ displayName: "View collaboration audit",
21
+ uid: "editor.audit",
22
+ subCategory: "editor",
23
+ pluginName: PLUGIN_ID,
24
+ },
25
+ ];
26
+
27
+ export default async function bootstrap({ strapi }: { strapi: any }) {
28
+ await strapi.service("admin::permission").actionProvider.registerMany(actions);
29
+
30
+ // Strapi registers custom plugin actions separately from the permissions held
31
+ // by its built-in Super Admin role. Grant the Canvas actions explicitly so a
32
+ // fresh demo (and an existing demo upgraded with this plugin) can surface
33
+ // the Canvas navigation item immediately.
34
+ const superAdmin = await strapi.service("admin::role").getSuperAdmin();
35
+ if (superAdmin?.id) {
36
+ await strapi.service("admin::role").assignPermissions(
37
+ superAdmin.id,
38
+ Object.values(CANVAS_PERMISSIONS).map((action) => ({ action, subject: null, conditions: [] })),
39
+ );
40
+ }
41
+
42
+ strapi.log?.debug?.(
43
+ `canvas plugin registered ${CANVAS_PERMISSIONS.access}, ${CANVAS_PERMISSIONS.write}, and ${CANVAS_PERMISSIONS.audit}`,
44
+ );
45
+ }
@@ -0,0 +1,46 @@
1
+ export type CanvasPluginConfig = {
2
+ boundCollections?: Record<string, string>;
3
+ discoverCollections?: boolean;
4
+ licenseKey?: string;
5
+ seedLibrary?: { categories?: unknown[]; blocks?: unknown[]; presets?: unknown[] };
6
+ collaboration?: {
7
+ secret: string;
8
+ origins?: string[];
9
+ tokenTtlSeconds?: number;
10
+ };
11
+ };
12
+
13
+ export default {
14
+ default: {
15
+ boundCollections: {},
16
+ discoverCollections: true,
17
+ } satisfies CanvasPluginConfig,
18
+ validator(config: CanvasPluginConfig) {
19
+ if (config.boundCollections && typeof config.boundCollections !== "object") {
20
+ throw new Error("plugin::canvas.boundCollections must be an object.");
21
+ }
22
+ if (config.discoverCollections !== undefined && typeof config.discoverCollections !== "boolean") {
23
+ throw new Error("plugin::canvas.discoverCollections must be a boolean.");
24
+ }
25
+ if (config.licenseKey !== undefined && typeof config.licenseKey !== "string") {
26
+ throw new Error("plugin::canvas.licenseKey must be a string.");
27
+ }
28
+ if (
29
+ config.seedLibrary !== undefined &&
30
+ (typeof config.seedLibrary !== "object" || config.seedLibrary === null || Array.isArray(config.seedLibrary))
31
+ ) {
32
+ throw new Error("plugin::canvas.seedLibrary must be an object with categories/blocks/presets arrays.");
33
+ }
34
+ if (config.collaboration !== undefined) {
35
+ if (!config.collaboration || typeof config.collaboration !== "object") {
36
+ throw new Error("plugin::canvas.collaboration must be an object.");
37
+ }
38
+ if (typeof config.collaboration.secret !== "string" || config.collaboration.secret.length < 32) {
39
+ throw new Error("plugin::canvas.collaboration.secret must contain at least 32 characters.");
40
+ }
41
+ if (config.collaboration.origins && !Array.isArray(config.collaboration.origins)) {
42
+ throw new Error("plugin::canvas.collaboration.origins must be an array.");
43
+ }
44
+ }
45
+ },
46
+ };