open-feishu-card
v0.14.5
Published
独立、零依赖的飞书 (Lark) Universal Card 渲染器,支持 Node/SSR 与浏览器,输入飞书 RAW 卡片数据或公开 DSL(JSON Schema 2.0,内置 DSL→RAW 转换),输出 HTML 字符串或 DOM。
Maintainers
Readme
open-feishu-card
独立、零运行时依赖的飞书 (Lark) Universal Card 渲染器。
- ✅ 输入飞书 RAW 卡片数据(服务端归一化后的
json_card),输出 HTML 字符串或 DOM - ✅ 同时适用于 Node / SSR 和浏览器
- ✅ TypeScript 编写,自带类型定义
- ✅ ESM + CJS 双格式
- ✅ header 高光纹理图已 base64 内联,无外部资源依赖
渲染规则(颜色、字号、间距、header 模板色等)均逆向自网页版飞书引擎,力求视觉还原。
安装
npm i open-feishu-card
# 或 pnpm add open-feishu-card / yarn add open-feishu-card使用
Node / SSR —— 渲染成 HTML 字符串
import { renderCardToString } from 'open-feishu-card';
const html = renderCardToString(cardRawJson);
// 把 html 塞进页面/邮件/截图服务即可样式表需一并引入(任选其一):
// 打包器里直接 import
import 'open-feishu-card/styles.css';<!-- 或在 HTML 里链接构建产物 -->
<link rel="stylesheet" href="node_modules/open-feishu-card/dist/styles.css" />浏览器 —— 渲染成 DOM
import { renderCard } from 'open-feishu-card';
const el = renderCard(cardRawJson); // 返回 Node
document.getElementById('app')!.appendChild(el);浏览器端无需引任何样式:
renderCard会把布局、图标字体、emoji 三类 CSS 自动注入 页面<head>(幂等,多张卡片只注一次)。手动引样式仅 Node / SSR(renderCardToString)才需要。
API
| 导出 | 说明 |
|---|---|
| renderCardToString(card, options?) | 渲染为 HTML 字符串(Node/SSR,同步) |
| renderCard(card, options?) | 渲染为 DOM 节点(浏览器;Node 端会抛错,同步) |
| renderDslToString(dsl, options?) | 输入公开 DSL,渲染为 HTML 字符串(= renderCardToString(dslToRaw(dsl))) |
| renderDsl(dsl, options?) | 输入公开 DSL,渲染为 DOM 节点 |
| dslToRaw(dsl) | 公开 DSL(JSON Schema 2.0)→ RAW(纯函数、同步、零依赖) |
| parseMarkdown(content) | markdown 字符串 → RAW 子元素树(dslToRaw 内部用,也可单独调) |
| renderCardToStringAsync(input, options?) | 异步渲染 HTML 字符串,支持用 token 自动拉图 |
| renderCardAsync(input, options?) | 异步渲染 DOM 节点,支持用 token 自动拉图 |
| fetchCardMessage(messageId, options) | 按 message_id 拉取整条卡片消息(含 json_card/json_attachment/mentions;并把 mentions/at_users 合并进 persons) |
| mergeMentionsIntoPersons(attachment, mentions?) | 把信封 mentions / at_users 合并进 json_attachment.persons(fetchCardMessage 已内置) |
| registerComponent(tag, fn) | 注册/覆盖某个 tag 的渲染组件 |
| getComponent(tag) | 取某 tag 的组件 |
| h(tag, props, ...children) | 轻量 VNode 构造器(写自定义组件用) |
| HEADER_BG_LIGHT / HEADER_BG_DARK | header 高光纹理的 base64 data URI |
| ICON_FONT_CSS | 图标字体完整 CSS(@font-face 内联 base64 + 每图标 content 规则),用于自助注入 |
| ICON_CODEPOINTS | 图标名 -> 私有区码点 映射(如 warning_outlined: "fa0c"),共 2484 个 |
RenderOptions
interface RenderOptions {
document?: Document; // 浏览器 document(默认取全局)
warnOnUnknownTag?: boolean; // 未注册 tag 时打印警告
resolveImage?: (p) => string; // 自定义图片地址解析
imageBaseURL?: string; // 最终 src = imageBaseURL + imageID
}输入格式:RAW(json_card)
接受 { json_card: {...} }(飞书 card.raw.json 顶层)或直接传 json_card 对象。
元素统一形如 { tag, id?, property },正文在 body.property.elements。
注:开发者编写的 DSL 格式(
body.elements+ markdown 字符串)也可直接渲染, 见下方「输入格式:DSL」——内部由dslToRaw转成 RAW,无需经飞书服务端。
输入格式:DSL(公开 JSON Schema 2.0)
开发者手写、im.message.create 用来发卡片的那套公开 DSL 也能直接渲染:
import { renderDslToString } from 'open-feishu-card';
import 'open-feishu-card/styles.css';
const html = renderDslToString({
schema: '2.0',
header: { template: 'green', title: '标题' }, // 文本可直接写字符串
body: {
elements: [
{ tag: 'markdown', content: '**加粗** 与 `代码` 与 [链接](https://feishu.cn)' },
{ tag: 'hr' },
],
},
});与 RAW 的差异(dslToRaw 负责抹平,纯函数、同步、零依赖):
- markdown 是字符串(
content: "**x**"),内部解析成 RAW 的子元素树(heading/list/link/code_span/<font>/<at>/<text_tag>等); - 元素字段扁平、
snake_case(text_align/background_style/user_id…),转成渲染器认得的形态; padding/margin是 CSS 简写字符串("12px 8px"),转成四向对象;- 未知 tag / 字段原样透传(配合
warnOnUnknownTag温和跳过)。
已覆盖 markdown 常用子集与本渲染器支持的全部 tag;
table、嵌套form等罕见构造走透传, 后续按需补齐。需要「飞书能接收的合法 raw envelope」而非「能渲染的形态」时,仍应走真实发送链路。
支持的 tag
| 分类 | tag |
|---|---|
| 结构 | body、card_header、div、note |
| 布局 | column_set、column |
| 文本 | markdown、plain_text、br、hr |
| markdown 子元素 | heading、blockquote、list、code_block、code_span、emoji(:KEY: 短码,见下方「Emoji」一节) |
| 内容/交互 | img、button、action、interactive_container、checker(勾选器)、at、at_all、link、text_tag |
| 图标 | standard_icon、ud_icon(见下方「图标」一节) |
| 数据 | table(表格)、chart(图表,需 VChart 水合,见下方「图表与表格」一节) |
card_header的text_tag_list会渲染成标题右侧的彩色标签(复用text_tag,色板同 header 模板)。
未注册的 tag 会被温和跳过(传 { warnOnUnknownTag: true } 可打印警告)。
图标
standard_icon / ud_icon 复刻网页版飞书的图标字体机制:空的 <i class="ud-icon_<token>">
靠内联 woff2 字体的 :before{content} 画出 glyph。property 取 token(如 warning_outlined)、
size(small/normal/large1~large4,默认 16px)、color(命名色 red/green/blue… 或直接
#hex/rgb();缺省继承父级文字色)。*_colorful 结尾的彩色图标字体自带色,不受 color 覆盖。
字体(2484 个图标、约 160KB woff2)已 base64 内联,零外部依赖:
浏览器(
renderCard):首次渲染图标时自动注入一次<style>,无需额外引样式。Node / SSR(
renderCardToString):无 DOM 不自动注入,需引入图标字体 CSS:import 'open-feishu-card/icon-font.css'; // 打包器<link rel="stylesheet" href="node_modules/open-feishu-card/dist/icon-font.css" />或在运行时把导出的
ICON_FONT_CSS字符串自行注入页面。
更新图标字体:跑
node scripts/gen-icon-font.mjs(从飞书资源重新抽取,重写src/icon-font.ts+src/icon-font.css)。
Emoji
markdown 里的 :KEY: 短码(如 :Fire:、:OK:、:Trophy:,大小写敏感)复刻网页版飞书的表情雪碧图机制:
渲染成 <i class="larkw-emoji__img--emoji-<name>">,靠单张雪碧图 + background-position 出图(270 个短码、291 个定位)。
不认识的短码(如 :NOTAREALKEY:)原样保留字面文本,与飞书一致。
雪碧图(665KB)走飞书 CDN(不内联),但定位 CSS 需注入:
浏览器(
renderCard):首次渲染 emoji 时自动注入一次<style>,无需额外引样式。Node / SSR(
renderCardToString):无 DOM 不自动注入,需引入 emoji CSS:import 'open-feishu-card/emoji.css'; // 打包器<link rel="stylesheet" href="node_modules/open-feishu-card/dist/emoji.css" />或在运行时把导出的
EMOJI_SPRITE_CSS字符串自行注入页面。
更新表情:跑
node scripts/gen-emoji.mjs(从飞书资源重新抽取,重写src/emoji-data.ts+src/emoji.css)。
图表与表格
卡片里的 table、chart 均已支持(DSL / RAW 皆可)。
table(表格) 是纯 HTML,开箱即用、零额外依赖:columns 定义列
(name / display_name / width / data_type / horizontal_align / format),
rows 是 { 列name: 值 } 数组。data_type 支持 text / number(format.precision 定小数位)/
options(值为 [{ text, color }],渲染成彩色标签)/ lark_md·markdown(单元格内飞书 markdown,
含 <font> / **加粗** 等;DSL 写字符串,RAW 为已解析的 markdown 元素树);header_style 控表头底色/加粗/对齐,
row_height(low/middle/high)控行高,page_size 只显示前 N 行。
chart(图表) 复刻网页版飞书——真机用 VChart 画到 <canvas>。
本渲染器保持同步「输出字符串/DOM」模型,不在渲染期跑 VChart(那要拉数百 KB 图形库、且依赖真实
DOM 测量)。因此 chart 组件只输出一个挂载容器,把 VChart 规格塞进 data-feishu-chart-spec
属性,真正画图交给运行期水合(chart_spec 本身即合法 VChart spec,原样透传):
// 浏览器:先渲染卡片,再用自行引入的 VChart 水合全部图表
import { renderCard, hydrateFeishuCharts } from 'open-feishu-card';
import { VChart } from '@visactor/vchart';
const el = renderCard(card);
document.body.appendChild(el);
hydrateFeishuCharts(el, VChart); // 扫描 el 下所有图表容器并画图(幂等)// SSR:把 hydrate 脚本连同 VChart CDN 一起塞进页面,加载后自动扫描并画图
import { renderCardToString, feishuChartHydrateScript, feishuCardStyleTag } from 'open-feishu-card';
const page = `<!DOCTYPE html><html><head>${feishuCardStyleTag()}</head><body>
${renderCardToString(card)}
<script src="https://unpkg.com/@visactor/vchart/build/index.min.js"></script>
${feishuChartHydrateScript()}
</body></html>`;未水合(页面没引 VChart)时,图表位置显示「图表加载中…」占位框,其余内容照常渲染。
chart组件本身零依赖——只有真要画图才需要 VChart,且由你决定怎么引(打包 or CDN)。
| 导出 | 说明 |
|---|---|
| hydrateFeishuCharts(root?, VChart?) | 扫描 root(默认 document)下所有图表容器并画图(幂等),返回成功数。VChart 缺省取 window.VChart |
| renderFeishuChart(mount, spec, VChart) | 水合单张图:把 VChart 规格画进指定挂载节点 |
| feishuChartHydrateScript(opts?) | SSR:返回自执行 <script>,页面加载后自动画图(依赖全局 VChart) |
| feishuChartHydrateJs(vchartGlobal?) | 同上,但只返回脚本正文(不含 <script> 包裹,便于加 nonce) |
| CHART_PALETTE | 默认序列色板(VChart 默认主题分类色,spec 未显式给 color 时注入) |
一次性引入全部样式(SSR 推荐)
浏览器端(renderCard / renderDsl)布局、图标、emoji 三类样式都会自动注入 <style>(幂等),
无需引任何样式。以下仅 Node / SSR 需要。
Node / SSR(renderCardToString*)无 DOM、不自动注入,需自行把 CSS 送进页面。与其分别引
styles.css + icon-font.css + emoji.css 三个文件(库以后加资源还得跟着改),可以用一份聚合样式:
// 运行时字符串(推荐):页面级注入一次,多张卡片共享
import { renderCardToString, ALL_CSS, feishuCardStyleTag } from 'open-feishu-card';
const page = `<!DOCTYPE html><html><head>${feishuCardStyleTag()}</head>
<body>${renderCardToString(cardA)}${renderCardToString(cardB)}</body></html>`;
// feishuCardStyleTag() = <style>ALL_CSS</style>;也可直接用 ALL_CSS 字符串自行包裹import 'open-feishu-card/all.css'; // 或打包器 import 聚合文件<link rel="stylesheet" href="node_modules/open-feishu-card/dist/all.css" />ALL_CSS = 布局 + 图标字体 + emoji 雪碧三份合一。以后库再新增资源,聚合导出会自动包含,消费者代码零改动。
图片地址解析
RAW 里图片通常只有 imageID(飞书图床 key),不是直链:
renderCardToString(card, {
resolveImage: (p) => myCDN(p.imageID), // 完全自定义
// 或
imageBaseURL: 'https://my-host/img/', // 拼前缀
});若 property 自带 src/url 则直接用;都没有时渲染为占位框。
异步渲染 / 自动拉图
飞书 RAW 卡片里的 img 元素只带局部序号 imageID(如 "19"),不是图片直链;真实图床 key(img_v3_...)在消息的 json_attachment.images 里,需要再用 access token 调接口换二进制。renderCardToStringAsync / renderCardAsync 把这套流程内置了:
- 传了
accessToken→ 渲染器自动拉取图片并内联为 base64 data URI; - 不传
accessToken→ 图片渲染成占位图(其余内容照常渲染)。
输入有两种形态:
import { renderCardToStringAsync } from 'open-feishu-card';
// 形态 1:只给 message_id —— 渲染器自己把整条消息拉回来再渲染(需 token)
const html1 = await renderCardToStringAsync('om_xxx', { accessToken: 't-xxx' });
// 形态 2:已有卡片数据(接口 content 解包后的形态)—— 直接渲染
// 传 json_attachment + messageId + token 才能内联图片;否则图片走占位
const html2 = await renderCardToStringAsync(
{ json_card, json_attachment, messageId: 'om_xxx' },
{ accessToken: 't-xxx' },
);只传
message_id但缺accessToken会抛错——拉取消息本身就需要鉴权。
@ 提及与 mentions
fetchCardMessage 请求带 card_msg_content_type=raw_card_content&user_id_type=open_id,除解包 body.content 外,还会读取消息信封上的 data.items[0].mentions(不是 content 内字段):
const msg = await fetchCardMessage('om_xxx', { accessToken: 't-xxx' });
// msg.mentions: [{ id: 'ou_...', id_type: 'open_id', key: '@_user_1', name: '赵天雄' }]
// msg.json_attachment.persons 已合并 mentions + at_users,at 可直接出真名- mentions:来自消息信封;
at_users仍在json_attachment内。 - 库内会把二者写入
json_attachment.persons(ou_...与@_user_N别名键都写),现有at查表逻辑无需改动。 - 因此
renderCardToStringAsync('om_xxx', { accessToken })对含@的卡片会显示真实姓名,不必再传options.persons。
AsyncRenderOptions
在 RenderOptions 基础上扩展:
interface AsyncRenderOptions extends RenderOptions {
accessToken?: string; // 可选:有则拉图内联,无则占位
messageId?: string; // 输入是卡片对象时,用它拼图片接口 URL
fetch?: typeof fetch; // 注入自定义 fetch(默认全局 fetch;便于代理 / 单测 mock)
apiBaseURL?: string; // 默认 https://open.feishu.cn/open-apis/im/v1
}运行环境
- 异步功能依赖全局
fetch(Node 18+ / 现代浏览器原生支持)。Node 16 用户:同步渲染不受影响;异步功能请升级运行时,或通过options.fetch注入实现。 - 浏览器场景注意 CORS:浏览器直连
open.feishu.cn会被跨域拦截,且会暴露 token。正确做法是让服务端做代理转发(用apiBaseURL指向你的代理),或在服务端预渲染(renderCardToStringAsync)后把 HTML 下发给前端。
扩展自定义元素
import { registerComponent, h } from 'open-feishu-card';
registerComponent('my_widget', (el, ctx) =>
h('div', { className: 'my-widget' }, el.property?.content ?? '')
);暗色主题
给根节点加 theme-dark,或自行覆盖样式变量:
const el = renderCard(card);
el.classList.add('theme-dark');开发
pnpm install
pnpm build # tsup 产出 dist/(esm + cjs + d.ts + styles.css)
pnpm test # node --test(含异步拉图的 mock-fetch 测试,无需联网、无需凭据)
pnpm example # 渲染示例到 examples/output.html测试用注入的 mock fetch,不需要任何凭据或联网。
让 pnpm example 联网拉取真实卡片并自动内联图片——把凭据放进 .env(已被 gitignore):
cp .env.example .env
# 编辑 .env,填入自建应用的 App ID / Secret(开发者后台 -> 凭证与基础信息)
# FEISHU_APP_ID=cli_xxx
# FEISHU_APP_SECRET=xxx
pnpm example # 自动用 App ID/Secret 换取 tenant_access_token,无需手动给 token不配 .env 也能跑:用内置兜底卡片,图片渲染成占位图。凭据解析优先级:FEISHU_TOKEN(直接给的 token)> App ID + Secret 自动换取。
License
MIT
