@yaoxiu/marketing-dsl 1.0.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,430 @@
1
+ /**
2
+ * DSL 的类型定义。
3
+ *
4
+ * 分两组:
5
+ * - Dsl* 是运营写的配置,也就是存进数据库的那份 JSON
6
+ * - Render* 是 core 算完之后交给框架层的渲染树,纯数据,跟框架无关
7
+ *
8
+ * 框架层只认 Render*,永远不该去读 Dsl*——一旦读了,解析逻辑就又漏回渲染层了。
9
+ */
10
+ type DslViewType = 'popup' | 'banner' | 'notice';
11
+ type DslNodeType = 'box' | 'flex' | 'repeat' | 'image' | 'text' | 'button' | 'tabs' | 'countdown';
12
+ /** 长度:数字按 px,字符串原样交给 CSS,可以带 {{ }} */
13
+ type DslLength = number | string;
14
+ /** rect: [x, y, width, height],绝对定位容器里用 */
15
+ type DslRect = [DslLength, DslLength, DslLength, DslLength];
16
+ /** 样式对象,键必须在白名单内,值可以带 {{ }} */
17
+ type DslStyle = Record<string, unknown>;
18
+ interface DslActionBase {
19
+ type: string;
20
+ }
21
+ interface DslNavigateAction extends DslActionBase {
22
+ type: 'navigate';
23
+ url: string;
24
+ target?: '_self' | '_blank';
25
+ }
26
+ interface DslCloseAction extends DslActionBase {
27
+ type: 'close';
28
+ reason?: string;
29
+ }
30
+ interface DslCloseAllAction extends DslActionBase {
31
+ type: 'closeAll';
32
+ reason?: string;
33
+ }
34
+ interface DslOpenAction extends DslActionBase {
35
+ type: 'open';
36
+ view: string;
37
+ mode?: 'stack' | 'replace';
38
+ }
39
+ interface DslSetStateAction extends DslActionBase {
40
+ type: 'setState';
41
+ key: string;
42
+ value: unknown;
43
+ }
44
+ interface DslTrackAction extends DslActionBase {
45
+ type: 'track';
46
+ event: string;
47
+ params?: Record<string, unknown>;
48
+ }
49
+ /** 调用宿主注册的方法,名字必须在 handlers 白名单内 */
50
+ interface DslCallAction extends DslActionBase {
51
+ type: 'call';
52
+ name: string;
53
+ params?: Record<string, unknown>;
54
+ }
55
+ interface DslSequenceAction extends DslActionBase {
56
+ type: 'sequence';
57
+ actions: DslAction[];
58
+ }
59
+ type DslAction = DslNavigateAction | DslCloseAction | DslCloseAllAction | DslOpenAction | DslSetStateAction | DslTrackAction | DslCallAction | DslSequenceAction;
60
+ interface DslNode {
61
+ id?: string;
62
+ type: DslNodeType;
63
+ rect?: DslRect;
64
+ style?: DslStyle;
65
+ /** 表达式,为假时整个节点不渲染 */
66
+ visibleWhen?: string;
67
+ action?: DslAction;
68
+ /** box / flex / countdown */
69
+ children?: DslNode[];
70
+ /** repeat / tabs:数组来源表达式 */
71
+ bind?: string;
72
+ /** repeat */
73
+ template?: DslNode;
74
+ itemName?: string;
75
+ indexName?: string;
76
+ /** tabs */
77
+ stateKey?: string;
78
+ labelField?: string;
79
+ itemStyle?: DslStyle;
80
+ activeItemStyle?: DslStyle;
81
+ /** image */
82
+ src?: string;
83
+ /** text */
84
+ content?: string;
85
+ /** button */
86
+ text?: string;
87
+ /** countdown */
88
+ to?: string;
89
+ precision?: 's' | 'cs';
90
+ as?: string;
91
+ format?: string;
92
+ endText?: string;
93
+ onEnd?: DslAction;
94
+ }
95
+ type DslClosePosition = 'top-right' | 'top-left' | 'top-center' | 'bottom-right' | 'bottom-left' | 'bottom-center';
96
+ interface DslCloseButton {
97
+ show?: boolean;
98
+ position?: DslClosePosition;
99
+ /** [x, y],负值把按钮移到弹窗外面 */
100
+ offset?: [DslLength, DslLength];
101
+ size?: DslLength;
102
+ icon?: string;
103
+ image?: string;
104
+ style?: DslStyle;
105
+ }
106
+ interface DslStage {
107
+ width?: DslLength;
108
+ height?: DslLength;
109
+ /** absolute(默认)子节点用 rect 定位;flow 子节点按 flex 排 */
110
+ layout?: 'absolute' | 'flow';
111
+ mask?: boolean;
112
+ maskClosable?: boolean;
113
+ closeButton?: DslCloseButton;
114
+ style?: DslStyle;
115
+ /** 曝光埋点:数据就绪、视图真正展示时触发一次 */
116
+ onShow?: DslAction;
117
+ /** 关闭埋点:点 X、点遮罩、close 动作、被 replace 顶掉都会触发 */
118
+ onClose?: DslAction;
119
+ }
120
+ interface DslView {
121
+ type: DslViewType;
122
+ stage: DslStage;
123
+ nodes: DslNode[];
124
+ }
125
+ /** 后端数据源引用,名字必须在 sources 白名单内 */
126
+ interface DslSourceRef {
127
+ $source: string;
128
+ params?: Record<string, unknown>;
129
+ }
130
+ interface DslDerived {
131
+ /** data 里的数组字段名 */
132
+ list: string;
133
+ /** state 里的下标字段名 */
134
+ indexBy: string;
135
+ }
136
+ /** 一份完整配置。单视图直接写 type/stage/nodes,多视图写 views + entry */
137
+ interface Dsl extends Partial<DslView> {
138
+ version: number;
139
+ meta?: {
140
+ name?: string;
141
+ [key: string]: unknown;
142
+ };
143
+ data?: Record<string, unknown | DslSourceRef>;
144
+ state?: Record<string, unknown>;
145
+ derived?: Record<string, DslDerived>;
146
+ views?: Record<string, DslView>;
147
+ entry?: string;
148
+ }
149
+ /** 已经算好的行内样式,键是 CSS 属性名(驼峰),值是最终字符串 */
150
+ type CssStyle = Record<string, string>;
151
+ interface RenderElement {
152
+ /** 稳定 key,框架层做列表 diff 用 */
153
+ key: string;
154
+ /** 画成什么。div 带 text 就是文本节点,带 children 就是容器 */
155
+ tag: 'div' | 'img';
156
+ style: CssStyle;
157
+ text?: string;
158
+ src?: string;
159
+ /**
160
+ * 有值才是可点的,动作和上下文都已经绑好,框架层直接挂上去即可。
161
+ *
162
+ * 【框架层必须遵守】挂上去时要阻止冒泡。
163
+ * 父子都可点是常见排版——公告条整条点开弹窗、行末 × 关闭公告条——
164
+ * 不阻止的话点 × 会连带触发外层,关掉的瞬间又弹出来。
165
+ * 没有 onClick 的节点不要绑任何事件,让它正常冒泡到有 onClick 的祖先。
166
+ */
167
+ onClick?: () => void;
168
+ children?: RenderElement[];
169
+ }
170
+ /**
171
+ * 一个视图层。框架层照着这个结构套四层 div 就行,不需要自带任何 CSS:
172
+ *
173
+ * div(layerStyle)
174
+ * div(maskStyle) ← mask 为 true 时才画
175
+ * div(scrollStyle)
176
+ * div(stageStyle)
177
+ * div(clipStyle) ← 圆角裁切,关闭按钮留在外面才能溢出
178
+ * ...nodes
179
+ * closeButton ← 有配才有
180
+ */
181
+ interface RenderLayer {
182
+ /** 视图名,同时用作 key */
183
+ name: string;
184
+ type: DslViewType;
185
+ isTop: boolean;
186
+ /** 画不画遮罩。只有栈顶的弹窗层才为 true */
187
+ mask: boolean;
188
+ onMaskClick?: () => void;
189
+ layerStyle: CssStyle;
190
+ maskStyle: CssStyle;
191
+ scrollStyle: CssStyle;
192
+ stageStyle: CssStyle;
193
+ clipStyle: CssStyle;
194
+ /** 关闭按钮,没配就是 undefined */
195
+ closeButton?: RenderElement;
196
+ nodes: RenderElement[];
197
+ }
198
+ interface RenderTree {
199
+ /** 数据没回来或加载失败时为 false,框架层什么都不画 */
200
+ ready: boolean;
201
+ /** 有弹窗层时框架层需要撑满宿主容器 */
202
+ hasPopup: boolean;
203
+ /** 根容器样式,已按 hasPopup 算好 */
204
+ rootStyle: CssStyle;
205
+ layers: RenderLayer[];
206
+ /** 这一轮里所有倒计时的结束时间戳,运行时据此决定要不要起定时器 */
207
+ countdownEndTimes: number[];
208
+ /** 只要有一个倒计时要厘秒,整体就按厘秒刷 */
209
+ countdownPrecision: 's' | 'cs';
210
+ }
211
+ type DslSource = (params: Record<string, unknown>, user: Record<string, unknown>) => unknown | Promise<unknown>;
212
+ type DslHandler = (params: Record<string, unknown>) => void;
213
+ interface RuntimeEvents {
214
+ ready: {
215
+ keys: string[];
216
+ };
217
+ close: {
218
+ reason: string;
219
+ };
220
+ track: {
221
+ event: string;
222
+ params: Record<string, unknown>;
223
+ };
224
+ navigate: {
225
+ url: string;
226
+ target: string;
227
+ };
228
+ call: {
229
+ name: string;
230
+ params: Record<string, unknown>;
231
+ };
232
+ 'state-change': Record<string, unknown>;
233
+ 'view-change': {
234
+ view: string;
235
+ mode: string;
236
+ stack: string[];
237
+ closed?: string;
238
+ };
239
+ open: {
240
+ view: string;
241
+ mode: string;
242
+ };
243
+ error: {
244
+ type: 'unknown-source';
245
+ name: string;
246
+ } | {
247
+ type: 'unknown-view';
248
+ name: string;
249
+ } | {
250
+ type: 'unknown-handler';
251
+ name: string;
252
+ } | {
253
+ type: 'unknown-action';
254
+ action: string;
255
+ } | {
256
+ type: 'unsafe-url';
257
+ raw: string;
258
+ } | {
259
+ type: 'data-source-failed';
260
+ message: string;
261
+ };
262
+ }
263
+ type RuntimeEventName = keyof RuntimeEvents;
264
+ type RuntimeEmit = <K extends RuntimeEventName>(event: K, payload: RuntimeEvents[K]) => void;
265
+ interface RuntimeOptions {
266
+ /** 宿主注入的用户上下文,配置里用 {{ user.xxx }} 读 */
267
+ user?: Record<string, unknown>;
268
+ /** 数据源白名单 */
269
+ sources?: Record<string, DslSource>;
270
+ /** call 动作的方法白名单 */
271
+ handlers?: Record<string, DslHandler>;
272
+ /** 编辑态:动作只上报不真正执行 */
273
+ editMode?: boolean;
274
+ /** 所有事件都从这里出去,宿主自己决定怎么处理 */
275
+ emit?: RuntimeEmit;
276
+ }
277
+
278
+ /**
279
+ * 运行时。
280
+ *
281
+ * 持有全部可变状态(state / 数据 / 视图栈),并在状态变化时通知订阅者。
282
+ * 框架层只做两件事:订阅、拿 getTree() 的结果画出来。
283
+ *
284
+ * 这里不碰 DOM,也不认识任何框架——换 Vue3 / React 时这个文件一行都不用改。
285
+ */
286
+
287
+ interface DslRuntime {
288
+ /** 当前渲染树。每次 subscribe 回调后重新取 */
289
+ getTree: () => RenderTree;
290
+ /** 状态变化时回调,返回取消订阅的函数 */
291
+ subscribe: (listener: () => void) => () => void;
292
+ /** 供调试台等外部读取,正常渲染不需要 */
293
+ getState: () => Record<string, unknown>;
294
+ getViewStack: () => string[];
295
+ /** 卸载时必须调用,否则倒计时定时器不会停 */
296
+ destroy: () => void;
297
+ }
298
+ declare function createRuntime(dsl: Dsl, options?: RuntimeOptions): DslRuntime;
299
+
300
+ /**
301
+ * DSL 校验
302
+ *
303
+ * 三层校验里的「编辑器」和「后端保存」两层都应该跑这个逻辑。
304
+ * 运行时是第三层,走的是「遇到不认识的节点跳过、不崩」的降级策略。
305
+ *
306
+ * 这个文件不依赖任何框架和 DOM,后端用 Node 跑校验也可以直接引。
307
+ */
308
+
309
+ interface Issue {
310
+ path: string;
311
+ message: string;
312
+ }
313
+ interface ValidateResult {
314
+ valid: boolean;
315
+ errors: Issue[];
316
+ warnings: Issue[];
317
+ }
318
+ declare const DSL_VERSION = 1;
319
+ declare const NODE_TYPES: string[];
320
+ declare const ACTION_TYPES: string[];
321
+ declare const CLOSE_POSITIONS: string[];
322
+ declare function validate(dsl: Dsl | unknown): ValidateResult;
323
+ /** 把校验结果拼成可以直接展示的文本 */
324
+ declare function formatIssues(issues: Issue[]): string;
325
+
326
+ /**
327
+ * 视图归一化。
328
+ *
329
+ * 一份 DSL 有两种写法:
330
+ * 单视图(也是绝大多数场景):顶层直接写 type / stage / nodes
331
+ * 多视图:写 views + entry,用来做「公告条点开弹窗」「二次确认」这类叠加
332
+ *
333
+ * 运行时和校验都只认归一化之后的结构,省得到处判断两种形态。
334
+ */
335
+
336
+ declare const SINGLE_VIEW_NAME = "main";
337
+ interface NormalizedViews {
338
+ entry: string;
339
+ views: Record<string, DslView>;
340
+ isMulti: boolean;
341
+ }
342
+ declare function normalizeViews(dsl: Dsl | null | undefined): NormalizedViews;
343
+
344
+ /**
345
+ * 受限表达式求值器
346
+ *
347
+ * 设计原则:配置是数据,永远不能变成可执行代码。
348
+ * 因此这里手写词法 + 递归下降语法分析,绝不使用 eval / new Function。
349
+ *
350
+ * 支持:取值(a.b.c / a[0])、字面量、! - 一元、+ - * / %、比较、&& ||、三元
351
+ * 不支持:函数调用、赋值、new、正则、对象/数组字面量
352
+ */
353
+ type Context = Record<string, unknown>;
354
+ /** 求值单个表达式;出错时返回 undefined,不让配置写错把整个弹窗搞崩 */
355
+ declare function evaluate(source: string, context: Context): unknown;
356
+ /** 校验表达式是否合法,供编辑器和保存前校验使用。合法返回空字符串 */
357
+ declare function check(source: string): string;
358
+ /**
359
+ * 插值。整串只有一个 {{ }} 时返回原始类型(保留数字/布尔),
360
+ * 否则按字符串拼接。
361
+ */
362
+ declare function interpolate(input: unknown, context: Context): unknown;
363
+ /** 深度插值:对象/数组里所有字符串都跑一遍插值 */
364
+ declare function interpolateDeep<T>(input: T, context: Context): T;
365
+
366
+ /**
367
+ * style 白名单 → CSS
368
+ *
369
+ * 只有登记在册的属性才会被输出,运营配置里写别的会被直接丢弃。
370
+ * 这样能挡住 position:fixed 全屏覆盖、z-index 越权这类通过样式做的坏事。
371
+ */
372
+
373
+ /**
374
+ * 长度值:数字按 px 处理,字符串原样透传,
375
+ * 这样 "100%" / "auto" / "calc(100% - 24px)" / "2rem" 都能用。
376
+ */
377
+ declare function toLength(value: unknown): string;
378
+ /** 校验用:判断一个值是否是合法长度 */
379
+ declare function isLength(value: unknown): boolean;
380
+ declare const ALLOWED_STYLE_KEYS: string[];
381
+ /** 把 DSL 的 style 对象转成行内样式对象 */
382
+ declare function toCssStyle(style?: DslStyle): CssStyle;
383
+
384
+ /**
385
+ * URL 白名单
386
+ *
387
+ * 运营能填的任何链接都要过这里。
388
+ * 重点是挡掉 javascript: 和 data:text/html —— 否则一个「跳转链接」字段
389
+ * 就等价于开了 JS 执行权限,前面所有的沙箱设计都白做了。
390
+ */
391
+ /** 不安全的链接返回空字符串,调用方据此判断是否拦截 */
392
+ declare function safeUrl(input: unknown): string;
393
+ /** 图片额外允许 data:image/*,方便 base64 占位图 */
394
+ declare function safeImageUrl(input: unknown): string;
395
+
396
+ /**
397
+ * 倒计时。只负责「算时间」,不负责「长什么样」。
398
+ *
399
+ * 时间是运行时数据,DSL 表达不了,所以必须由解释器提供;
400
+ * 但显示形态(一行文字 / 分格数字 / 带厘秒)是排版,留在 DSL 里。
401
+ * 所以这里只把时间片段算出来注入上下文,排版由 children 自己决定。
402
+ *
403
+ * 剩余时间按「结束时间 − 当前时间」实时算,不是逐帧递减:
404
+ * 浏览器把后台标签页暂停后再切回来,显示会自动校准,不会走慢。
405
+ */
406
+ interface CountdownParts {
407
+ /** 天数 */
408
+ d: string;
409
+ /** 小时 0~23,已补零 */
410
+ h: string;
411
+ /** 总小时数,用于 143:58:07 这种不按天折算的写法 */
412
+ hAll: string;
413
+ /** 分,已补零 */
414
+ m: string;
415
+ /** 秒,已补零 */
416
+ s: string;
417
+ /** 厘秒,已补零。只在 precision 为 cs 时有意义 */
418
+ cs: string;
419
+ /** 是否已结束 */
420
+ ended: boolean;
421
+ }
422
+ type CountdownPrecision = 's' | 'cs';
423
+ /** 把 to 解析成时间戳。兼容 '2026-09-01 00:00:00' 这种 Safari 不认的格式 */
424
+ declare function parseEndTime(to: unknown): number;
425
+ /** 算出某个时刻的时间片段 */
426
+ declare function computeParts(endTime: number, now?: number): CountdownParts;
427
+ /** 不给 children 时退化成一行文本,占位符 {d} {h} {hAll} {m} {s} {cs} */
428
+ declare function formatParts(parts: CountdownParts, format?: string): string;
429
+
430
+ export { ACTION_TYPES, ALLOWED_STYLE_KEYS, CLOSE_POSITIONS, type CountdownParts, type CountdownPrecision, type CssStyle, DSL_VERSION, type Dsl, type DslAction, type DslActionBase, type DslCallAction, type DslCloseAction, type DslCloseAllAction, type DslCloseButton, type DslClosePosition, type DslDerived, type DslHandler, type DslLength, type DslNavigateAction, type DslNode, type DslNodeType, type DslOpenAction, type DslRect, type DslRuntime, type DslSequenceAction, type DslSetStateAction, type DslSource, type DslSourceRef, type DslStage, type DslStyle, type DslTrackAction, type DslView, type DslViewType, type Issue, NODE_TYPES, type NormalizedViews, type RenderElement, type RenderLayer, type RenderTree, type RuntimeEmit, type RuntimeEventName, type RuntimeEvents, type RuntimeOptions, SINGLE_VIEW_NAME, type ValidateResult, check, computeParts, createRuntime, evaluate, formatIssues, formatParts, interpolate, interpolateDeep, isLength, normalizeViews, parseEndTime, safeImageUrl, safeUrl, toCssStyle, toLength, validate };