@yaoxiu/marketing-dsl 1.5.2 → 2.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.
package/README.md CHANGED
@@ -38,6 +38,36 @@ if (!result.valid) {
38
38
  // warnings 不阻断保存,但该提示运营,比如样式属性不在白名单里会被忽略
39
39
  ```
40
40
 
41
+ #### 可选:校验 `{{ user.xxx }}` 用到的字段
42
+
43
+ `validate` 的第二个参数是可选项,**不传时行为完全不变**。传了 `userFields` 就会检查配置里
44
+ 所有插值位置引用的 `user` 字段是否在白名单内,不在的报 **error**(附最接近的候选字段名)。
45
+
46
+ ```ts
47
+ import { validate, DEFAULT_USER_FIELDS } from '@yaoxiu/marketing-dsl';
48
+
49
+ const result = validate(dsl, { userFields: DEFAULT_USER_FIELDS });
50
+ // { path: 'nodes[0].content',
51
+ // message: '用户信息里没有 "user.remainDay" 这个字段,是不是想写 "user.remainDays"?…' }
52
+ ```
53
+
54
+ `user` 是宿主注入的登录用户信息对象,运营写错字段名(`user.expireDays` 而不是
55
+ `user.remainDays`)运行时只会求值为空——页面上就是一处空白、倒计时归零,投到线上才发现。
56
+ 所以保存时拦一道。
57
+
58
+ `DEFAULT_USER_FIELDS` 是各平台通用的那套用户字段:
59
+
60
+ `shopId` / `shopName` / `collectCount` / `albbCollectCount` / `isNewUser` / `levelId` /
61
+ `levelName` / `serviceExpireTime` / `remainDays` / `userTypeNew` / `serviceInfo`
62
+
63
+ 刻意排除 `sourceData`(后端原始响应整包,结构随平台漂移)和 `mcToken`(令牌,不该出现在
64
+ 运营配置里)。字段的中文说明在 `USER_FIELD_LABELS`,编辑器做提示可以直接用。
65
+ 只想单独跑这一项检查时用 `validateUserFields(dsl, fields)`。
66
+
67
+ 检查覆盖**全部字符串值**里的 `{{ }}`(文案 `content`、`to`、`src`、`style` 的每个值、
68
+ 动作的 `params` …),以及 `visibleWhen` 这种整串就是表达式的字段——
69
+ 正向列举「哪些字段能写插值」必漏,所以是反过来做的。
70
+
41
71
  #### 埋点事件名(`track` 动作的 `event`)有格式要求
42
72
 
43
73
  ```
@@ -95,8 +125,8 @@ unsubscribe();
95
125
  ```ts
96
126
  {
97
127
  ready: true, // 数据源没回来时是 false,什么都别画
98
- hasPopup: true,
99
- rootStyle: { ... },
128
+ hasPopup: true, // 当前视图栈里有弹窗(不是「配置里有弹窗视图」)
129
+ rootStyle: { ... }, // 恒 relative;弹窗铺满视口由 layerStyle 的 fixed 负责
100
130
  layers: [{
101
131
  name: 'main',
102
132
  isTop: true,
@@ -122,6 +152,46 @@ unsubscribe();
122
152
 
123
153
  父子都可点是常见排版——公告条整条点开弹窗、行末 × 关闭公告条——不阻止的话点 × 会连带触发外层,关掉的瞬间又弹出来。没有 `onClick` 的节点不要绑任何事件,让它正常冒泡到有 `onClick` 的祖先。
124
154
 
155
+ ## 运行时事件(`emit`)
156
+
157
+ | 事件 | 何时抛 | 宿主拿它干嘛 |
158
+ | -------------- | -------------------------------------------- | --------------------------------------- |
159
+ | `ready` | 数据源全部返回 | 知道可以画了 |
160
+ | `interaction` | **一次手势 / 一个时机一条**,见下 | **上报**(曝光 / 点击 / 关闭只认它) |
161
+ | `track` | 每执行一个 `track` 动作 | 接自有埋点系统 |
162
+ | `navigate` | 每执行一个 `navigate` 动作 | 想自己接管跳转时用(core 默认已经跳了) |
163
+ | `close` | 物料整体关闭(点 × / 遮罩 / 倒计时 / close) | 把组件从页面上移除,并上报一条 `close` |
164
+ | `call` | 每执行一个 `call` 动作 | 宿主自己注册的方法已被调用 |
165
+ | `open` | 打开另一个视图 | 调试用 |
166
+ | `view-change` | 视图栈变化 | 调试用 |
167
+ | `state-change` | `setState` 改了状态 | 调试用 |
168
+ | `error` | 非法 URL / 未注册数据源或方法 / 未知动作 | 排查配置问题 |
169
+
170
+ ### `interaction`:动作粒度之外再来一条交互粒度
171
+
172
+ `track` / `navigate` 是**动作**粒度:一次点击里配 `sequence: [track, navigate]`(先埋点再跳转)
173
+ 是标准写法,按动作上报会把点击量翻倍;`track` 还能挂在 `stage.onShow` / `onClose` 上,
174
+ 动作粒度看不出时机,一律算成点击就把曝光和关闭也计进了点击。
175
+
176
+ 所以在**交互**粒度上再抛一条汇总事件,一次手势 / 一个时机只有一条:
177
+
178
+ ```ts
179
+ interface DslInteractionEvent {
180
+ trigger: 'show' | 'close' | 'action'; // 触发时机
181
+ reason?: string; // 仅 trigger='close'
182
+ track?: { event: string; params?: Record<string, unknown> }; // 多个时取第一个
183
+ navigate?: { url: string; target?: string }; // 多个时取第一个
184
+ }
185
+ ```
186
+
187
+ - `stage.onShow` 触发 → `{ trigger: 'show', ... }`
188
+ - `stage.onClose` 触发 → `{ trigger: 'close', reason, ... }`
189
+ - 点了带 `action` 的节点(含关闭按钮、tab、倒计时 `onEnd`)→ `{ trigger: 'action', ... }`
190
+ - 没配动作的时机不抛(不制造空事件)
191
+
192
+ `interaction` 是**新增**的,既有事件语义一个都没变 —— 宿主该用 `navigate` 接管跳转、
193
+ 用 `track` 接自有埋点系统的,照旧。上报侧只订 `interaction`,见下方「上报契约」。
194
+
125
195
  ## 安全边界
126
196
 
127
197
  这几条是设计红线,不会因为「就差一点点」而放开:
@@ -161,7 +231,8 @@ unsubscribe();
161
231
  ```
162
232
 
163
233
  上报出去就是 `{ "id": "...", "event": "close", "reason": "user-dismiss" }`,后端看到这个 `reason`
164
- 就永久压制这条物料。注意这个信息走 **`close` 的 `reason`**,不走 `extra`——`extra` 只属于 `click`。
234
+ 就永久压制这条物料。注意这个信息走 **`close` 的 `reason`**,不是点击详情 —— 带 `trigger`
235
+ 点击 `extra` 只属于 `click`。
165
236
 
166
237
  ## 完整 DSL 语法
167
238
 
@@ -198,27 +269,59 @@ buildAiPrompt(); // 生成喂给 AI 的提示词
198
269
  import {
199
270
  mapRuntimeEventToReport,
200
271
  buildExposureReport,
272
+ buildCloseReport,
273
+ pickInteractionTrack,
201
274
  } from '@yaoxiu/marketing-dsl/report';
202
275
  import type { MarketingReportParams } from '@yaoxiu/marketing-dsl/report';
203
276
 
204
- // 宿主把物料画到页面上时主动发(曝光不由运行时事件触发,解释器不知道自己被画出来了)
277
+ // 点击:只认 interaction,且只认 trigger='action'
278
+ mapRuntimeEventToReport(materialId, 'interaction', {
279
+ trigger: 'action',
280
+ track: { event: 'banner_renew_click', params: { pos: 1 } },
281
+ navigate: { url: 'https://a.com/renew' },
282
+ });
283
+ // → { id, event: 'click',
284
+ // extra: { trigger: 'track', name: 'banner_renew_click', params: { pos: 1 },
285
+ // url: 'https://a.com/renew' } }
286
+
287
+ // 曝光 / 关闭由宿主主动发(解释器不知道自己被画出来了,也不该替宿主决定关不关)
205
288
  buildExposureReport(materialId); // → { id, event: 'exposure' }
289
+ buildCloseReport(materialId, { reason: 'mask' }); // → { id, event: 'close', reason: 'mask' }
206
290
 
207
- // runtime emit 事件直接喂进来,返回 null 表示这个事件不产生上报
208
- mapRuntimeEventToReport(materialId, 'track', {
209
- event: 'banner_renew_click',
210
- params: { pos: 1 },
211
- });
212
- // → { id, event: 'click', extra: { trigger: 'track', name: 'banner_renew_click', params: { pos: 1 } } }
213
- mapRuntimeEventToReport(materialId, 'navigate', { url: 'https://a.com' });
214
- // → { id, event: 'click', extra: { trigger: 'navigate', url: 'https://a.com' } }
215
- mapRuntimeEventToReport(materialId, 'close', { reason: 'mask' });
216
- // → { id, event: 'close', reason: 'mask' }
217
- mapRuntimeEventToReport(materialId, 'call', payload); // → null(call / error / state-change / view-change / ready / open 都不上报)
291
+ // 运营配在 stage.onShow / onClose 里的埋点名并进这两条,不另算点击
292
+ buildExposureReport(materialId, pickInteractionTrack(showInteraction));
293
+ // → { id, event: 'exposure', extra: { name: 'popup_show', params: {...} } }
294
+
295
+ // 动作粒度的事件一律不上报(按它们上报会重复计数)
296
+ mapRuntimeEventToReport(materialId, 'track', payload); // null
297
+ mapRuntimeEventToReport(materialId, 'navigate', payload); // null
298
+ mapRuntimeEventToReport(materialId, 'close', payload); // null
218
299
  ```
219
300
 
220
- 上报载荷 `MarketingReportParams` 是**可辨识联合**:`reason` 只属于 `close`、`extra` 只属于 `click`,
221
- 写错编译不过;不该出现的字段声明成 `?: never` 而非省略,消费方仍可直接读,不必先窄化。
301
+ ### 为什么上报只认 `interaction`
302
+
303
+ 按动作粒度上报会**放大点击量**,实测一个弹窗的完整生命周期(1 曝光 + 1 点击 + 1 关闭)能报出 6 条:
304
+
305
+ | 重复来源 | 原因 |
306
+ | ----------------------------- | ----------------------------------- |
307
+ | `stage.onShow` 里的 `track` | 那是曝光埋点,被算成了点击 |
308
+ | `sequence: [track, navigate]` | 一次点击、两个动作,各报一条 → 翻倍 |
309
+ | `stage.onClose` 里的 `track` | 那是关闭埋点,也被算成了点击 |
310
+
311
+ 所以口径改成:**一次手势 = 一条 `interaction` = 至多一条上报**。
312
+
313
+ | interaction.trigger | 上报 | 运营配的埋点名去哪 |
314
+ | ------------------- | ------------------- | -------------------------------------------- |
315
+ | `action` | 一条 `click` | `extra.name`(同时有跳转则并上 `extra.url`) |
316
+ | `show` | 不产生(返回 null) | 由宿主并进 `exposure` 的 `extra.name` |
317
+ | `close` | 不产生(返回 null) | 由宿主并进 `close` 的 `extra.name` |
318
+
319
+ `action` 里既没埋点也没跳转(比如纯 `close` / `setState`)时同样不产生上报。
320
+
321
+ 上报载荷 `MarketingReportParams` 是**可辨识联合**:`reason` 只属于 `close`、
322
+ 点击详情(带 `trigger` 的 `extra`)只属于 `click`,写错编译不过;`exposure` / `close` 的 `extra`
323
+ 是另一种形态(只有 `name` / `params`),装的就是上表里那些埋点名。
324
+ 不该出现的字段声明成 `?: never` 而非省略,消费方仍可直接读,不必先窄化。
222
325
 
223
326
  **给谁用**:接了后端上报的宿主(前台坑位组件),以及调试台的「上报预览」——
224
327
  预览要展示的就是真实会发给后端的 JSON,映射再写第二份,前台改了口径而调试台没跟上,预览就会骗人。
@@ -527,6 +527,120 @@ function resolveNodeStyle(node, layout) {
527
527
  );
528
528
  }
529
529
 
530
+ // src/user-fields.ts
531
+ var DEFAULT_USER_FIELDS = [
532
+ "shopId",
533
+ "shopName",
534
+ "collectCount",
535
+ "albbCollectCount",
536
+ "isNewUser",
537
+ "levelId",
538
+ "levelName",
539
+ "serviceExpireTime",
540
+ "remainDays",
541
+ "userTypeNew",
542
+ "serviceInfo"
543
+ ];
544
+ var USER_FIELD_LABELS = {
545
+ shopId: "\u5E97\u94FA ID",
546
+ shopName: "\u5E97\u94FA\u540D\u79F0",
547
+ collectCount: "\u5269\u4F59\u91C7\u96C6\u6B21\u6570",
548
+ albbCollectCount: "\u5269\u4F59 1688 \u91C7\u96C6\u6B21\u6570",
549
+ isNewUser: "\u662F\u5426\u65B0\u7528\u6237\uFF080 / 1\uFF0C\u53EF\u76F4\u63A5\u7528\u4E8E visibleWhen\uFF09",
550
+ levelId: "\u7248\u672C ID",
551
+ levelName: "\u7248\u672C\u540D\u79F0\uFF0C\u5982\u300C\u4E13\u4E1A\u7248\u300D",
552
+ serviceExpireTime: "\u670D\u52A1\u5230\u671F\u65F6\u95F4\uFF0C\u53EF\u76F4\u63A5\u5582\u7ED9 countdown \u7684 to",
553
+ remainDays: "\u670D\u52A1\u5269\u4F59\u5929\u6570",
554
+ userTypeNew: "\u65B0\u5546\u7528\u6237\u7C7B\u578B\uFF080 \u672A\u8BBE\u7F6E / 1 \u6709\u8D27\u6E90 / 2 \u65E0\u8D27\u6E90 / 3 \u4E24\u8005\u517C\u5907\uFF09",
555
+ serviceInfo: "\u5BA2\u670D\u4E0E\u793E\u7FA4\u4FE1\u606F\uFF08\u5BF9\u8C61\uFF0C\u6309\u9700\u53D6\u5B50\u5B57\u6BB5\uFF09"
556
+ };
557
+ var USER_FIELD_RE = /\buser\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g;
558
+ var INTERPOLATION_RE2 = /\{\{([\s\S]*?)\}\}/g;
559
+ var RAW_EXPRESSION_KEYS = ["visibleWhen"];
560
+ function editDistance(a, b) {
561
+ const prev = [];
562
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
563
+ for (let i = 1; i <= a.length; i++) {
564
+ let diagonal = prev[0];
565
+ prev[0] = i;
566
+ for (let j = 1; j <= b.length; j++) {
567
+ const temp = prev[j];
568
+ prev[j] = Math.min(
569
+ prev[j] + 1,
570
+ prev[j - 1] + 1,
571
+ diagonal + (a[i - 1] === b[j - 1] ? 0 : 1)
572
+ );
573
+ diagonal = temp;
574
+ }
575
+ }
576
+ return prev[b.length];
577
+ }
578
+ function closestField(name, allowed) {
579
+ const lower = name.toLowerCase();
580
+ let best = "";
581
+ let bestScore = Infinity;
582
+ allowed.forEach((field) => {
583
+ const score = editDistance(lower, field.toLowerCase());
584
+ if (score < bestScore) {
585
+ bestScore = score;
586
+ best = field;
587
+ }
588
+ });
589
+ return bestScore <= Math.max(3, Math.ceil(name.length / 2)) ? best : "";
590
+ }
591
+ function collectFromExpression(source, into) {
592
+ USER_FIELD_RE.lastIndex = 0;
593
+ let matched = USER_FIELD_RE.exec(source);
594
+ while (matched) {
595
+ into.push(matched[1]);
596
+ matched = USER_FIELD_RE.exec(source);
597
+ }
598
+ }
599
+ function collectFromValue(value, key, into) {
600
+ if (RAW_EXPRESSION_KEYS.indexOf(key) > -1) {
601
+ collectFromExpression(value, into);
602
+ return;
603
+ }
604
+ INTERPOLATION_RE2.lastIndex = 0;
605
+ let matched = INTERPOLATION_RE2.exec(value);
606
+ while (matched) {
607
+ collectFromExpression(matched[1], into);
608
+ matched = INTERPOLATION_RE2.exec(value);
609
+ }
610
+ }
611
+ function validateUserFields(dsl, allowed) {
612
+ const issues = [];
613
+ const seen = [];
614
+ const walk = (node, path, key) => {
615
+ if (typeof node === "string") {
616
+ const names = [];
617
+ collectFromValue(node, key, names);
618
+ names.forEach((name) => {
619
+ if (allowed.indexOf(name) > -1) return;
620
+ const guess = closestField(name, allowed);
621
+ issues.push({
622
+ path,
623
+ message: `\u7528\u6237\u4FE1\u606F\u91CC\u6CA1\u6709 "user.${name}" \u8FD9\u4E2A\u5B57\u6BB5` + (guess ? `\uFF0C\u662F\u4E0D\u662F\u60F3\u5199 "user.${guess}"\uFF1F` : "") + `\u3002\u53EF\u7528\u5B57\u6BB5\uFF1A${allowed.join(" / ")}`
624
+ });
625
+ });
626
+ return;
627
+ }
628
+ if (!node || typeof node !== "object") return;
629
+ if (seen.indexOf(node) > -1) return;
630
+ seen.push(node);
631
+ if (Array.isArray(node)) {
632
+ node.forEach((item, index) => walk(item, `${path}[${index}]`, key));
633
+ return;
634
+ }
635
+ Object.keys(node).forEach((childKey) => {
636
+ const childPath = path ? `${path}.${childKey}` : childKey;
637
+ walk(node[childKey], childPath, childKey);
638
+ });
639
+ };
640
+ walk(dsl, "", "");
641
+ return issues;
642
+ }
643
+
530
644
  // src/validate.ts
531
645
  var LENGTH_STYLE_KEYS = [
532
646
  "width",
@@ -578,7 +692,7 @@ var CLOSE_POSITIONS = [
578
692
  "bottom-center"
579
693
  ];
580
694
  var CONTAINER_TYPES = ["box", "flex"];
581
- function validate(dsl) {
695
+ function validate(dsl, options) {
582
696
  const errors = [];
583
697
  const warnings = [];
584
698
  const add = (path, message) => errors.push({ path, message });
@@ -608,6 +722,11 @@ function validate(dsl) {
608
722
  viewNames.forEach((name) => {
609
723
  validateView(views[name], isMulti ? `views.${name}` : "", ctx);
610
724
  });
725
+ if (options && options.userFields) {
726
+ validateUserFields(doc, options.userFields).forEach(
727
+ (issue) => add(issue.path, issue.message)
728
+ );
729
+ }
611
730
  return { valid: errors.length === 0, errors, warnings };
612
731
  }
613
732
  function validateMultiView(dsl, add) {
@@ -965,4 +1084,4 @@ function formatIssues(issues) {
965
1084
  return issues.map((item) => item.path ? `${item.path}: ${item.message}` : item.message).join("\n");
966
1085
  }
967
1086
 
968
- export { ACTION_TYPES, ALLOWED_STYLE_KEYS, CLOSE_POSITIONS, DSL_VERSION, NODE_TYPES, SINGLE_VIEW_NAME, TRACK_EVENT_PATTERN, check, computeParts, createTicker, evaluate, formatIssues, formatParts, interpolate, interpolateDeep, isLength, isValidEndTime, normalizeViews, parseEndTime, resolveNodeStyle, toCssStyle, toLength, validate };
1087
+ export { ACTION_TYPES, ALLOWED_STYLE_KEYS, CLOSE_POSITIONS, DEFAULT_USER_FIELDS, DSL_VERSION, NODE_TYPES, SINGLE_VIEW_NAME, TRACK_EVENT_PATTERN, USER_FIELD_LABELS, check, computeParts, createTicker, evaluate, formatIssues, formatParts, interpolate, interpolateDeep, isLength, isValidEndTime, normalizeViews, parseEndTime, resolveNodeStyle, toCssStyle, toLength, validate, validateUserFields };