@standhigher/puck-page-builder 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.
- package/README.md +55 -0
- package/dist/chunk-72MFV6J7.js +15 -0
- package/dist/chunk-G2LNCKYO.js +107 -0
- package/dist/chunk-RGB53WE6.js +180 -0
- package/dist/extensions.d.ts +148 -0
- package/dist/extensions.js +10 -0
- package/dist/index.d.ts +182 -0
- package/dist/index.js +911 -0
- package/dist/renderer.d.ts +12 -0
- package/dist/renderer.js +6 -0
- package/dist/schema.d.ts +59 -0
- package/dist/schema.js +10 -0
- package/package.json +68 -0
- package/src/styles.css +91 -0
- package/src/ui/builder/tokens.css +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @standhigher/puck-page-builder
|
|
2
|
+
|
|
3
|
+
`@standhigher/puck-page-builder` provides a React PageDocument editor, immutable extension registry and Web renderer for BestTrack page experiences.
|
|
4
|
+
|
|
5
|
+
> This is the package's only install and import name. `BestTrack Page Builder` is the product name; `packages/puck-page-builder` is only this repository's source directory.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @standhigher/puck-page-builder react react-dom @puckeditor/core @shopify/polaris @shopify/polaris-icons
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The React, Puck and Polaris packages are peer dependencies and must be supplied by the host application.
|
|
14
|
+
|
|
15
|
+
## Web rendering
|
|
16
|
+
|
|
17
|
+
Import the package styles from the application's global stylesheet entry:
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import "@standhigher/puck-page-builder/styles.css";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Load a published `PageDocument` on the server, validate it at the storage boundary, assemble the business extensions, then render it:
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { migratePageDocument } from "@standhigher/puck-page-builder";
|
|
27
|
+
import { WebRenderer } from "@standhigher/puck-page-builder/renderer";
|
|
28
|
+
import { createExtensionRegistry } from "@standhigher/puck-page-builder/extensions";
|
|
29
|
+
|
|
30
|
+
const migration = migratePageDocument(storedDocument);
|
|
31
|
+
if (!migration.success) throw new Error("Invalid published PageDocument");
|
|
32
|
+
|
|
33
|
+
const registry = createExtensionRegistry([bestTrackExtension]);
|
|
34
|
+
const page = <WebRenderer document={migration.data} registry={registry} />;
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Public entry points
|
|
38
|
+
|
|
39
|
+
| Import | Purpose |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| `@standhigher/puck-page-builder` | `PageDocument`, editor, document migration and base exports |
|
|
42
|
+
| `@standhigher/puck-page-builder/renderer` | `WebRenderer` |
|
|
43
|
+
| `@standhigher/puck-page-builder/extensions` | extension definitions and `createExtensionRegistry` |
|
|
44
|
+
| `@standhigher/puck-page-builder/schema` | schema-specific types and migration helpers |
|
|
45
|
+
| `@standhigher/puck-page-builder/styles.css` | Builder and Web renderer styles |
|
|
46
|
+
|
|
47
|
+
## Runtime and DataSource boundary
|
|
48
|
+
|
|
49
|
+
The package can register DataSources but does not automatically resolve `block.binding`, call a Live data source, or inject its result into `WebRenderer`. Implement authorization, data resolution, caching, error handling and temporary render-data projection in the host application's server-side Runtime. Do not persist tokens, credentials or resolved private data in `PageDocument`.
|
|
50
|
+
|
|
51
|
+
See the repository's [integration documentation](https://github.com/standhigher/puck-page-builder/tree/main/docs/integration) for the Web quick start, extension development and Runtime/DataSource guidance.
|
|
52
|
+
|
|
53
|
+
## Stability
|
|
54
|
+
|
|
55
|
+
The package is pre-1.0. Public APIs may evolve between minor releases; validate and migrate persisted documents at every read and publish boundary.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// src/renderer/web/WebRenderer.tsx
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
function WebRenderer({ document, className, registry }) {
|
|
4
|
+
return /* @__PURE__ */ jsx("main", { className: className ?? "pb-web-renderer", "data-page-id": document.pageId, lang: document.settings.locale, children: document.blocks.map((block) => {
|
|
5
|
+
if (block.type === "core.text") return /* @__PURE__ */ jsx("section", { "data-block-id": block.id, className: "pb-web-renderer__text", children: /* @__PURE__ */ jsx("p", { children: typeof block.props.content === "string" ? block.props.content : "" }) }, block.id);
|
|
6
|
+
if (block.type === "core.image") return /* @__PURE__ */ jsx("figure", { "data-block-id": block.id, className: "pb-web-renderer__image", children: /* @__PURE__ */ jsx("img", { src: typeof block.props.src === "string" ? block.props.src : "", alt: typeof block.props.alt === "string" ? block.props.alt : "" }) }, block.id);
|
|
7
|
+
const BlockRenderer = registry?.getBlock(block.type)?.render.web;
|
|
8
|
+
if (BlockRenderer) return /* @__PURE__ */ jsx("section", { "data-block-id": block.id, children: /* @__PURE__ */ jsx(BlockRenderer, { ...block.props }) }, block.id);
|
|
9
|
+
return null;
|
|
10
|
+
}) });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
WebRenderer
|
|
15
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// src/core/schema/page-document.ts
|
|
2
|
+
var pageDocumentKeys = /* @__PURE__ */ new Set(["schemaVersion", "pageId", "target", "templateId", "root", "blocks", "settings"]);
|
|
3
|
+
var blockKeys = /* @__PURE__ */ new Set(["id", "type", "version", "props", "slots", "binding"]);
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function isJsonValue(value) {
|
|
8
|
+
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") return true;
|
|
9
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
10
|
+
return isRecord(value) && Object.values(value).every(isJsonValue);
|
|
11
|
+
}
|
|
12
|
+
function normalizeBlock(value, path, issues) {
|
|
13
|
+
if (!isRecord(value)) {
|
|
14
|
+
issues.push({ path, message: "\u5FC5\u987B\u662F\u5BF9\u8C61" });
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
for (const key of Object.keys(value)) if (!blockKeys.has(key)) issues.push({ path: `${path}.${key}`, message: "\u4E0D\u5141\u8BB8\u4FDD\u5B58\u672A\u77E5\u5B57\u6BB5" });
|
|
18
|
+
if (typeof value.id !== "string" || !value.id) issues.push({ path: `${path}.id`, message: "\u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32" });
|
|
19
|
+
if (typeof value.type !== "string" || !value.type) issues.push({ path: `${path}.type`, message: "\u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32" });
|
|
20
|
+
if (typeof value.version !== "number" || !Number.isInteger(value.version) || value.version < 1) issues.push({ path: `${path}.version`, message: "\u5FC5\u987B\u662F\u5927\u4E8E 0 \u7684\u6574\u6570" });
|
|
21
|
+
if (!isRecord(value.props) || !Object.values(value.props).every(isJsonValue)) issues.push({ path: `${path}.props`, message: "\u5FC5\u987B\u662F JSON \u5BF9\u8C61" });
|
|
22
|
+
if (value.binding !== void 0 && (!isRecord(value.binding) || typeof value.binding.source !== "string" || value.binding.params !== void 0 && (!isRecord(value.binding.params) || !Object.values(value.binding.params).every(isJsonValue)))) issues.push({ path: `${path}.binding`, message: "\u5FC5\u987B\u5305\u542B source\uFF0C\u4E14 params \u5FC5\u987B\u662F JSON \u5BF9\u8C61" });
|
|
23
|
+
if (issues.some((issue) => issue.path.startsWith(path))) return null;
|
|
24
|
+
const slots = normalizeSlots(value.slots, `${path}.slots`, issues);
|
|
25
|
+
if (issues.some((issue) => issue.path.startsWith(path))) return null;
|
|
26
|
+
return {
|
|
27
|
+
id: value.id,
|
|
28
|
+
type: value.type,
|
|
29
|
+
version: value.version,
|
|
30
|
+
props: value.props,
|
|
31
|
+
...slots ? { slots } : {},
|
|
32
|
+
...value.binding ? { binding: value.binding } : {}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function normalizeSlots(value, path, issues) {
|
|
36
|
+
if (value === void 0) return void 0;
|
|
37
|
+
if (!isRecord(value)) {
|
|
38
|
+
issues.push({ path, message: "\u5FC5\u987B\u662F\u5BF9\u8C61" });
|
|
39
|
+
return void 0;
|
|
40
|
+
}
|
|
41
|
+
const slots = {};
|
|
42
|
+
for (const [slot, children] of Object.entries(value)) {
|
|
43
|
+
if (!Array.isArray(children)) {
|
|
44
|
+
issues.push({ path: `${path}.${slot}`, message: "\u5FC5\u987B\u662F\u533A\u5757\u6570\u7EC4" });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
slots[slot] = children.map((child, index) => normalizeBlock(child, `${path}.${slot}[${index}]`, issues)).filter((child) => child !== null);
|
|
48
|
+
}
|
|
49
|
+
return slots;
|
|
50
|
+
}
|
|
51
|
+
function createPageDocument(input) {
|
|
52
|
+
return {
|
|
53
|
+
schemaVersion: 1,
|
|
54
|
+
pageId: input.pageId,
|
|
55
|
+
target: input.target ?? "web",
|
|
56
|
+
...input.templateId ? { templateId: input.templateId } : {},
|
|
57
|
+
root: input.root ?? {},
|
|
58
|
+
blocks: input.blocks ?? [],
|
|
59
|
+
settings: { locale: input.settings?.locale ?? "en", ...input.settings?.seoTitle ? { seoTitle: input.settings.seoTitle } : {} }
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function validatePageDocument(value) {
|
|
63
|
+
const issues = [];
|
|
64
|
+
if (!isRecord(value)) return { success: false, issues: [{ path: "$", message: "PageDocument \u5FC5\u987B\u662F\u5BF9\u8C61" }] };
|
|
65
|
+
for (const key of Object.keys(value)) if (!pageDocumentKeys.has(key)) issues.push({ path: `$.${key}`, message: "\u4E0D\u5141\u8BB8\u4FDD\u5B58\u672A\u77E5\u5B57\u6BB5" });
|
|
66
|
+
if (value.schemaVersion !== 1) issues.push({ path: "$.schemaVersion", message: "\u4EC5\u652F\u6301 schemaVersion 1" });
|
|
67
|
+
if (typeof value.pageId !== "string" || !value.pageId) issues.push({ path: "$.pageId", message: "\u5FC5\u987B\u662F\u975E\u7A7A\u5B57\u7B26\u4E32" });
|
|
68
|
+
if (value.target !== "web" && value.target !== "email") issues.push({ path: "$.target", message: "\u5FC5\u987B\u662F web \u6216 email" });
|
|
69
|
+
if (value.templateId !== void 0 && typeof value.templateId !== "string") issues.push({ path: "$.templateId", message: "\u5FC5\u987B\u662F\u5B57\u7B26\u4E32" });
|
|
70
|
+
if (!isRecord(value.root) || !Object.values(value.root).every(isJsonValue)) issues.push({ path: "$.root", message: "\u5FC5\u987B\u662F JSON \u5BF9\u8C61" });
|
|
71
|
+
if (!isRecord(value.settings) || typeof value.settings.locale !== "string") issues.push({ path: "$.settings.locale", message: "\u5FC5\u987B\u662F\u5B57\u7B26\u4E32" });
|
|
72
|
+
if (!Array.isArray(value.blocks)) issues.push({ path: "$.blocks", message: "\u5FC5\u987B\u662F\u6570\u7EC4" });
|
|
73
|
+
const blocks = Array.isArray(value.blocks) ? value.blocks.map((block, index) => normalizeBlock(block, `$.blocks[${index}]`, issues)).filter((block) => block !== null) : [];
|
|
74
|
+
const blockIds = /* @__PURE__ */ new Set();
|
|
75
|
+
for (const block of blocks) {
|
|
76
|
+
if (blockIds.has(block.id)) issues.push({ path: "$.blocks", message: `\u533A\u5757 ID \u91CD\u590D\uFF1A${block.id}` });
|
|
77
|
+
blockIds.add(block.id);
|
|
78
|
+
}
|
|
79
|
+
if (issues.length > 0) return { success: false, issues };
|
|
80
|
+
return {
|
|
81
|
+
success: true,
|
|
82
|
+
data: createPageDocument({
|
|
83
|
+
pageId: value.pageId,
|
|
84
|
+
target: value.target,
|
|
85
|
+
...typeof value.templateId === "string" ? { templateId: value.templateId } : {},
|
|
86
|
+
root: value.root,
|
|
87
|
+
blocks,
|
|
88
|
+
settings: value.settings
|
|
89
|
+
})
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function migratePageDocument(value) {
|
|
93
|
+
if (!isRecord(value)) {
|
|
94
|
+
const validation2 = validatePageDocument(value);
|
|
95
|
+
return validation2.success ? { ...validation2, migrated: false } : validation2;
|
|
96
|
+
}
|
|
97
|
+
const migrated = value.schemaVersion === void 0;
|
|
98
|
+
const candidate = migrated ? { ...value, schemaVersion: 1 } : value;
|
|
99
|
+
const validation = validatePageDocument(candidate);
|
|
100
|
+
return validation.success ? { ...validation, migrated } : validation;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export {
|
|
104
|
+
createPageDocument,
|
|
105
|
+
validatePageDocument,
|
|
106
|
+
migratePageDocument
|
|
107
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// src/core/extensions/registry.ts
|
|
2
|
+
var ExtensionRegistryError = class extends Error {
|
|
3
|
+
constructor(code, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.name = "ExtensionRegistryError";
|
|
7
|
+
}
|
|
8
|
+
code;
|
|
9
|
+
};
|
|
10
|
+
var namespacedIdentifier = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
|
|
11
|
+
var slotNames = /* @__PURE__ */ new Set(["toolbar.left", "toolbar.center", "toolbar.right", "inspector.content"]);
|
|
12
|
+
function compareByOrder(left, right) {
|
|
13
|
+
return (left.order ?? 0) - (right.order ?? 0);
|
|
14
|
+
}
|
|
15
|
+
function assertIdentifier(identifier, label) {
|
|
16
|
+
if (!namespacedIdentifier.test(identifier)) throw new ExtensionRegistryError("invalid-identifier", `${label} \u5FC5\u987B\u4F7F\u7528\u547D\u540D\u7A7A\u95F4 ID\uFF1A${identifier}`);
|
|
17
|
+
}
|
|
18
|
+
function assertTargets(targets, label) {
|
|
19
|
+
if (targets.length === 0 || targets.some((target) => target !== "web" && target !== "email" && target !== "all")) throw new ExtensionRegistryError("invalid-target", `${label} \u5FC5\u987B\u58F0\u660E web\u3001email \u6216 all target`);
|
|
20
|
+
}
|
|
21
|
+
function register(items, values, extension, registry, kind) {
|
|
22
|
+
for (const item of values ?? []) {
|
|
23
|
+
const id = item.id ?? item.type ?? item.key;
|
|
24
|
+
if (!id) throw new ExtensionRegistryError("invalid-identifier", `${kind} \u7F3A\u5C11\u6CE8\u518C ID`);
|
|
25
|
+
assertIdentifier(id, kind);
|
|
26
|
+
if (registry.has(id)) throw new ExtensionRegistryError("duplicate-definition", `${kind} ID \u51B2\u7A81\uFF1A${id}`);
|
|
27
|
+
const registered = Object.freeze({ ...item, extension });
|
|
28
|
+
registry.set(id, registered);
|
|
29
|
+
items.push(registered);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function resolveOrder(extensions, disabled) {
|
|
33
|
+
const byName = /* @__PURE__ */ new Map();
|
|
34
|
+
for (const extension of extensions) {
|
|
35
|
+
assertIdentifier(extension.name, "Extension name");
|
|
36
|
+
if (byName.has(extension.name)) throw new ExtensionRegistryError("duplicate-extension", `Extension \u91CD\u590D\uFF1A${extension.name}`);
|
|
37
|
+
byName.set(extension.name, extension);
|
|
38
|
+
}
|
|
39
|
+
const active = extensions.filter((extension) => !disabled.has(extension.name));
|
|
40
|
+
const activeNames = new Set(active.map((extension) => extension.name));
|
|
41
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
42
|
+
const visited = /* @__PURE__ */ new Set();
|
|
43
|
+
const sorted = [];
|
|
44
|
+
const visit = (extension) => {
|
|
45
|
+
if (visited.has(extension.name)) return;
|
|
46
|
+
if (visiting.has(extension.name)) throw new ExtensionRegistryError("dependency-cycle", `Extension \u4F9D\u8D56\u5B58\u5728\u5FAA\u73AF\uFF1A${extension.name}`);
|
|
47
|
+
visiting.add(extension.name);
|
|
48
|
+
for (const dependency of extension.dependsOn ?? []) {
|
|
49
|
+
if (!activeNames.has(dependency)) throw new ExtensionRegistryError("missing-dependency", `Extension ${extension.name} \u7F3A\u5C11\u542F\u7528\u7684\u4F9D\u8D56\uFF1A${dependency}`);
|
|
50
|
+
visit(byName.get(dependency));
|
|
51
|
+
}
|
|
52
|
+
visiting.delete(extension.name);
|
|
53
|
+
visited.add(extension.name);
|
|
54
|
+
sorted.push(extension);
|
|
55
|
+
};
|
|
56
|
+
for (const extension of [...active].sort(compareByOrder)) visit(extension);
|
|
57
|
+
return sorted;
|
|
58
|
+
}
|
|
59
|
+
var ExtensionRegistry = class _ExtensionRegistry {
|
|
60
|
+
constructor(extensions, blockMap, fieldMap, actionMap, rendererMap, dataSourceMap, templateMap, slotMap, hooks) {
|
|
61
|
+
this.extensions = extensions;
|
|
62
|
+
this.blockMap = blockMap;
|
|
63
|
+
this.fieldMap = fieldMap;
|
|
64
|
+
this.actionMap = actionMap;
|
|
65
|
+
this.rendererMap = rendererMap;
|
|
66
|
+
this.dataSourceMap = dataSourceMap;
|
|
67
|
+
this.templateMap = templateMap;
|
|
68
|
+
this.slotMap = slotMap;
|
|
69
|
+
this.hooks = hooks;
|
|
70
|
+
}
|
|
71
|
+
extensions;
|
|
72
|
+
blockMap;
|
|
73
|
+
fieldMap;
|
|
74
|
+
actionMap;
|
|
75
|
+
rendererMap;
|
|
76
|
+
dataSourceMap;
|
|
77
|
+
templateMap;
|
|
78
|
+
slotMap;
|
|
79
|
+
hooks;
|
|
80
|
+
get blocks() {
|
|
81
|
+
return Object.freeze([...this.blockMap.values()]);
|
|
82
|
+
}
|
|
83
|
+
get fields() {
|
|
84
|
+
return Object.freeze([...this.fieldMap.values()]);
|
|
85
|
+
}
|
|
86
|
+
get actions() {
|
|
87
|
+
return Object.freeze([...this.actionMap.values()].sort(compareByOrder));
|
|
88
|
+
}
|
|
89
|
+
get renderers() {
|
|
90
|
+
return Object.freeze([...this.rendererMap.values()]);
|
|
91
|
+
}
|
|
92
|
+
get dataSources() {
|
|
93
|
+
return Object.freeze([...this.dataSourceMap.values()]);
|
|
94
|
+
}
|
|
95
|
+
get templates() {
|
|
96
|
+
return Object.freeze([...this.templateMap.values()]);
|
|
97
|
+
}
|
|
98
|
+
getBlock(type) {
|
|
99
|
+
return this.blockMap.get(type);
|
|
100
|
+
}
|
|
101
|
+
getField(type) {
|
|
102
|
+
return this.fieldMap.get(type);
|
|
103
|
+
}
|
|
104
|
+
getAction(id) {
|
|
105
|
+
return this.actionMap.get(id);
|
|
106
|
+
}
|
|
107
|
+
getRenderer(id) {
|
|
108
|
+
return this.rendererMap.get(id);
|
|
109
|
+
}
|
|
110
|
+
getDataSource(key) {
|
|
111
|
+
return this.dataSourceMap.get(key);
|
|
112
|
+
}
|
|
113
|
+
getTemplate(id) {
|
|
114
|
+
return this.templateMap.get(id);
|
|
115
|
+
}
|
|
116
|
+
getSlot(slot) {
|
|
117
|
+
return this.slotMap.get(slot) ?? [];
|
|
118
|
+
}
|
|
119
|
+
notifyChange(document) {
|
|
120
|
+
this.hooks.forEach((hook) => hook.onChange?.(document));
|
|
121
|
+
}
|
|
122
|
+
notifyError(error) {
|
|
123
|
+
this.hooks.forEach((hook) => hook.onError?.(error));
|
|
124
|
+
}
|
|
125
|
+
static create(extensions, options = {}) {
|
|
126
|
+
const resolved = resolveOrder(extensions, new Set(options.disabled ?? []));
|
|
127
|
+
const blocks = /* @__PURE__ */ new Map();
|
|
128
|
+
const fields = /* @__PURE__ */ new Map();
|
|
129
|
+
const actions = /* @__PURE__ */ new Map();
|
|
130
|
+
const renderers = /* @__PURE__ */ new Map();
|
|
131
|
+
const dataSources = /* @__PURE__ */ new Map();
|
|
132
|
+
const templates = /* @__PURE__ */ new Map();
|
|
133
|
+
const blockItems = [];
|
|
134
|
+
const fieldItems = [];
|
|
135
|
+
const actionItems = [];
|
|
136
|
+
const rendererItems = [];
|
|
137
|
+
const dataSourceItems = [];
|
|
138
|
+
const templateItems = [];
|
|
139
|
+
const slots = /* @__PURE__ */ new Map();
|
|
140
|
+
for (const extension of resolved) {
|
|
141
|
+
for (const block of extension.blocks ?? []) assertTargets(block.targets, `Block ${block.type}`);
|
|
142
|
+
register(blockItems, extension.blocks, extension.name, blocks, "Block");
|
|
143
|
+
register(fieldItems, extension.fields, extension.name, fields, "Field");
|
|
144
|
+
register(actionItems, extension.actions, extension.name, actions, "Action");
|
|
145
|
+
register(rendererItems, extension.renderers, extension.name, renderers, "Renderer");
|
|
146
|
+
register(dataSourceItems, extension.dataSources, extension.name, dataSources, "DataSource");
|
|
147
|
+
register(templateItems, extension.templates, extension.name, templates, "Template");
|
|
148
|
+
for (const slot of extension.slots ?? []) {
|
|
149
|
+
assertIdentifier(slot.id, "UI Slot");
|
|
150
|
+
if (!slotNames.has(slot.slot)) throw new ExtensionRegistryError("invalid-identifier", `\u672A\u77E5 UI Slot\uFF1A${slot.slot}`);
|
|
151
|
+
const current = slots.get(slot.slot) ?? [];
|
|
152
|
+
if (current.some((item) => item.id === slot.id)) throw new ExtensionRegistryError("duplicate-definition", `UI Slot ID \u51B2\u7A81\uFF1A${slot.id}`);
|
|
153
|
+
current.push(Object.freeze({ ...slot, extension: extension.name }));
|
|
154
|
+
slots.set(slot.slot, current);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const sortedSlots = /* @__PURE__ */ new Map();
|
|
158
|
+
for (const [slot, contributions] of slots) sortedSlots.set(slot, Object.freeze([...contributions].sort(compareByOrder)));
|
|
159
|
+
return Object.freeze(new _ExtensionRegistry(
|
|
160
|
+
Object.freeze([...resolved]),
|
|
161
|
+
blocks,
|
|
162
|
+
fields,
|
|
163
|
+
actions,
|
|
164
|
+
renderers,
|
|
165
|
+
dataSources,
|
|
166
|
+
templates,
|
|
167
|
+
sortedSlots,
|
|
168
|
+
Object.freeze(resolved.flatMap((extension) => extension.hooks ? [extension.hooks] : []))
|
|
169
|
+
));
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
function createExtensionRegistry(extensions, options) {
|
|
173
|
+
return ExtensionRegistry.create(extensions, options);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export {
|
|
177
|
+
ExtensionRegistryError,
|
|
178
|
+
ExtensionRegistry,
|
|
179
|
+
createExtensionRegistry
|
|
180
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { ComponentType, ReactNode } from 'react';
|
|
2
|
+
import { RenderTarget, PageDocument } from './schema.js';
|
|
3
|
+
|
|
4
|
+
type ExtensionTarget = RenderTarget | "all";
|
|
5
|
+
type EditorActionPosition = "left" | "center" | "right";
|
|
6
|
+
type UISlotName = "toolbar.left" | "toolbar.center" | "toolbar.right" | "inspector.content";
|
|
7
|
+
type ValidationIssue = {
|
|
8
|
+
path: string;
|
|
9
|
+
message: string;
|
|
10
|
+
};
|
|
11
|
+
type FieldConfig = {
|
|
12
|
+
field: string;
|
|
13
|
+
label?: string;
|
|
14
|
+
required?: boolean;
|
|
15
|
+
};
|
|
16
|
+
type FieldProps<T = unknown> = {
|
|
17
|
+
value: T;
|
|
18
|
+
onChange(value: T): void;
|
|
19
|
+
};
|
|
20
|
+
interface BlockDefinition<P = Record<string, unknown>> {
|
|
21
|
+
type: string;
|
|
22
|
+
version: number;
|
|
23
|
+
label: string;
|
|
24
|
+
category: string;
|
|
25
|
+
targets: ExtensionTarget[];
|
|
26
|
+
defaultProps: P;
|
|
27
|
+
fields: Record<string, FieldConfig>;
|
|
28
|
+
render: Partial<Record<RenderTarget, ComponentType<P>>>;
|
|
29
|
+
dataSources?: string[];
|
|
30
|
+
validate?: (props: P) => ValidationIssue[];
|
|
31
|
+
}
|
|
32
|
+
interface FieldDefinition<T = unknown> {
|
|
33
|
+
type: string;
|
|
34
|
+
component: ComponentType<FieldProps<T>>;
|
|
35
|
+
normalize?: (value: T) => T;
|
|
36
|
+
validate?: (value: T) => ValidationIssue[];
|
|
37
|
+
}
|
|
38
|
+
type ExtensionActionContext = {
|
|
39
|
+
document: PageDocument;
|
|
40
|
+
pageId: string;
|
|
41
|
+
notify?(message: string): void;
|
|
42
|
+
};
|
|
43
|
+
interface EditorAction {
|
|
44
|
+
id: string;
|
|
45
|
+
label: string;
|
|
46
|
+
position: EditorActionPosition;
|
|
47
|
+
order?: number;
|
|
48
|
+
variant?: "default" | "primary" | "danger";
|
|
49
|
+
hidden?: (context: ExtensionActionContext) => boolean;
|
|
50
|
+
disabled?: (context: ExtensionActionContext) => boolean;
|
|
51
|
+
execute(context: ExtensionActionContext): Promise<void> | void;
|
|
52
|
+
}
|
|
53
|
+
interface RendererDefinition {
|
|
54
|
+
id: string;
|
|
55
|
+
target: RenderTarget;
|
|
56
|
+
render(document: PageDocument): ReactNode | string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* `P` defaults to `any` so an extension may contribute a narrower, validated
|
|
60
|
+
* parameter type while the framework-independent registry remains generic.
|
|
61
|
+
*/
|
|
62
|
+
interface DataSourceDefinition<P = any, R = unknown> {
|
|
63
|
+
key: string;
|
|
64
|
+
mock: (params: P) => Promise<R>;
|
|
65
|
+
live: (params: P) => Promise<R>;
|
|
66
|
+
validateParams?: (params: P) => ValidationIssue[];
|
|
67
|
+
}
|
|
68
|
+
interface TemplateDefinition {
|
|
69
|
+
id: string;
|
|
70
|
+
version: number;
|
|
71
|
+
name: string;
|
|
72
|
+
target: RenderTarget;
|
|
73
|
+
thumbnail?: string;
|
|
74
|
+
create(): PageDocument;
|
|
75
|
+
}
|
|
76
|
+
interface LifecycleHooks {
|
|
77
|
+
onChange?(document: PageDocument): void;
|
|
78
|
+
beforeSave?(document: PageDocument): Promise<PageDocument>;
|
|
79
|
+
afterSave?(result: unknown): Promise<void>;
|
|
80
|
+
beforePublish?(document: PageDocument): Promise<PageDocument>;
|
|
81
|
+
afterPublish?(result: unknown): Promise<void>;
|
|
82
|
+
onError?(error: Error): void;
|
|
83
|
+
}
|
|
84
|
+
interface UISlotContribution {
|
|
85
|
+
id: string;
|
|
86
|
+
slot: UISlotName;
|
|
87
|
+
order?: number;
|
|
88
|
+
component: ComponentType;
|
|
89
|
+
}
|
|
90
|
+
interface PageBuilderExtension {
|
|
91
|
+
/** Namespaced, stable extension identifier, such as `besttrack.tracking`. */
|
|
92
|
+
name: string;
|
|
93
|
+
version: string;
|
|
94
|
+
order?: number;
|
|
95
|
+
dependsOn?: string[];
|
|
96
|
+
blocks?: BlockDefinition[];
|
|
97
|
+
fields?: FieldDefinition[];
|
|
98
|
+
actions?: EditorAction[];
|
|
99
|
+
renderers?: RendererDefinition[];
|
|
100
|
+
dataSources?: DataSourceDefinition[];
|
|
101
|
+
templates?: TemplateDefinition[];
|
|
102
|
+
hooks?: LifecycleHooks;
|
|
103
|
+
slots?: UISlotContribution[];
|
|
104
|
+
}
|
|
105
|
+
type ExtensionRegistryOptions = {
|
|
106
|
+
disabled?: string[];
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type ExtensionRegistryErrorCode = "duplicate-extension" | "duplicate-definition" | "invalid-identifier" | "invalid-target" | "missing-dependency" | "dependency-cycle";
|
|
110
|
+
declare class ExtensionRegistryError extends Error {
|
|
111
|
+
readonly code: ExtensionRegistryErrorCode;
|
|
112
|
+
constructor(code: ExtensionRegistryErrorCode, message: string);
|
|
113
|
+
}
|
|
114
|
+
type Registered<T> = T & {
|
|
115
|
+
extension: string;
|
|
116
|
+
};
|
|
117
|
+
/** Immutable compiled view of all enabled extensions. */
|
|
118
|
+
declare class ExtensionRegistry {
|
|
119
|
+
readonly extensions: readonly PageBuilderExtension[];
|
|
120
|
+
private readonly blockMap;
|
|
121
|
+
private readonly fieldMap;
|
|
122
|
+
private readonly actionMap;
|
|
123
|
+
private readonly rendererMap;
|
|
124
|
+
private readonly dataSourceMap;
|
|
125
|
+
private readonly templateMap;
|
|
126
|
+
private readonly slotMap;
|
|
127
|
+
private readonly hooks;
|
|
128
|
+
private constructor();
|
|
129
|
+
get blocks(): readonly Registered<BlockDefinition<Record<string, unknown>>>[];
|
|
130
|
+
get fields(): readonly Registered<FieldDefinition<unknown>>[];
|
|
131
|
+
get actions(): readonly Registered<EditorAction>[];
|
|
132
|
+
get renderers(): readonly Registered<RendererDefinition>[];
|
|
133
|
+
get dataSources(): readonly Registered<DataSourceDefinition<any, unknown>>[];
|
|
134
|
+
get templates(): readonly Registered<TemplateDefinition>[];
|
|
135
|
+
getBlock(type: string): Registered<BlockDefinition<Record<string, unknown>>> | undefined;
|
|
136
|
+
getField(type: string): Registered<FieldDefinition<unknown>> | undefined;
|
|
137
|
+
getAction(id: string): Registered<EditorAction> | undefined;
|
|
138
|
+
getRenderer(id: string): Registered<RendererDefinition> | undefined;
|
|
139
|
+
getDataSource(key: string): Registered<DataSourceDefinition<any, unknown>> | undefined;
|
|
140
|
+
getTemplate(id: string): Registered<TemplateDefinition> | undefined;
|
|
141
|
+
getSlot(slot: UISlotName): readonly Registered<UISlotContribution>[];
|
|
142
|
+
notifyChange(document: PageDocument): void;
|
|
143
|
+
notifyError(error: Error): void;
|
|
144
|
+
static create(extensions: PageBuilderExtension[], options?: ExtensionRegistryOptions): ExtensionRegistry;
|
|
145
|
+
}
|
|
146
|
+
declare function createExtensionRegistry(extensions: PageBuilderExtension[], options?: ExtensionRegistryOptions): ExtensionRegistry;
|
|
147
|
+
|
|
148
|
+
export { type BlockDefinition, type DataSourceDefinition, type EditorAction, type EditorActionPosition, type ExtensionActionContext, ExtensionRegistry, ExtensionRegistryError, type ExtensionRegistryErrorCode, type ExtensionRegistryOptions, type ExtensionTarget, type FieldConfig, type FieldDefinition, type FieldProps, type LifecycleHooks, type PageBuilderExtension, type RendererDefinition, type TemplateDefinition, type UISlotContribution, type UISlotName, type ValidationIssue, createExtensionRegistry };
|