@shopbb/helium 0.8.0 → 0.9.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/dist/page-schema/PageRenderer.d.ts +29 -0
- package/dist/page-schema/PageRenderer.d.ts.map +1 -0
- package/dist/page-schema/PageRenderer.js +73 -0
- package/dist/page-schema/PageRenderer.js.map +1 -0
- package/dist/page-schema/index.d.ts +20 -0
- package/dist/page-schema/index.d.ts.map +1 -0
- package/dist/page-schema/index.js +18 -0
- package/dist/page-schema/index.js.map +1 -0
- package/dist/page-schema/sections/Banner.d.ts +10 -0
- package/dist/page-schema/sections/Banner.d.ts.map +1 -0
- package/dist/page-schema/sections/Banner.js +35 -0
- package/dist/page-schema/sections/Banner.js.map +1 -0
- package/dist/page-schema/sections/Hero.d.ts +15 -0
- package/dist/page-schema/sections/Hero.d.ts.map +1 -0
- package/dist/page-schema/sections/Hero.js +44 -0
- package/dist/page-schema/sections/Hero.js.map +1 -0
- package/dist/page-schema/sections/Image.d.ts +10 -0
- package/dist/page-schema/sections/Image.d.ts.map +1 -0
- package/dist/page-schema/sections/Image.js +12 -0
- package/dist/page-schema/sections/Image.js.map +1 -0
- package/dist/page-schema/sections/ProductGrid.d.ts +29 -0
- package/dist/page-schema/sections/ProductGrid.d.ts.map +1 -0
- package/dist/page-schema/sections/ProductGrid.js +28 -0
- package/dist/page-schema/sections/ProductGrid.js.map +1 -0
- package/dist/page-schema/sections/RichText.d.ts +13 -0
- package/dist/page-schema/sections/RichText.d.ts.map +1 -0
- package/dist/page-schema/sections/RichText.js +16 -0
- package/dist/page-schema/sections/RichText.js.map +1 -0
- package/dist/page-schema/sections/Spacer.d.ts +10 -0
- package/dist/page-schema/sections/Spacer.d.ts.map +1 -0
- package/dist/page-schema/sections/Spacer.js +6 -0
- package/dist/page-schema/sections/Spacer.js.map +1 -0
- package/dist/page-schema/types.d.ts +138 -0
- package/dist/page-schema/types.d.ts.map +1 -0
- package/dist/page-schema/types.js +129 -0
- package/dist/page-schema/types.js.map +1 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +5 -0
- package/dist/react.js.map +1 -1
- package/package.json +1 -1
- package/src/page-schema/PageRenderer.tsx +147 -0
- package/src/page-schema/index.ts +48 -0
- package/src/page-schema/sections/Banner.tsx +63 -0
- package/src/page-schema/sections/Hero.tsx +92 -0
- package/src/page-schema/sections/Image.tsx +42 -0
- package/src/page-schema/sections/ProductGrid.tsx +96 -0
- package/src/page-schema/sections/RichText.tsx +49 -0
- package/src/page-schema/sections/Spacer.tsx +15 -0
- package/src/page-schema/types.ts +286 -0
- package/src/react.tsx +33 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <ProductGridSection> — 商品网格
|
|
3
|
+
*
|
|
4
|
+
* 拉取商品数据需要 storefront client,由父组件通过 props 注入避免组件耦合。
|
|
5
|
+
*
|
|
6
|
+
* P0 简化:组件本身不拉数据,仅按传入的 products 数组渲染。Page renderer 负责
|
|
7
|
+
* 在渲染前根据 selection_mode 拉好商品。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as React from 'react';
|
|
11
|
+
import type { ProductGridData } from '../types';
|
|
12
|
+
|
|
13
|
+
export interface ProductGridSectionProps {
|
|
14
|
+
data: ProductGridData;
|
|
15
|
+
/** 父组件预先拉好的商品数据 */
|
|
16
|
+
products?: Array<{
|
|
17
|
+
id: string;
|
|
18
|
+
handle: string;
|
|
19
|
+
title: string;
|
|
20
|
+
price?: { amount: string; currencyCode: string };
|
|
21
|
+
image?: { url: string; alt?: string };
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ProductGridSection({ data, products }: ProductGridSectionProps): React.ReactElement {
|
|
26
|
+
const columns = data.columns ?? 3;
|
|
27
|
+
const list = products ?? [];
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<section style={{ padding: '64px 32px', maxWidth: 1280, margin: '0 auto' }}>
|
|
31
|
+
{data.title && (
|
|
32
|
+
<h2 style={{ fontSize: 'clamp(1.5rem, 3vw, 2rem)', margin: 0, fontWeight: 800 }}>{data.title}</h2>
|
|
33
|
+
)}
|
|
34
|
+
{data.subtitle && (
|
|
35
|
+
<p style={{ marginTop: 8, color: '#64748b', fontSize: 15 }}>{data.subtitle}</p>
|
|
36
|
+
)}
|
|
37
|
+
<div
|
|
38
|
+
style={{
|
|
39
|
+
marginTop: 24,
|
|
40
|
+
display: 'grid',
|
|
41
|
+
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
42
|
+
gap: 24,
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
{list.length === 0 ? (
|
|
46
|
+
<div
|
|
47
|
+
style={{
|
|
48
|
+
gridColumn: '1 / -1',
|
|
49
|
+
padding: '40px 0',
|
|
50
|
+
textAlign: 'center',
|
|
51
|
+
color: '#94a3b8',
|
|
52
|
+
fontSize: 14,
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
{data.selection_mode === 'manual'
|
|
56
|
+
? '(暂未选定商品)'
|
|
57
|
+
: '(暂无商品数据,编辑器预览模式不拉取真实数据)'}
|
|
58
|
+
</div>
|
|
59
|
+
) : (
|
|
60
|
+
list.map((p) => (
|
|
61
|
+
<a
|
|
62
|
+
key={p.id}
|
|
63
|
+
href={`/products/${p.handle}`}
|
|
64
|
+
style={{
|
|
65
|
+
background: '#fff',
|
|
66
|
+
border: '1px solid #e2e8f0',
|
|
67
|
+
borderRadius: 10,
|
|
68
|
+
overflow: 'hidden',
|
|
69
|
+
textDecoration: 'none',
|
|
70
|
+
color: 'inherit',
|
|
71
|
+
display: 'block',
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
{p.image && (
|
|
75
|
+
<img
|
|
76
|
+
src={p.image.url}
|
|
77
|
+
alt={p.image.alt || p.title}
|
|
78
|
+
style={{ width: '100%', aspectRatio: '1 / 1', objectFit: 'cover', display: 'block' }}
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
81
|
+
<div style={{ padding: 14 }}>
|
|
82
|
+
<div style={{ fontSize: 14, fontWeight: 600, color: '#0f172a' }}>{p.title}</div>
|
|
83
|
+
{p.price && (
|
|
84
|
+
<div style={{ marginTop: 6, fontSize: 15, color: '#ea580c', fontWeight: 700 }}>
|
|
85
|
+
{p.price.currencyCode === 'CNY' ? '¥' : p.price.currencyCode + ' '}
|
|
86
|
+
{p.price.amount}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
</a>
|
|
91
|
+
))
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
</section>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <RichTextSection> — 富文本
|
|
3
|
+
*
|
|
4
|
+
* P0:只支持 plain text + 简单换行;markdown 渲染留给商家自己用 marked 库或写成 html
|
|
5
|
+
* mode。等编辑器成熟后再上 markdown 渲染。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as React from 'react';
|
|
9
|
+
import type { RichTextData } from '../types';
|
|
10
|
+
|
|
11
|
+
export interface RichTextSectionProps {
|
|
12
|
+
data: RichTextData;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const WIDTH_MAP: Record<NonNullable<RichTextData['max_width']>, string> = {
|
|
16
|
+
narrow: '640px',
|
|
17
|
+
normal: '880px',
|
|
18
|
+
wide: '1200px',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function RichTextSection({ data }: RichTextSectionProps): React.ReactElement {
|
|
22
|
+
const maxWidth = WIDTH_MAP[data.max_width ?? 'normal'];
|
|
23
|
+
|
|
24
|
+
if (data.format === 'html') {
|
|
25
|
+
return (
|
|
26
|
+
<section style={{ padding: '48px 32px' }}>
|
|
27
|
+
<div
|
|
28
|
+
style={{ maxWidth, margin: '0 auto', lineHeight: 1.7, fontSize: 15.5, color: '#0f172a' }}
|
|
29
|
+
dangerouslySetInnerHTML={{ __html: data.body }}
|
|
30
|
+
/>
|
|
31
|
+
</section>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// markdown / plain:按段落分割(双换行 = 新段落)
|
|
36
|
+
const paragraphs = data.body.split(/\n{2,}/);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<section style={{ padding: '48px 32px' }}>
|
|
40
|
+
<div style={{ maxWidth, margin: '0 auto', lineHeight: 1.7, fontSize: 15.5, color: '#0f172a' }}>
|
|
41
|
+
{paragraphs.map((p, i) => (
|
|
42
|
+
<p key={i} style={{ margin: i === 0 ? '0' : '1em 0 0', whiteSpace: 'pre-line' }}>
|
|
43
|
+
{p}
|
|
44
|
+
</p>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
</section>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <SpacerSection> — 垂直留白
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
import type { SpacerData } from '../types';
|
|
7
|
+
|
|
8
|
+
export interface SpacerSectionProps {
|
|
9
|
+
data: SpacerData;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function SpacerSection({ data }: SpacerSectionProps): React.ReactElement {
|
|
13
|
+
const height = typeof data.height === 'number' ? `${data.height}px` : data.height;
|
|
14
|
+
return <div style={{ height }} aria-hidden />;
|
|
15
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page Schema — 装修页面的结构化数据模型
|
|
3
|
+
*
|
|
4
|
+
* 一个 Page 是若干 Section 的有序数组。每个 Section 是 (type, data) 的结构化值,
|
|
5
|
+
* 不是任意 React 代码。这样 agent / 编辑器 / 渲染器都能精准操作。
|
|
6
|
+
*
|
|
7
|
+
* 设计原则:
|
|
8
|
+
* 1. 强 schema:每个 type 的 data 字段固定,agent 不能"乱发挥"
|
|
9
|
+
* 2. stable id:每个 section 有唯一 id,编辑器选中、agent 引用都靠它
|
|
10
|
+
* 3. 可演进:新加 section type 只需扩 SectionType 联合 + 实现对应 React 组件
|
|
11
|
+
* 4. 反规范化:data 是平铺字段而不是嵌套结构,方便 agent 修改单个属性
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ============================================================
|
|
15
|
+
// 基础类型
|
|
16
|
+
// ============================================================
|
|
17
|
+
|
|
18
|
+
export type SectionType =
|
|
19
|
+
| 'hero'
|
|
20
|
+
| 'product-grid'
|
|
21
|
+
| 'banner'
|
|
22
|
+
| 'rich-text'
|
|
23
|
+
| 'image'
|
|
24
|
+
| 'spacer';
|
|
25
|
+
|
|
26
|
+
/** 通用 section wrapper */
|
|
27
|
+
export interface Section<T extends SectionType = SectionType, D = unknown> {
|
|
28
|
+
/** 唯一 ID,stable,编辑器与 agent 都引用它 */
|
|
29
|
+
id: string;
|
|
30
|
+
/** Section 类型 */
|
|
31
|
+
type: T;
|
|
32
|
+
/** 类型对应的数据字段 */
|
|
33
|
+
data: D;
|
|
34
|
+
/** 是否可见。隐藏的 section 不渲染但保留在 schema 里(方便商家临时下线某块) */
|
|
35
|
+
visible: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ============================================================
|
|
39
|
+
// 每种 section 的 data 字段
|
|
40
|
+
// ============================================================
|
|
41
|
+
|
|
42
|
+
export interface HeroData {
|
|
43
|
+
/** 大标题 */
|
|
44
|
+
headline: string;
|
|
45
|
+
/** 副标题 */
|
|
46
|
+
subheadline?: string;
|
|
47
|
+
/** 背景图 URL */
|
|
48
|
+
background_image?: string;
|
|
49
|
+
/** 背景色,hex 或 css 颜色 */
|
|
50
|
+
background_color?: string;
|
|
51
|
+
/** 文字颜色 */
|
|
52
|
+
text_color?: string;
|
|
53
|
+
/** 主 CTA 按钮文案 */
|
|
54
|
+
cta_label?: string;
|
|
55
|
+
/** 主 CTA 链接 */
|
|
56
|
+
cta_link?: string;
|
|
57
|
+
/** 副 CTA 按钮文案 */
|
|
58
|
+
secondary_cta_label?: string;
|
|
59
|
+
/** 副 CTA 链接 */
|
|
60
|
+
secondary_cta_link?: string;
|
|
61
|
+
/** 高度 */
|
|
62
|
+
height?: 'small' | 'medium' | 'large' | 'full';
|
|
63
|
+
/** 文字对齐 */
|
|
64
|
+
text_align?: 'left' | 'center' | 'right';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ProductGridData {
|
|
68
|
+
/** 标题(可选) */
|
|
69
|
+
title?: string;
|
|
70
|
+
/** 副标题 */
|
|
71
|
+
subtitle?: string;
|
|
72
|
+
/** 选商品的方式 */
|
|
73
|
+
selection_mode: 'manual' | 'collection' | 'newest' | 'top-selling';
|
|
74
|
+
/** manual 模式下,指定的 product handle 列表 */
|
|
75
|
+
product_handles?: string[];
|
|
76
|
+
/** collection 模式下,指定的 collection handle */
|
|
77
|
+
collection_handle?: string;
|
|
78
|
+
/** 自动模式下取多少个 */
|
|
79
|
+
limit?: number;
|
|
80
|
+
/** 一行几列 */
|
|
81
|
+
columns?: 2 | 3 | 4;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface BannerData {
|
|
85
|
+
/** 主文案 */
|
|
86
|
+
text: string;
|
|
87
|
+
/** 链接(可选) */
|
|
88
|
+
link?: string;
|
|
89
|
+
/** 链接打开文案,例如"立即查看" */
|
|
90
|
+
link_label?: string;
|
|
91
|
+
/** 背景色 */
|
|
92
|
+
background_color?: string;
|
|
93
|
+
/** 文字色 */
|
|
94
|
+
text_color?: string;
|
|
95
|
+
/** 关闭按钮(用户能 dismiss) */
|
|
96
|
+
dismissible?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface RichTextData {
|
|
100
|
+
/** Markdown / HTML 文本 */
|
|
101
|
+
body: string;
|
|
102
|
+
/** 渲染模式 */
|
|
103
|
+
format?: 'markdown' | 'html';
|
|
104
|
+
/** 最大宽度(约束阅读体验) */
|
|
105
|
+
max_width?: 'narrow' | 'normal' | 'wide';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ImageData {
|
|
109
|
+
/** 图片 URL */
|
|
110
|
+
url: string;
|
|
111
|
+
/** alt 文本(无障碍) */
|
|
112
|
+
alt?: string;
|
|
113
|
+
/** 点击跳转链接 */
|
|
114
|
+
link?: string;
|
|
115
|
+
/** 宽度模式 */
|
|
116
|
+
width?: 'full' | 'normal' | 'narrow';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface SpacerData {
|
|
120
|
+
/** 高度(像素或者 css 单位字符串) */
|
|
121
|
+
height: number | string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================
|
|
125
|
+
// 联合类型
|
|
126
|
+
// ============================================================
|
|
127
|
+
|
|
128
|
+
export type HeroSection = Section<'hero', HeroData>;
|
|
129
|
+
export type ProductGridSection = Section<'product-grid', ProductGridData>;
|
|
130
|
+
export type BannerSection = Section<'banner', BannerData>;
|
|
131
|
+
export type RichTextSection = Section<'rich-text', RichTextData>;
|
|
132
|
+
export type ImageSection = Section<'image', ImageData>;
|
|
133
|
+
export type SpacerSection = Section<'spacer', SpacerData>;
|
|
134
|
+
|
|
135
|
+
export type AnySection =
|
|
136
|
+
| HeroSection
|
|
137
|
+
| ProductGridSection
|
|
138
|
+
| BannerSection
|
|
139
|
+
| RichTextSection
|
|
140
|
+
| ImageSection
|
|
141
|
+
| SpacerSection;
|
|
142
|
+
|
|
143
|
+
// ============================================================
|
|
144
|
+
// Page 与全局配置
|
|
145
|
+
// ============================================================
|
|
146
|
+
|
|
147
|
+
/** 全局样式(应用到整个 page 的层级) */
|
|
148
|
+
export interface PageGlobalStyle {
|
|
149
|
+
primary_color?: string;
|
|
150
|
+
background_color?: string;
|
|
151
|
+
font_family?: 'system' | 'serif' | 'sans-serif' | string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface Page {
|
|
155
|
+
/** 页面标识,例如 'home' / 'about' / 'collections/featured' */
|
|
156
|
+
slug: string;
|
|
157
|
+
/** 浏览器标题(可选,缺省用 store name) */
|
|
158
|
+
title?: string;
|
|
159
|
+
/** Section 数组,按顺序渲染 */
|
|
160
|
+
sections: AnySection[];
|
|
161
|
+
/** 全局样式 */
|
|
162
|
+
global_style?: PageGlobalStyle;
|
|
163
|
+
/** Schema 版本,用于将来升级时迁移 */
|
|
164
|
+
schema_version: 1;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ============================================================
|
|
168
|
+
// Helper:生成 section ID
|
|
169
|
+
// ============================================================
|
|
170
|
+
|
|
171
|
+
export function newSectionId(type: SectionType): string {
|
|
172
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
173
|
+
return `sec_${type.replace('-', '_')}_${rand}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ============================================================
|
|
177
|
+
// 默认 data —— 商家新增某 type 的 section 时用作初始值
|
|
178
|
+
// ============================================================
|
|
179
|
+
|
|
180
|
+
export const DEFAULT_DATA: { [K in SectionType]: AnySection['data'] } = {
|
|
181
|
+
hero: {
|
|
182
|
+
headline: '欢迎来到我们的店铺',
|
|
183
|
+
subheadline: '精选商品 全场包邮',
|
|
184
|
+
height: 'medium',
|
|
185
|
+
text_align: 'center',
|
|
186
|
+
background_color: '#0f172a',
|
|
187
|
+
text_color: '#ffffff',
|
|
188
|
+
cta_label: '立刻购买',
|
|
189
|
+
cta_link: '/products',
|
|
190
|
+
} as HeroData,
|
|
191
|
+
'product-grid': {
|
|
192
|
+
title: '精选商品',
|
|
193
|
+
selection_mode: 'newest',
|
|
194
|
+
limit: 6,
|
|
195
|
+
columns: 3,
|
|
196
|
+
} as ProductGridData,
|
|
197
|
+
banner: {
|
|
198
|
+
text: '满 200 减 20,限时优惠',
|
|
199
|
+
background_color: '#f97316',
|
|
200
|
+
text_color: '#ffffff',
|
|
201
|
+
} as BannerData,
|
|
202
|
+
'rich-text': {
|
|
203
|
+
body: '在这里写一段品牌故事或说明文字。',
|
|
204
|
+
format: 'markdown',
|
|
205
|
+
max_width: 'normal',
|
|
206
|
+
} as RichTextData,
|
|
207
|
+
image: {
|
|
208
|
+
url: 'https://placehold.co/1200x400',
|
|
209
|
+
alt: '广告图',
|
|
210
|
+
width: 'full',
|
|
211
|
+
} as ImageData,
|
|
212
|
+
spacer: {
|
|
213
|
+
height: 40,
|
|
214
|
+
} as SpacerData,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** 创建一个新 section(带默认 data) */
|
|
218
|
+
export function createSection<T extends SectionType>(type: T): AnySection {
|
|
219
|
+
return {
|
|
220
|
+
id: newSectionId(type),
|
|
221
|
+
type,
|
|
222
|
+
data: { ...(DEFAULT_DATA[type] as object) } as any,
|
|
223
|
+
visible: true,
|
|
224
|
+
} as AnySection;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 创建一个空白 page */
|
|
228
|
+
export function createEmptyPage(slug: string): Page {
|
|
229
|
+
return {
|
|
230
|
+
slug,
|
|
231
|
+
sections: [],
|
|
232
|
+
schema_version: 1,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ============================================================
|
|
237
|
+
// 校验
|
|
238
|
+
// ============================================================
|
|
239
|
+
|
|
240
|
+
const REQUIRED_FIELDS: { [K in SectionType]: string[] } = {
|
|
241
|
+
hero: ['headline'],
|
|
242
|
+
'product-grid': ['selection_mode'],
|
|
243
|
+
banner: ['text'],
|
|
244
|
+
'rich-text': ['body'],
|
|
245
|
+
image: ['url'],
|
|
246
|
+
spacer: ['height'],
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** 校验一个 section 数据完整性。返回错误列表,空数组表示合法 */
|
|
250
|
+
export function validateSection(section: AnySection): string[] {
|
|
251
|
+
const errs: string[] = [];
|
|
252
|
+
if (!section.id) errs.push('id_required');
|
|
253
|
+
if (!section.type) errs.push('type_required');
|
|
254
|
+
const required = REQUIRED_FIELDS[section.type];
|
|
255
|
+
if (!required) {
|
|
256
|
+
errs.push(`unknown_section_type:${section.type}`);
|
|
257
|
+
return errs;
|
|
258
|
+
}
|
|
259
|
+
const data = section.data as unknown as Record<string, unknown>;
|
|
260
|
+
for (const f of required) {
|
|
261
|
+
if (data[f] == null || data[f] === '') {
|
|
262
|
+
errs.push(`missing_field:${section.type}.${f}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return errs;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 校验整个 page */
|
|
269
|
+
export function validatePage(page: Page): string[] {
|
|
270
|
+
const errs: string[] = [];
|
|
271
|
+
if (!page.slug) errs.push('slug_required');
|
|
272
|
+
if (page.schema_version !== 1) errs.push(`unknown_schema_version:${page.schema_version}`);
|
|
273
|
+
if (!Array.isArray(page.sections)) {
|
|
274
|
+
errs.push('sections_must_be_array');
|
|
275
|
+
return errs;
|
|
276
|
+
}
|
|
277
|
+
const seenIds = new Set<string>();
|
|
278
|
+
for (let i = 0; i < page.sections.length; i++) {
|
|
279
|
+
const s = page.sections[i];
|
|
280
|
+
if (seenIds.has(s.id)) errs.push(`duplicate_section_id:${s.id}`);
|
|
281
|
+
seenIds.add(s.id);
|
|
282
|
+
const secErrs = validateSection(s);
|
|
283
|
+
errs.push(...secErrs.map((e) => `section[${i}]:${e}`));
|
|
284
|
+
}
|
|
285
|
+
return errs;
|
|
286
|
+
}
|
package/src/react.tsx
CHANGED
|
@@ -274,6 +274,39 @@ function escapeHtml(s: string): string {
|
|
|
274
274
|
/**
|
|
275
275
|
* 把 head + react stream + tail 拼成一个 ReadableStream<Uint8Array>
|
|
276
276
|
*/
|
|
277
|
+
// ============================================================
|
|
278
|
+
// Page Schema re-export
|
|
279
|
+
// ============================================================
|
|
280
|
+
// 结构化页面渲染。Admin 编辑器与 storefront 共用同一份组件。
|
|
281
|
+
export {
|
|
282
|
+
PageRenderer,
|
|
283
|
+
HeroSection,
|
|
284
|
+
ProductGridSection,
|
|
285
|
+
BannerSection,
|
|
286
|
+
RichTextSection,
|
|
287
|
+
ImageSection,
|
|
288
|
+
SpacerSection,
|
|
289
|
+
createSection,
|
|
290
|
+
createEmptyPage,
|
|
291
|
+
validateSection,
|
|
292
|
+
validatePage,
|
|
293
|
+
DEFAULT_DATA,
|
|
294
|
+
newSectionId,
|
|
295
|
+
} from './page-schema';
|
|
296
|
+
export type {
|
|
297
|
+
Page,
|
|
298
|
+
PageGlobalStyle,
|
|
299
|
+
AnySection,
|
|
300
|
+
SectionType,
|
|
301
|
+
HeroData,
|
|
302
|
+
ProductGridData,
|
|
303
|
+
BannerData,
|
|
304
|
+
RichTextData,
|
|
305
|
+
ImageData,
|
|
306
|
+
SpacerData,
|
|
307
|
+
PageRendererProps,
|
|
308
|
+
} from './page-schema';
|
|
309
|
+
|
|
277
310
|
// ============================================================
|
|
278
311
|
// Tracking SDK re-export
|
|
279
312
|
// ============================================================
|