npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@yaoxiu/marketing-dsl

v2.6.1

Published

营销弹窗 DSL 解释器核心,纯逻辑无框架依赖

Readme

@yaoxiu/marketing-dsl

营销弹窗 DSL 的解释器核心。运营用一段 JSON 描述弹窗 / Banner / 公告条的排版和交互,这个包负责把它解释成可渲染的数据。

纯逻辑,不依赖任何框架,也不碰 DOM。 换 Vue3 / React 时这个包一行都不用改。

为什么要有这个包

运营发活动时弹窗形态千奇百怪,最初的方案是让他们写 JS。这条路走不通——配置一旦变成可执行代码,就等于把 XSS 入口开在了自己家里。

所以改成 DSL:排版是数据,不是逻辑。表达式求值器是手写的词法 + 递归下降,绝不使用 eval / new Function;样式、链接协议、数据源名、可调用方法全部走白名单。

安装

pnpm add @yaoxiu/marketing-dsl

渲染还需要对应框架的壳:

pnpm add @yaoxiu/marketing-dsl-vue2

两种用法

1. 校验配置

编辑器和后端保存前都该跑一遍。这个包不依赖 DOM,Node 里可以直接用。

import { validate, formatIssues } from '@yaoxiu/marketing-dsl';

const result = validate(dsl);
if (!result.valid) {
  throw new Error(formatIssues(result.errors));
}
// warnings 不阻断保存,但该提示运营,比如样式属性不在白名单里会被忽略

可选:校验 {{ user.xxx }} 用到的字段

validate 的第二个参数是可选项,不传时行为完全不变。传了 userFields 就会检查配置里 所有插值位置引用的 user 字段是否在白名单内,不在的报 error(附最接近的候选字段名)。

import { validate, DEFAULT_USER_FIELDS } from '@yaoxiu/marketing-dsl';

const result = validate(dsl, { userFields: DEFAULT_USER_FIELDS });
// { path: 'nodes[0].content',
//   message: '用户信息里没有 "user.remainDay" 这个字段,是不是想写 "user.remainDays"?…' }

user 是宿主注入的登录用户信息对象,运营写错字段名(user.expireDays 而不是 user.remainDays)运行时只会求值为空——页面上就是一处空白、倒计时归零,投到线上才发现。 所以保存时拦一道。

DEFAULT_USER_FIELDS 是各平台通用的那套用户字段:

shopId / shopName / collectCount / albbCollectCount / isNewUser / levelId / levelName / serviceExpireTime / remainDays / userTypeNew / serviceInfo

刻意排除 sourceData(后端原始响应整包,结构随平台漂移)和 mcToken(令牌,不该出现在 运营配置里)。字段的中文说明在 USER_FIELD_LABELS,编辑器做提示可以直接用。 只想单独跑这一项检查时用 validateUserFields(dsl, fields)

检查覆盖全部字符串值里的 {{ }}(文案 contenttosrcstyle 的每个值、 动作的 params …),以及 visibleWhen 这种整串就是表达式的字段—— 正向列举「哪些字段能写插值」必漏,所以是反过来做的。

埋点事件名(track 动作的 event)有格式要求

^[a-z][a-z0-9_]{2,63}$

小写字母开头,之后只能是小写字母、数字、下划线,总长 3~64 个字符。 不合规是 error,直接阻断保存(导出为 TRACK_EVENT_PATTERN,编辑器可复用同一份正则做即时提示)。

| ✅ 正确 | ❌ 错误 | 为什么 | | -------------------- | -------------------- | -------------------------- | | banner_renew_click | 续费点击 | 不能用中文 | | popup_show | bannerRenewClick | 不能用驼峰 / 大写字母 | | countdown_end | banner-renew-click | 不能用中划线(下划线分词) | | cta_click_v2 | cta(不足 3 字符) | 太短,聚合时看不出是什么 |

建议命名:坑位_业务_动作,如 popup_vip_renew_click不做白名单——事件名由运营自由定义, 只统一格式;后端按前缀聚合,格式乱了就没法分组。这个名字最终会作为 extra.name 上报 (见 platform-adapters 的 base-service/marketing/README.md「上报字段契约」)。

2. 渲染

一般不直接用,交给框架壳。自己接的话是这样:

import { createRuntime } from '@yaoxiu/marketing-dsl';

const runtime = createRuntime(dsl, {
  user: { shopName: '麦爆了旗舰店', version: '专业版' },
  sources: { listRenewTiers: params => api.getTiers(params) },
  handlers: { openRecharge: () => showRechargeModal() },
  emit: (event, payload) => console.log(event, payload),
});

const unsubscribe = runtime.subscribe(() => {
  render(runtime.getTree()); // 每次通知后重新取树画一遍
});

// 用户信息异步到了、或者要切换编辑态,用 update 就地更新。
// 不要为此重建 runtime——重建会重新拉数据源、重放曝光埋点,
// 还会把用户已经切到的 tab 打回默认值。
runtime.update({ user: { shopName: '麦爆了旗舰店' } });

// 卸载时必须调,否则倒计时定时器不会停
runtime.destroy();
unsubscribe();

update 能改 user / handlers / editMode。换 dsl 本身要重建 runtime。

渲染树

getTree() 返回的是纯数据,所有解释工作都已经做完:插值算完了、条件判断过了、循环展开了、样式转成 CSS 了、坐标换算好了、动作绑好了。

{
  ready: true,          // 数据源没回来时是 false,什么都别画
  hasPopup: true,      // 当前视图栈里有弹窗(不是「配置里有弹窗视图」)
  rootStyle: { ... },  // 恒 relative;弹窗铺满视口由 layerStyle 的 fixed 负责
  layers: [{
    name: 'main',
    isTop: true,
    mask: true,         // 只有栈顶的弹窗层才画遮罩
    onMaskClick: fn,
    layerStyle: {...}, maskStyle: {...}, scrollStyle: {...},
    stageStyle: {...}, clipStyle: {...},
    // 默认图标是内联 SVG 画的两条交叉线('×' 字符的字形偏下,肉眼可见);
    // 运营配了 icon 走 text,配了 image 走 img
    closeButton: { key, tag, style, onClick, children },
    nodes: [{ key: 'main-0', tag: 'div', style: {...}, onClick: fn, children: [...] }],
  }],
  countdownEndTimes: [], // 有倒计时时运行时会自己起定时器
  countdownPrecision: 's',
  css: '',              // 要注入的 CSS 文本(keyframes + hover 规则),没有动画时是空串
  rootClassName: 'dsl-renderer dsl-r3', // 根容器类名,后半截是这份物料的作用域前缀
}

框架壳照着 layer 的结构套四层 div 即可,不需要自带任何 CSS,基础样式已经写进行内样式了。

动画与 hover:唯一需要一段真 CSS 的地方

@keyframes:hover 行内表达不了,所以渲染树多带两个字段。框架壳的义务只有两条: 根容器的 class 用 tree.rootClassName(不要写死 dsl-renderer),tree.css 非空时把它原样塞进 一个 <style>、随变化更新、销毁时移除。壳里不做任何 CSS 生成或判断

  • animate 落的是行内 animation 简写(节点、stagestage.maskAnimatestage.closeButton 都支持), 只有 keyframes 定义与 hover 规则进 tree.css
  • keyframes 名带作用域前缀(dsl-kf-{uid}-{name}),同一页面上多份物料不会互相覆盖
  • tree.css 末尾恒带 prefers-reduced-motion 兜底:系统开了「减少动态效果」就全部静止
  • CSS 文本是字符串拼接,所以每个值都过 sanitizeCssValue——含 { } < > ; @/* 或反斜杠的 整条声明直接丢弃(行内那条路径不需要,框架自己会转义)

框架壳必须遵守的一条契约

节点有 onClick 时,挂事件要阻止冒泡

父子都可点是常见排版——公告条整条点开弹窗、行末 × 关闭公告条——不阻止的话点 × 会连带触发外层,关掉的瞬间又弹出来。没有 onClick 的节点不要绑任何事件,让它正常冒泡到有 onClick 的祖先。

运行时事件(emit

| 事件 | 何时抛 | 宿主拿它干嘛 | | -------------- | -------------------------------------------- | --------------------------------------- | | ready | 数据源全部返回 | 知道可以画了 | | interaction | 一次手势 / 一个时机一条,见下 | 上报(曝光 / 点击 / 关闭只认它) | | track | 每执行一个 track 动作 | 接自有埋点系统 | | navigate | 每执行一个 navigate 动作 | 想自己接管跳转时用(core 默认已经跳了) | | close | 物料整体关闭(点 × / 遮罩 / 倒计时 / close) | 把组件从页面上移除,并上报一条 close | | call | 每执行一个 call 动作 | 宿主自己注册的方法已被调用 | | open | 打开另一个视图 | 调试用 | | view-change | 视图栈变化 | 调试用 | | state-change | setState 改了状态 | 调试用 | | error | 非法 URL / 未注册数据源或方法 / 未知动作 | 排查配置问题 |

interaction:动作粒度之外再来一条交互粒度

track / navigate动作粒度:一次点击里配 sequence: [track, navigate](先埋点再跳转) 是标准写法,按动作上报会把点击量翻倍;track 还能挂在 stage.onShow / onClose 上, 动作粒度看不出时机,一律算成点击就把曝光和关闭也计进了点击。

所以在交互粒度上再抛一条汇总事件,一次手势 / 一个时机只有一条:

interface DslInteractionEvent {
  trigger: 'show' | 'close' | 'action'; // 触发时机
  reason?: string; // 仅 trigger='close'
  track?: { event: string; params?: Record<string, unknown> }; // 多个时取第一个
  navigate?: { url: string; target?: string }; // 多个时取第一个
}
  • stage.onShow 触发 → { trigger: 'show', ... }
  • stage.onClose 触发 → { trigger: 'close', reason, ... }
  • 点了带 action 的节点(含关闭按钮、tab、倒计时 onEnd)→ { trigger: 'action', ... }
  • 没配动作的时机不抛(不制造空事件)

interaction新增的,既有事件语义一个都没变 —— 宿主该用 navigate 接管跳转、 用 track 接自有埋点系统的,照旧。上报侧只订 interaction,见下方「上报契约」。

安全边界

这几条是设计红线,不会因为「就差一点点」而放开:

| 边界 | 做法 | | ---------------------- | -------------------------------------------------------------------- | | 不执行任意代码 | 手写解析器,不用 eval / new Function;不支持函数调用和赋值 | | 不能沿原型链逃逸 | constructor / __proto__ / prototype 一律读不到,函数值也读不到 | | 不能通过样式作恶 | 只有白名单里的属性会输出,position: fixed 这类会被丢弃 | | 不能执行 javascript: | 链接只允许 http / https / mailto / tel 和相对路径 | | 不能打任意接口 | 数据源用注册名引用,配置里写不了 URL | | 不能调任意方法 | call 只能调宿主注册在 handlers 里的方法 |

call 的适用边界

call 是「让宿主去做一件事」,不是「记一笔账」。

| ✅ 该用 call | ❌ 不该用 call | | ---------------------------------------------------------- | --------------------------------------------- | | 唤起充值弹窗、打开客服会话、复制到剪贴板、跳转应用内特定页 | 记录用户意图、写本地存储(localStorage 等) |

判断标准只有一条:这件事后端需不需要知道

  • 需要 → 走上报closereason,或 track 动作。
  • 纯粹是宿主的本地动作、后端不关心 → 才用 call

典型反例是「不再提醒」。曾经的写法是 close 之外再配一条 call 让宿主写 localStorage,这是错的:

  1. 频控职责分裂。「这条还要不要出」由后端算,前端存一份频控依据后端看不见,两边依据不一致,规则就是假的。
  2. 换设备、清缓存就失效。这是用户的长期意图,该跟着账号走,不该跟着浏览器走。
  3. 每个宿主各写一份handlers 由宿主注册,前台一份、调试台一份、将来 Vue3 宿主再一份,行为迟早分叉。

正确写法不需要任何新增代码,close 带上 reason 即可:

{ "type": "close", "reason": "user-dismiss" }

上报出去就是 { "id": "...", "event": "close", "reason": "user-dismiss" },后端看到这个 reason 就永久压制这条物料。注意这个信息走 closereason,不是点击详情 —— 带 trigger 的 点击 extra 只属于 click

完整 DSL 语法

见调试台的「开发文档」页,或从那里导出 markdown / AI 提示词。

文档正文本身也在这个包里,走子路径引入——正文有几十 KB,业务侧只用运行时, 不该背这份体积,所以它不在主入口:

import {
  sections,
  meta,
  examples,
  toMarkdown,
  buildAiPrompt,
} from '@yaoxiu/marketing-dsl/docs';

toMarkdown(sections, meta); // 导出 .md
buildAiPrompt(); // 生成喂给 AI 的提示词

章节正文是数据DocBlock 数组)不是字符串:调试台按块渲染成页面,toMarkdown 导出成 markdown, 同一份内容两边永远一致。节点类型 / 动作 / 样式白名单 / 埋点事件名正则等清单一律从解释器常量取, 并经 reconcileRows 对账——解释器加了东西而文档没跟上,会在文档页面上直接标出来。


上报契约(/report 子路径)

物料的曝光 / 点击 / 关闭要如实上报给后端。「运行时事件 → 上报载荷」这一步的映射是纯函数, 放在这个包的 /report 子路径里,前台(move-front)与后台调试台(moveback-front)共用同一份:

import {
  mapRuntimeEventToReport,
  buildExposureReport,
  buildCloseReport,
  pickInteractionTrack,
} from '@yaoxiu/marketing-dsl/report';
import type { MarketingReportParams } from '@yaoxiu/marketing-dsl/report';

// 点击:只认 interaction,且只认 trigger='action'
mapRuntimeEventToReport(materialId, 'interaction', {
  trigger: 'action',
  track: { event: 'banner_renew_click', params: { pos: 1 } },
  navigate: { url: 'https://a.com/renew' },
});
// → { id, event: 'click',
//     extra: { trigger: 'track', name: 'banner_renew_click', params: { pos: 1 },
//              url: 'https://a.com/renew' } }

// 曝光 / 关闭由宿主主动发(解释器不知道自己被画出来了,也不该替宿主决定关不关)
buildExposureReport(materialId); // → { id, event: 'exposure' }
buildCloseReport(materialId, { reason: 'mask' }); // → { id, event: 'close', reason: 'mask' }

// 运营配在 stage.onShow / onClose 里的埋点名并进这两条,不另算点击
buildExposureReport(materialId, pickInteractionTrack(showInteraction));
// → { id, event: 'exposure', extra: { name: 'popup_show', params: {...} } }

// 动作粒度的事件一律不上报(按它们上报会重复计数)
mapRuntimeEventToReport(materialId, 'track', payload); // → null
mapRuntimeEventToReport(materialId, 'navigate', payload); // → null
mapRuntimeEventToReport(materialId, 'close', payload); // → null

为什么上报只认 interaction

按动作粒度上报会放大点击量,实测一个弹窗的完整生命周期(1 曝光 + 1 点击 + 1 关闭)能报出 6 条:

| 重复来源 | 原因 | | ----------------------------- | ----------------------------------- | | stage.onShow 里的 track | 那是曝光埋点,被算成了点击 | | sequence: [track, navigate] | 一次点击、两个动作,各报一条 → 翻倍 | | stage.onClose 里的 track | 那是关闭埋点,也被算成了点击 |

所以口径改成:一次手势 = 一条 interaction = 至多一条上报

| interaction.trigger | 上报 | 运营配的埋点名去哪 | | ------------------- | ------------------- | -------------------------------------------- | | action | 一条 click | extra.name(同时有跳转则并上 extra.url) | | show | 不产生(返回 null) | 由宿主并进 exposureextra.name | | close | 不产生(返回 null) | 由宿主并进 closeextra.name |

action 里既没埋点也没跳转(比如纯 close / setState)时同样不产生上报。

上报载荷 MarketingReportParams可辨识联合reason 只属于 close、 点击详情(带 triggerextra)只属于 click,写错编译不过;exposure / closeextra 是另一种形态(只有 name / params),装的就是上表里那些埋点名。 不该出现的字段声明成 ?: never 而非省略,消费方仍可直接读,不必先窄化。

给谁用:接了后端上报的宿主(前台坑位组件),以及调试台的「上报预览」—— 预览要展示的就是真实会发给后端的 JSON,映射再写第二份,前台改了口径而调试台没跟上,预览就会骗人。

为什么不在主入口:主入口的身份是「DSL 解释器」,上报是可选的附加约定, 只有接了上报的宿主才需要。主入口不 re-export,边界才清楚,只用解释器的消费方也不背这份代码。 包根同样有一个物理 report/ 转发垫片,理由与 docs/ 一致(webpack 4 不认 exports)。

契约字段的完整说明(后端视角)见 move-front 仓库的 docs/marketing/README.md