duckfn-docs-kit 0.1.0 → 0.2.1

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/AGENTS.md CHANGED
@@ -1,689 +1,224 @@
1
- # duckfn-docs-kit — AGENTS.md
2
-
3
- 本包是 duckfn 系(DuckDB 扩展)文档站的共享积木库:TOC 折叠控件、首页
4
- web components、品牌 CSS tokens、版本占位符 remark 插件。它被 `docs/`
5
- (本仓库文档站)以 npm workspace 消费,也供下游扩展项目复用 ——
6
- **发布通用逻辑,而不是到处拷代码**。
7
-
8
- ## 包结构 / 导出
9
-
10
- ### 目录按业务语义组织,不按技术类型(默认逻辑,可递归)
11
-
12
- **一个能力一个文件夹,它的 TS、CSS(以及将来的测试、资源)都放进去。** 目录
13
- 首先回答「这东西属于哪个业务」,而不是「这是 TS 还是 CSS」。技术类型只是业务
14
- 模块内部的次级文件,不单独开 `css/`、`utils/` 这类技术分类目录。
15
-
16
- 这条不是只管 `src/` 一层的局部偏好,而是**跨尺度的默认逻辑,可以递归**:类、
17
- 模块、多级目录、包、项目、多层项目,每一层都用同一个判断 —— 先问「属于哪个
18
- 业务语义」,再把它的实现材料(代码、样式、测试、资源)收拢在同一处。
19
-
20
- - **类**:一个类的字段、方法、事件处理聚在一个 class 里,不拆成摊在多文件的
21
- partial / mixin。
22
- - **模块 / 目录**:`home/` 一个目录装齐首页三件套的 TS、CSS、样式注入。
23
- - **包**:本包的 `src/` 按能力分目录(`home/`、`toc-toggle/`、`theme/`),
24
- 不按 `css/`、`elements/`、`utils/` 分。
25
- - **项目 / 多层项目**:仓库根同理 —— Rust 运行时(根 `src/` + `test/`)与
26
- 文档站(`docs/`)各是一个业务,各自内部再按同一逻辑递归。
27
-
28
- **万不得已才允许按技术栈分**:两种实现材料在工具链上放不到一块儿时,才拆成
29
- 并列的顶层目录 —— 例如 Rust 代码(Cargo workspace)和文档站(npm +
30
- Docusaurus):构建系统、依赖管理、产物形态完全不同,强行合并只会互相污染。
31
- 这是生态系统的物理限制,不是「按技术分类更好」;能收拢的必须收拢。
32
-
33
- ```
34
- src/
35
- ├── home/ # 首页三件套:DfkHero.ts / DfkFeatures.ts / DfkNextSteps.ts
36
- │ # + home.css(三组件共享的样式)+ styles.ts(?inline 注入)
37
- ├── toc-toggle/ # TocToggle.ts + TocToggle.css(light-DOM 例外,见第 9 条)
38
- │ # + client.ts / plugin.ts(胶水:插件把 client 注入每个页面)
39
- ├── sql/ # 可运行 SQL:DfkSql.ts + DfkSql.css/styles.ts(shadow:编辑器
40
- │ # + 悬浮图标按钮)+ sql.css(light-DOM 结果区,见第 9 条)
41
- │ # + runtime.ts(DuckDB-Wasm 单例)+ editor.ts / renderers.ts
42
- │ # + PreviewTabs.ts(预览页签,末尾恒定 Table)+ remark.ts
43
- │ # + extensions.ts(Node:扩展预加载插件)+ runtimeConfig.ts
44
- │ # (两侧共享的注入配置契约,见「扩展预加载」)
45
- │ # + client.ts(dfk-* 元素注册,插件注入到每个页面)
46
- ├── theme/ # tokens.css —— 全局设计基础设施,无业务归属,单独放
47
- ├── kit.css # 全局 CSS 聚合入口(@import theme + toc-toggle + sql)
48
- ├── dom.ts # el() / HTMLElementBase 纯工具
49
- ├── index.ts # 浏览器桶文件
50
- ├── register.ts # customElements 注册
51
- ├── remark.ts # Node 构建期 remark 插件
52
- ├── types.ts # 组件值类型
53
- └── vite-env.d.ts # `*.css?inline` 的模块声明
54
- ```
55
-
56
- - `home.css` 留在 `home/` 而非拆进各组件:`:host`/box-sizing 重置、`.dfk-section`
57
- 系列、`dfk-rise` keyframes、断点是三组件**真正共享**的规则,拆三份会引入重复
58
- 或额外的 base 文件。共享样式属于「home 这个业务」,就放 `home/` 里。
59
- - `tokens.css` 是唯一没有业务归属的全局基础设施,`theme/` 是它的例外单独目录。
60
-
61
- ### 构建与导出
62
-
63
- - 构建:Vite lib 模式产出 ESM(`dist/`),`tsc -p tsconfig.build.json` 产出
64
- `.d.ts`。`npm run build` 一步完成。全局 CSS(`theme/tokens.css`、
65
- `toc-toggle/TocToggle.css`)不经构建、作为源文件直接导出;组件自己的
66
- `home/home.css` 由 `home/styles.ts` 以 `?inline` 内联进 bundle(见第 9 条)。
67
- - **package.json 与业务解耦(硬性要求)**:`exports` 只有三条**永远不改**的规则
68
- —— `.`(主入口)、`./src/*`(源文件直出,CSS 走这里)、`./*`(通配,
69
- `dist/` 下任何产物自动成为可导入子路径)。新增 / 移动 / 重命名模块**一律不碰
70
- package.json**,改 `vite.config.ts` 的 `entry` 一行即可。评审时看到 package.json
71
- 里出现逐个文件、逐个入口的映射,就是违反本条。
72
- - 运行时入口文件名与其主导出的类名一致(大驼峰),ts 源文件同理;入口的
73
- **导入子路径 = 它在 `src/` 下的相对路径**(通配映射到 `dist/` 同路径产物):
74
- - `duckfn-docs-kit`(浏览器:`index.ts` 桶文件,CE + 类型)
75
- - `duckfn-docs-kit/toc-toggle/TocToggle`(浏览器:TOC 折叠类)
76
- - `duckfn-docs-kit/remark`(**Node 构建期**:版本占位符 remark 插件)
77
- - `duckfn-docs-kit/sql/remark`(**Node 构建期**:可运行 SQL remark 插件)
78
- - `duckfn-docs-kit/sql/extensions`(**Node 构建期**:扩展预加载 Docusaurus 插件)
79
- - `duckfn-docs-kit/toc-toggle/plugin`(**Node 构建期**:TOC 胶水插件)
80
- - `duckfn-docs-kit/sql/client`、`duckfn-docs-kit/toc-toggle/client`(浏览器引导,
81
- 由上面两个插件注入,站点不要手写引用)
82
- - CSS 子路径:消费方直接按源文件路径引 —— `@import
83
- 'duckfn-docs-kit/src/kit.css'`(聚合入口),或单独引
84
- `duckfn-docs-kit/src/theme/tokens.css` 等。不再维护 `css/kit.css` 这类
85
- 与源路径脱钩的别名。`home.css` **不作为**全局 CSS 导出 —— 它由
86
- `home/styles.ts` 内联进 JS bundle,注入各组件的 shadow root。
87
-
88
- ### 可运行 SQL:渲染契约与 DuckDB-Wasm 事实
89
-
90
- `sql/` 是一条单向链:`remark.ts`(构建期)→ `DfkSql.ts`(元素)→ `runtime.ts`
91
- (DuckDB 单例)→ `renderers.ts` + `PreviewTabs.ts`(结果渲染)。改动按这个顺序读。
92
-
93
- **渲染器(`renderers.ts`)**
94
-
95
- - `rendererFor(config, result)` 是唯一入口:`result.error` 一律走 `errorRenderer`;
96
- 否则按 `config.show` 查表,`show` 缺省时「单列单行 → `text`,其余 → `table`」。
97
- - 新增一种 `show` = `registry` 加一项 + `RunnableSqlConfig.show` 联合类型加一个字面量。
98
- 渲染器签名统一是 `(context, result) => Promise<void | (() => void)>` —— **一律
99
- 异步**,返回的 disposer 由 `DfkSql` 在重跑 / 断开时调用,调用方只处理一种形态。
100
- - `html` 与 `iframe` 是**同一个渲染器**(都写 `srcdoc`);`svg` 是另一个(内联进页面)。
101
- - iframe 默认 `sandbox="allow-scripts"` 且**不含 `allow-same-origin`**:报告里的
102
- JavaScript 照跑,但 frame 持有 opaque origin,与文档站主体隔离。**父文档因此读不到
103
- `iframe.contentDocument`(为 `null`)——这是设计,不是 bug**,验证时别拿它当失败。
104
- 放宽只能显式写 `option.sandbox`(`sandbox` 在 DOM 上是 `DOMTokenList`,`el()` 里
105
- 必须走 `attrs`)。
106
- - 内联 SVG 走 `DOMParser` + `parsererror` / `namespaceURI` 检查,插入前**剥离**所有
107
- 可执行或可导航内容(`script`、`foreignObject`、`on*`、`javascript:` 的
108
- `href`/`xlink:href`);解析失败退化为 `pre` 文本,绝不把裸标记塞进 DOM。
109
- - 预览尺寸用 CSS 自定义属性表达(`--dfk-sql-preview-width` / `-height`、
110
- `--dfk-sql-table-height`),靠选择器特异性覆盖,不写 `!important`。
111
- - **结果面底色一律用 `--ifm-background-surface-color`,不要用
112
- `--ifm-background-color`**:后者可以被站点声明成 `transparent`(本仓库文档站
113
- 正是如此,页面底色另有来源),全屏 overlay 会因此变成透明、内容直接透出。
114
-
115
- **界面契约(`DfkSql.ts` + `PreviewTabs.ts`)**
116
-
117
- - **没有静态预览、没有工具栏、没有「编辑」模式**:CodeMirror 编辑器就是代码视图,
118
- 在 `connectedCallback()` 里挂载(`#mounting` 守卫 + await 后 `isConnected` 守卫,
119
- 断开即 `destroy()`)。`remark.ts` 保留下来的 `code` 子节点只是预渲染文本,
120
- 元素没有默认 slot、`sql.css` 里 `dfk-sql > :not([slot]) { display: none }` 把它压掉。
121
- - **加载占位(三处,尺寸要一起改)**:编辑器和表格各是一个懒 `import()`,慢网下这两段窗口
122
- 肉眼可见,所以各有一个骨架。编辑器是**每条 SQL 一行**的条:`#setEditorPending()` 按
123
- `#currentSql` 的行数增删条数,每条占一个 `1.6em` 的盒子(= `.cm-scroller` 的 `line-height`),
124
- 半行距靠 flex 居中、**不能用 margin** —— 相邻 margin 会折叠,条与条会比真实代码行更密
125
- (实测 3 行时矮 13px)。所以换成编辑器时块的高度不变;`import()` 自己失败时也要撤掉(状态行
126
- 才是那条消息)。行数按**源码行**算,这是刻意的近似:编辑器里折行的长行会让真块更高,要精确
127
- 就得先把文字排出来(还得猜站点字体与列宽),对占位来说不值当。表格那两行由 `mountTable` 在
128
- 解析前塞进 `.dfk-sql-table`,画布落地(或错误视图接手)即移除,行高由 `ROW_HEIGHT` 经自定义
129
- 属性传下去。第三处在 **upgrade 之前**:`dfk-sql:not(:defined)`(`sql.css`)——元素还没定义时
130
- 既没有 shadow 树、预渲染的代码节点又被上面那条隐藏,不画点什么就是一个会突然弹出来把页面顶
131
- 下去的空洞。这一份是 `sql/remark.ts` 在构建期就写进 HTML 的真实子节点(`skeleton()`),所以
132
- 它也按行数走、与 shadow 里那份逐像素一致;升级后它不在 slot 里,自然不渲染,不需要 JS 删。
133
- 两侧各有一份 bar 规则与 `@keyframes`(跨 shadow 边界拿不到对方的),改尺寸/动画要一起改。
134
- - **不要覆盖 `.cm-content` 的垂直 padding,也不要给 `.cm-gutters` 加**:CodeMirror 的
135
- `ViewState.measure()` 用 `parseInt` 读 `.cm-content` 的 computed `padding-top` 算出
136
- `paddingTop`,再把同一数值作为第一个 gutter 元素的 `marginTop` 施加下去(经 gutter 的
137
- `above` 偏移)。因此覆盖值不是整像素就会留下小数偏移(`0.4rem` = 6.4px 被读成 6px,
138
- 行号偏高 0.4px),而两侧都加等于把同一段内缩算两遍、行号整体低于代码行约 6px。基类主题
139
- 自带的 `padding: 4px 0` 既是整数又够紧凑,**保持原样**。
140
- - **每个结果都有 tab 栏**,包括只有一个 `Table` 页签的普通表格结果 —— 因为 tab 栏是
141
- 全屏按钮唯一的落脚点。`text` 结果是 `[Text, Table]`;`table` 结果是 `[]` + 末尾 Table。
142
- - **全屏按钮归 `DfkSql` 所有**(状态在它手里),节点经 `RenderContext.fullscreenButton`
143
- 交给 `PreviewTabs`,由后者摆到 tab 栏右端、`role="tablist"` 之外,所以不随页签滚动。
144
- 全屏 overlay 不再需要 `padding-top` 给悬浮工具栏让位,退出按钮就在原来的位置。
145
- - 代码块那五个按钮(执行 / 格式化 / 重置 / 折行 / 复制)是紧凑的图标按钮,悬浮在代码区右上角
146
- (`.dfk-sql-code:hover / :focus-within` 时才 `opacity: 1` + `pointer-events: auto`,
147
- 隐藏时不可点);提示用 `data-tip` + `::after`。这套图标按钮与 tooltip 规则**在
148
- `DfkSql.css`(shadow)与 `sql.css`(light)各写一份** —— 前五个按钮在 shadow 树里,
149
- 全屏按钮在 light DOM 的结果区,一条规则够不着两处;两处都留了交叉引用注释。
150
- - 折行默认**开启**,用 `Compartment` + `wrap.reconfigure(lineWrapping)` 切换,不重建
151
- 编辑器(`@codemirror/state` 因此是动态 import 列表的一员,也在 vite external 里)。
152
- 复制成功后按钮变 `lucide:check` + `Copied` 约 1.6s 再复位,定时器在
153
- `disconnectedCallback()` 里清掉。
154
- - 格式化走 `sql-formatter`(`duckdb` dialect,它认 DuckDB 的 `EXCLUDE` / `PIVOT`),与
155
- CodeMirror 同一套懒加载动态 import,同样列在 vite 的 external 里。它只改空白、保留
156
- 作者的关键字大小写(默认 `keywordCase: "preserve"`),所以格式化**不清结果**;写回
157
- 走 `setValue()`,属于普通编辑,CodeMirror 的撤销历史覆盖得到。
158
-
159
- **表格(VTable)**
160
-
161
- - 行高紧凑靠**构造函数选参** `defaultRowHeight` / `defaultHeaderRowHeight`(不是主题
162
- 对象,写在 `theme` 里无效),容器高度公式随之用同一个常量。
163
- - **列宽默认 `widthMode: 'adaptive'`**(官方「自适应容器宽度」):先按内容量出每列宽度
164
- (表头已含排序图标宽度),再按比例缩放到刚好铺满容器 —— 初始视图就填满、不靠默认列宽,
165
- 内容多的列自然多分(比「各列平分」更合理),超长未起别名的表头有 `limitMaxAutoWidth`
166
- (默认 450)兜底。代价:总内容宽超过容器时是**缩放**而不是横向滚动条;容器尺寸变化
167
- (含全屏)会重算;手动拖过的列被排除在再分配之外,其余列围着它重新铺满。
168
- - 列宽模式在右键菜单里可切(`WIDTH_MODES`:铺满 / 按内容列宽 / 内容优先)。切换时
169
- **`table.widthMode` 与 `table.autoFillWidth` 的 setter 只存值、不重排**,重排由
170
- `updateColumns(cols, {clearColWidthCache:true, clearRowHeightCache:false})` 触发
171
- (`createSceneGraph` → `computeColsWidth` 按新模式重测);特意**不用 `updateOption`**,
172
- 因为它会把 sortState 一并清掉。列宽模式也属于「重置视图」的回退范围。
173
- - 右键子菜单:父项 `children` 即子菜单(html 模式原生支持,箭头用 `.vtable__menu-element__arrow`)。
174
- 子菜单的当前项用文本前缀 `✓ ` 标记 —— vendor 的 `--select` 高亮只认
175
- `menu.dropDownMenuHighlight`,且要按当前单元格解析,不适合表达全局状态。
176
- - **结果区/表格/面板都要 `overscroll-behavior: contain`**:它不是继承属性,必须打到
177
- 每个真正滚动的盒子上(含 `.dfk-sql-table *`,VTable 的内部滚动容器藏在里面)。
178
- 否则滚轮滑到表格底部会继续链式滚动整页 —— 表现是「页面刷一下飞上去、表格消失」。
179
- - VTable 的尺寸变化交给 `ResizeObserver`,不向外传 resize 管道;但回调里**必须把
180
- `table.resize()` 延到 `requestAnimationFrame`**(并在 disposer 里
181
- `cancelAnimationFrame`)—— `resize()` 本身会改变被观察的盒子,同步调用会被浏览器
182
- 判为 `ResizeObserver loop completed with undelivered notifications`(dev server 会
183
- 把它弹成整屏错误浮层)。
184
- - `PreviewTabs` 的 `button` / `panel` 全在构造函数里一次建好,切换只改 `classList` /
185
- `aria-selected` / `tabIndex` / `hidden`;`Table` 面板懒挂载(首次切入才 `mountTable`,
186
- 因为 VTable 构造时要量容器,而 `hidden` 的盒子量出来是 0),若挂载还在飞行中就被
187
- `dispose()`,落地后立刻释放。
188
- - 交互能力(排序、复制、行高列宽可调、表头拖拽换位、hover 十字高亮、右键菜单折行/
189
- 冻结/重置)集中在 `renderers.ts` 的 `class ResultTable`,全部走 VTable 自己的
190
- option/event,`mountTable` 只是「建盒子 + 动态 import + `new ResultTable(...)`」的薄工厂。
191
- - 表头拖拽换位:`dragHeaderMode: 'column'`(默认 `'none'`,=只开列表头)。VTable 要求
192
- **先选中表头单元格**才能拖动(`_canDragHeaderPosition` 里的 `isSelected` 判断);冻结列
193
- 相关行为 `frozenColDragHeaderMode` **保持默认**(即 `fixedFrozenCount`:冻结**数量**不变,
194
- 冻的是哪几列随新顺序变),这样 `#frozen` 缓存不会失真。换位会重排布局与
195
- `options.columns`,所以**任何重建列数组的地方都必须按显示顺序重建**:`#toggleWrap` 用
196
- `#displayOrder()`(逐列读 `getHeaderField(col, 0)`)而不是查询结果的列序;
197
- `updateColumns` 会原样采用传入的数组,照查询序传就会把用户的换位撤销。
198
- - **样式一律用官方主题**:`#theme()` 直接返回 `themes.DEFAULT` / `themes.DARK`(按
199
- `data-theme` 二选一),斑马底色、hover/选中配色、冻结列阴影、排序图标色都随主题而来,
200
- 不要再按属性手写配色。两个坑:
201
- - `themes.of(partial)` 不以 DEFAULT 为父主题(`new TableTheme(p, p)`):手写的部分主题
202
- 不会与内置默认逐层合并,没写的属性会悄悄退回 `tools/global.js` 的硬编码常量
203
- (fontSize 16、padding [10,16,10,16]、黑边框、冻结列无阴影、排序图标近黑)。要叠加
204
- 小改动就用官方的 `themes.DEFAULT.extends({...})`(`TableTheme.extends`)。
205
- - `selectionStyle.selectionFillMode` 默认 `'overlay'`,选中色是**盖在文字之上**画的 ——
206
- 给成不透明色(如 `--ifm-color-emphasis-200`)会让选中格文字整块消失,表现为「一选中
207
- 就变灰、内容不见」。官方主题给的是 `rgba(0,0,255,0.1)` 这类半透明色,用官方主题即可。
208
- - 主题跟随 `data-theme`:canvas 用具体色绘制,CSS 变量改了不会进 canvas,故 `ResultTable`
209
- 挂一个 `MutationObserver`(监听 root 的 `data-theme`/`class`)→ 变了才
210
- `table.updateTheme(...)`(`updateTheme` 会整表重绘,不要无条件调)。
211
- - 排序只在**表头 sort 图标**上触发(循环 asc→desc→normal)。自定义比较器经
212
- `columns[].sort` 传入,VTable 会带着当前 `order` 调用它并**原样采用返回值**(不再像
213
- 内置比较器那样自行按 order 翻转),所以方向要在比较器里处理;空**记录**由引擎兜底排后,
214
- 但空**字段值**(NULL)得比较器自己管(本仓库选择恒排最后、desc 不翻转)。
215
- - 复制:`keyboardOptions` **没有默认值**,Ctrl+C / Ctrl+A 必须显式写 `copySelected` /
216
- `selectAllOnCtrlA` 才生效;右键菜单的「复制单元格 / 复制整表」不走选区,是自己遍历
217
- `getCellRawValue` + `stringify` 拼 TSV 再 `navigator.clipboard.writeText`。
218
- - 折行:右键「此列折行」把 field 记进 `#wrapped`,随即 `defaultRowHeight = 'auto'` +
219
- `updateColumns(cols, {clearColWidthCache:false, clearRowHeightCache:true})` —— 只清行高
220
- 缓存让行重新长高,保留用户拖过的列宽与行高(`updateColumns` 不动 `sortState`)。
221
- 「重置视图」改用 `updateOption(..., {clearColWidthCache:true, clearRowHeightCache:true})`,
222
- 它连带清排序状态与拖拽尺寸。
223
- - 右键菜单点击监听的是 **`dropdown_menu_click`,不是 `context_menu_click`**:1.26.8 里
224
- `context_menu_click` 只有常量、从不触发,html 菜单项的 click 处理器发的是
225
- `dropdown_menu_click`(`menuKey = menuItem.menuKey || menuItem.text`,故每项都显式给
226
- `MENU.*` key 以与本地化文案解耦)。`getCellInfo(col,row).field` 对表体单元格也返回所属
227
- 列 field,故菜单项对表头/表体都能定位到列。
228
- - `menu`/`tooltip` 的 `parentElement` 默认是 `table.getElement()`(在 `.dfk-sql-table` 内),
229
- 故不与全屏 overlay 抢 z-index。它们的 `renderMode` 默认 `html`,vendor 自己会注入一份
230
- **写死浅色(#fff/#000、Roboto)**的文档级样式表,所以暗色模式下菜单/提示仍是浅色 ——
231
- 这是 vendor 现状,**不要用 `sql.css` 去改它的配色**;真要隔离就改走 Shadow DOM,但要
232
- 注意 VTable 的样式表注入在 `document.head`,跨不过 shadow 边界(需把那段 CSS 复制进
233
- shadow root 才能生效)。
234
-
235
- **扩展加载(`runtime.ts`)**
236
-
237
- - **wasm 上 `INSTALL` 是空操作**(没有可安装的持久存储),只有 `LOAD` 真的 fetch
238
- `.duckdb_extension.wasm`、验签、加载;`INSTALL … FROM` 只记录「这个扩展以后从哪
239
- 拉」。所以按名加载 =(可选 `INSTALL <name> FROM <community|'url'>`)+ `LOAD <name>`;
240
- `{url}` 条目 = 直接 `LOAD '<绝对URL>'`(worker 基于 blob URL,解析不了相对路径,
241
- 站内路径由主线程先拼成绝对 URL)。**不要用 `SET custom_extension_repository`**:
242
- 那是全局状态,会污染之后所有扩展的加载。
243
- - 扩展名 / 仓库 / URL 是**白名单校验**而非转义——它们直接进 SQL 文本;仓库关键字
244
- (`community` / `core`)裸拼,URL 加引号。
245
- - 按 `${repository}\0${name}`(或 `url\0<url>`)记忆化(成功与 in-flight 都记),
246
- 失败时从表里删掉以便重试;**已加载集合全页共享**,与「每个块状态独立」不冲突。
247
- - **页面进入即预热**:`DfkSql.connectedCallback()` 顺手 `init()`(失败静默——
248
- `init()` 可重试,最终由点 Run 的块把错误显示出来),所以第一个点 Run 的人不用等
249
- DuckDB 下载与预加载;没有可运行块的页面(如首页)不会触发。
250
- - `allowUnsignedExtensions` **只由第一个 `init()` 决定**:配置在 `open()` 时一次性交给
251
- worker,之后改不了。站点级值(注入配置)与块级值在创建实例前合并,谁先到谁定调。
252
- - 文件名的契约:文件名**第一个 `.` 之前必须是扩展名**(wasm 用它拼 `<name>_init_c_api`
253
- 入口符号),所以 release 资产 `duckfn-wasm_eh.duckdb_extension.wasm` 落盘时
254
- 必须改名为 `duckfn.duckdb_extension.wasm`;`runtimeConfig` 两侧都做校验兜底。
255
-
256
- **扩展预加载(`sql/extensions.ts` + `runtimeConfig.ts`)**
257
-
258
- - 站点在 `docusaurus.config.ts` 用 `dfkExtensions({preload, allowUnsignedExtensions})` 配
259
- 一条**有序**列表;插件规范化后注入每个页面的
260
- `<script id="dfk-sql-runtime" type="application/json">`,`runtime.ts` 首次 `init()`
261
- 读一次,按序加载完才置 `ready`。同一次 `getClientModules()` 还把 `sql/client`
262
- (注册 `dfk-*` 元素)注入每个页面——docs 页面从不 import 本包的 React 树,元素
263
- 注册必须从客户端模块来,所以站点不再需要自备 clientModules 文件。TOC 折叠的胶水
264
- 同理,由 `dfkTocToggle()`(`toc-toggle/plugin`)注入 `toc-toggle/client`;两个插件
265
- 都从**站点**(`createRequire(siteDir/package.json)`)解析自己 `dist/` 下的 client
266
- 入口,所以站点打包 config 也不会断。
267
- - 三种来源:裸扩展名(官方仓库)、`{name, repository}`(`community` / `core` / URL)、
268
- `{url}`(同源静态文件或绝对 URL)。
269
- - `{url}` 带 `release` 时,插件在 dev/build 启动时从 GitHub **最新** release 拉取该资产
270
- 到 `static/<url>`:本地缓存(默认 `<siteDir>/.cache/duckfn-docs-kit`)+ sha256
271
- sidecar,与 release 的 `digest` 相同就跳过下载;网络失败时有缓存则降级为警告
272
- (CI 每次全新环境、无缓存,会直接失败),资产名对不上时报错并列出可用名。
273
- - 注入路径用 `context.siteConfig.baseUrl` 拼(多语言构建时它是本地化值;Docusaurus 会把
274
- `static/` 拷进每个 locale 的 outDir,天然自洽);站内相对路径由 `runtime.ts` 在
275
- 浏览器里解析成绝对 URL。
276
- - `runtimeConfig.ts` 是两侧唯一共享契约:无任何 import 的纯类型 + 校验函数,Node 插件与
277
- 浏览器 bundle 都不会把对方拖进来;Docusaurus 插件 API 用**结构化类型**,本包不依赖
278
- `@docusaurus/types`。
279
- - **版本耦合(改动前先读回)**:wasm 扩展只能由「与 duckdb-wasm 内置 DuckDB 版本 ABI
280
- 兼容」的构建提供,所以 kit 的 `package.json` 把 `@duckdb/duckdb-wasm` 固定成**精确
281
- 版本**(当前 `1.33.1-dev64.0`,内置 v1.5.5,与 CI 的 `TARGET_DUCKDB_VERSION` 一致)。
282
- 实测:`1.32.0`(内置 v1.4.3)拒绝 v1.5.5 构建的扩展(C API slot 数 459 vs 546,
283
- 报 `C extension API layout mismatch`);`1.33.1-dev57.0`(内置 v1.5.4)反而能加载
284
- ——wasm 补丁只校验 C API slot 数(1.5.4/1.5.5 的 unstable 区未变),原生则按版本
285
- 戳严格校验(1.5.4 的原生 duckdb 会拒绝 v1.5.5 构建的扩展)。升级 duckdb-wasm 或
286
- 改 CI 的 `duckdb_version` 时必须成对验证(跑一遍可运行 SQL 页的两个示例块即可)。
287
-
288
- ## 代码风格(硬性要求)
289
-
290
- 这些规则是本包存在的意义所在,评审时逐条对照。
291
-
292
- ### 0. 设计目标:保留模式 UI,不是 mini React / mini Lit
293
-
294
- **明确禁止** React / Vue / Lit 风格的「状态 → render → 重建 DOM」模型。
295
- 本包的目标是:
296
-
297
- > 用 TypeScript 对浏览器原生 DOM API 做面向对象封装。
298
-
299
- 组件应该表现得像一个**持有内部 DOM 对象的普通类**,而不是一个「根据 state
300
- 不断重新计算 UI」的函数。冲突时按下面的优先级裁决(上面的赢):
301
-
302
- 1. 保留 DOM 节点 —— 节点长期存在,不随状态重建
303
- 2. 类字段持有 DOM 引用
304
- 3. 直接修改 DOM(property / attribute / text / class / 事件监听)
305
- 4. 局部更新
306
- 5. 必要时才替换**局部集合**
307
- 6. 禁止整组件 re-render
308
- 7. 禁止用响应式 state 驱动 render
309
- 8. 禁止引入 Lit / React / Vue 等 UI runtime 或其设计模式
310
-
311
- ### 1. 禁止拼 HTML 字符串
312
-
313
- 不许 `innerHTML = \`...\``、不许 `insertAdjacentHTML`、不许任何「模板字符串
314
- 生成标记再塞进 DOM」的写法。
315
-
316
- ```ts
317
- // 禁止
318
- this.innerHTML = `<button class="x">${label}</button>`;
319
- ```
320
-
321
- ### 2. DOM 用对象操作,字段持有引用
322
-
323
- `document.createElement` 创建、**类的字段持有引用**、直接对字段调方法。
324
-
325
- ```ts
326
- readonly #run: HTMLButtonElement;
327
- readonly #output: HTMLDivElement;
328
-
329
- constructor() {
330
- super();
331
- this.#run = document.createElement('button');
332
- this.#run.textContent = 'Run';
333
- this.#run.addEventListener('click', () => this.#onRun());
334
- this.#output = document.createElement('div');
335
- }
336
- ```
337
-
338
- **`querySelector` 不得当作组件内部的状态管理方式**。需要反复访问的节点一律
339
- 存成字段;`querySelector` 只允许出现在「从外部挂载点找目标」(如
340
- `TocToggle.ts` 里找 `.theme-doc-toc-desktop`)这类不属于组件自身结构的地方。
341
-
342
- 纯结构节点用 `el()`(`src/dom.ts`)建,不必展开成 `createElement` + 逐行赋值。它的
343
- options 按标签收窄,直接写标签自己的属性;`class` / `text` 是 `className` /
344
- `textContent` 的简写,`aria-*` / `data-*` 以及没有同名属性的自定义元素属性走 `attrs`
345
- 兜底。属性名拼错、值类型不对、把 `style` / `dataset` / 方法名塞进去,都是编译错误:
346
-
347
- ```ts
348
- readonly #logo = el('img', {class: 'dfk-logo', alt: '', width: 480, height: 480});
349
- ```
350
-
351
- 第三个参数是可选的 `init` 回调,用来**就地描述不会再被单独引用的结构**,省掉一个
352
- 字段;需要反复访问的节点仍然存成字段:
353
-
354
- ```ts
355
- this.root.append(
356
- el('span', {class: 'dfk-next-card-body'}, (body) =>
357
- body.append(this.#title, this.#details),
358
- ),
359
- this.#arrow,
360
- );
361
- ```
362
-
363
- `init` 里不要读**声明顺序在其后**的 `#字段`(字段初始化器按声明顺序求值,会踩 TDZ):
364
- 在构造函数里传 `init` 最稳妥,构造函数执行时所有字段都已初始化。
365
-
366
- ### 3. 更新是局部、直接、明确的;不得有通用刷新机制
367
-
368
- 状态变化 = 改**相关的那几个**节点,用命名的领域 setter 表达,而不是把整个 UI
369
- 当成 `data` 的函数重新算一遍。
370
-
371
- ```ts
372
- setResult(value: string): void {
373
- this.#output.textContent = value;
374
- }
375
-
376
- setLoading(value: boolean): void {
377
- this.#run.disabled = value;
378
- this.#output.hidden = value;
379
- }
380
- ```
381
-
382
- 改哪一处用**标准 DOM API** 直说,不要绕:`textContent`、`setAttribute` /
383
- `removeAttribute`、`classList.add/remove/toggle`、`hidden`、`disabled`、`value`,
384
- 以及 `href` / `src` 这类直接赋值的属性。除此之外不要再找第三种写法。
385
-
386
- **必须避免**的形态(贴出来是为了让评审一眼对上号):
387
-
388
- ```ts
389
- set data(value) { this.#data = value; this.render(); } // 响应式 state 驱动 render
390
- update(data) { this.replaceChildren(); this.render(data); } // 用替换子树模拟更新
391
- render(data) { /* 根据 data 重新构建整个 DOM */ } // 通用刷新机制
392
- rebuild() { this.replaceChildren(); this.build(); } // 同上,换个名字而已
393
- ```
394
-
395
- 不允许实现 `render()` / `rebuild()` / `rerender()` 之类的**通用**刷新机制;
396
- 不允许因为一个字段变化就重建整个子树;不允许通过「替换子树」模拟响应式更新。
397
-
398
- ### 4. `replaceChildren()` 的分级用法
399
-
400
- `replaceChildren()` 本身不是禁品,禁的是把它当**整组件的通用 render 机制**:
401
-
402
- - 允许:**确实需要整体更新(或一次性静态组装)的局部集合**,例如卡片网格
403
- 一次给出全新条目、按钮的「图标 + 文本」这种固定组合。
404
- - 禁止:拿它清空组件自己的根子树再重建 —— 那是第 3 条里的 `rebuild()`。
405
-
406
- 变长列表能增量就增量:按数据长度**增删条目**、复用已有条目对象
407
- (`DfkHero.ts` 的 `setBadges()` 就是这么做的),只有条目语义整体失效时才整批替换。
408
-
409
- ### 5. 内容入口:命名的领域 setter(本包已统一)
410
-
411
- `dfk-*` 元素**不接受整份 data blob,也没有通用的 render/apply 入口**。每个组件
412
- 暴露一组命名的领域 setter,一个方法只碰它负责的那部分节点:
413
-
414
- ```ts
415
- hero.setTitle(text);
416
- hero.setTagline(text);
417
- hero.setPrimaryAction({label, href});
418
- hero.setBadges(badges);
419
-
420
- features.setSectionTitle(text);
421
- features.setFeatures(items);
422
- ```
423
-
424
- - setter 只做 mutation;变长集合(badge 行、卡片网格)按新长度增删条目、复用
425
- 已有条目对象,不整体重建。
426
- - setter 只写自己持有的节点,所以**在元素尚未连接、甚至尚未插入文档时调用也安全**。
427
- 消费方因此不必关心 `connectedCallback()` 的时序,可以乱序批量喂数据。
428
- - **不做 attribute reflection**:组件不读属性、也不把 setter 的值镜像回 attribute
429
- (内容不是可序列化的标记,唯一例外是自有节点上 `iconify-icon` 的 `icon` 属性)。
430
- 内容入口只有 setter。
431
- - 新增字段就加一个对应的 setter,**不要**回头引入 `setData()` / `update()` /
432
- `apply()` 这类通用入口。
433
- - 改 setter 签名属于公开 API 变更,需同步 `docs/src/pages/index.tsx` 的
434
- `mount*()` 助手、`src/index.ts` 的类型导出,以及下游消费方。
435
- - **例外(attribute 种子)**:`<dfk-sql>` 由 `sql/remark` 插件从 Markdown 自动
436
- 生成为 `<dfk-sql config="…" sql="…">`(外加一份预渲染的加载占位,见「加载占位」),
437
- 没有 `mount*()` 助手、也挂不上 ref
438
- 去调 setter —— 内容只能来自 `config` / `sql` 两个字符串属性。因此它在
439
- `connectedCallback()` 里 `getAttribute` **各读一次**作初始种子。这与「不做
440
- attribute reflection」不冲突:读一次用于初始化,不是 attribute 变化再驱动
441
- 重渲染,仍是保留模式。新增同类「由构建期插件生成、无 React 挂载点」的元素
442
- 才可套用此例外,手写 JSX 的元素仍走 setter。
443
-
444
- ### 6. 生命周期:构造函数建树 + 挂 shadow root
445
-
446
- 优先在 `constructor()` 里创建静态结构、`attachShadow` 并组装完毕;
447
- `connectedCallback()` 只做**必要的一次性初始化**(启动监听、注册外部资源)。
448
- 不要把 `connectedCallback() → rebuild() → build()` 当成标准渲染生命周期。
449
-
450
- **Custom Elements 规范限制(必须遵守)**:构造函数里**不得给 `this`
451
- 加属性或子节点**(`this.append(...)`、`this.setAttribute(...)` 都不行),
452
- 否则 `document.createElement()` / `innerHTML` 解析创建的元素会抛错。
453
- `attachShadow()` 不在禁止之列,所以正确拆法是:构造时把结构挂进 shadow root,
454
- light DOM 始终空着。
455
-
456
- ```ts
457
- constructor() {
458
- super();
459
- this.#section = document.createElement('section');
460
- this.#section.append(this.#title, this.#body); // 组装进自有节点
461
- const shadow = this.attachShadow({mode: 'open'}); // 构造函数里合法
462
- shadow.adoptedStyleSheets = [homeStyles()];
463
- shadow.appendChild(this.#section);
464
- }
465
- ```
466
-
467
- 组件因此**从诞生那一刻起结构就完整**,setter 在元素连接前调用也安全,
468
- `connectedCallback()` 不再承担「挂载」职责。
469
-
470
- **监听器按对象归属决定挂在哪**:
471
-
472
- - 挂在**自有节点**上(`this.#primary.addEventListener('click', …)`)→ 写在
473
- `constructor()` 里,节点与元素同生命周期,不需要清理,也不需要重挂。
474
- - 挂在**外部对象**上(`document` / `window` / `matchMedia`)→ 必须在
475
- `disconnectedCallback()` 里 `removeEventListener` / `removeListener`,且注册要有
476
- 幂等标志 —— 元素被移动或 React 重挂载会再次触发 `connectedCallback()`,重复注册
477
- 会让回调执行多次。
478
-
479
- ### 7. 尽量类化,但没有「组件基类」
480
-
481
- 一个组件一个 class:web component 直接 `extends HTMLElementBase`(`src/dom.ts`),
482
- **`home/` 下不存在共享的组件基类**。每个类自带字段、constructor 组装并挂进自己
483
- 的 shadow root、自己的 setter,不做 `create()` / `apply()` 之类的模板
484
- 方法抽象 —— 那种抽象会把「状态驱动刷新」重新引进来,正是第 0、3 条要避免的。
485
-
486
- 非元素的小部件(`TocToggle`、卡片类 `DfkFeatureCard` / `DfkNextStepCard`)同样
487
- 是 class。函数式导出只留给真正的纯工具(`dom.ts` 的 `el()`)。
488
-
489
- ### 8. 图标一律用官方 `<iconify-icon>` web component
490
-
491
- npm 包 `iconify-icon`,`register.ts` 里 side-effect import 注册。
492
- `document.createElement('iconify-icon')` + `setAttribute('icon',
493
- 'lucide:sparkles')` 即可,字形按需从 Iconify 公共 API 加载。
494
-
495
- - **不要**自己封装 SVG(不手写路径、不拼 `data:image/svg+xml`、不做 CSS mask
496
- 助手)—— 官方的实现比自研封装好。
497
- - 数据契约里图标就是 Iconify 名字字符串(`'lucide:arrow-right'`),不引入
498
- `@iconify/types`、不装 `@iconify-icons/*` 本地图标集。
499
- - 尺寸/颜色用 CSS 作用在 `iconify-icon` 元素上(`home.css` 的
500
- `.dfk-button-icon` 等),字形自带 `currentColor`。
501
- - **尺寸只认 `font-size`**:组件内部渲染的是 `<svg width="1em" height="1em">`,
502
- 字形大小跟随宿主的 font-size;给宿主设 CSS `width`/`height` 只会撑大空盒子,
503
- 图标本身不变。
504
-
505
- ### 9. 默认用 Shadow DOM
506
-
507
- `dfk-*` 组件**默认渲染进 shadow root**(`mode: 'open'`),样式用
508
- `adoptedStyleSheets` 注入(`home/styles.ts` 把 `home.css` 以 `?inline` 内联进
509
- bundle,全局共享一个 `CSSStyleSheet`)。这样宿主页面的全局 CSS 进不来、组件的
510
- CSS 也漏不出去,组件边界干净,不依赖「人工命名空间」去避免污染。
511
-
512
- **主题照样跟随宿主**:CSS 自定义属性会**继承穿过 shadow 边界**,所以组件内部
513
- 用 `var(--duckfn-*)` / `var(--ifm-*)` 即可拿到消费站的 Infima 变量与品牌色,
514
- `[data-theme]` 切换自动生效 —— 前提是这些变量声明在**文档的** `:root` /
515
- `[data-theme]` 上(这正是 `tokens.css` 必须留在全局、不能塞进 shadow 的原因)。
516
- 组件内部**不要**写 `[data-theme]` 选择器,也不要依赖宿主的 class。
517
-
518
- **只有「继承属性」能穿过边界**:`box-sizing` 不是继承属性,宿主 Infima 的
519
- `* { box-sizing: border-box }` 选不进 shadow tree,组件内所有盒子会退回
520
- `content-box`,带 padding / max-width 的盒子尺寸随之变化 —— 足以把布局阈值挪位
521
- (实测:feature grid 在 72rem 容器上限处从 3 列变 4 列)。所以 `home.css` 顶部
522
- 必须在 shadow 作用域里**重新声明一次** box-sizing 重置,别指望宿主的通用规则。
523
-
524
- **例外(万不得已才退回 light DOM)**:仅当组件必须直接复用消费站 light DOM 的
525
- CSS 时 —— 例如 `TocToggle` 注入并改写 Docusaurus 自己的 TOC、其规则必须落在
526
- `@layer docusaurus.theme-classic` 里 —— 才不用 shadow root。这种组件的类名一律
527
- `dfk-` 前缀(或 `toc-` 这类自有前缀),避免与宿主撞名。新增例外要在评审时说清楚
528
- 「依赖了宿主的哪条规则」。
529
-
530
- `<dfk-sql>` 是**混合**形态,且 light-DOM 部分只剩一件事:CodeMirror 编辑器与那簇悬浮
531
- 图标按钮**全在 shadow root 里**(编辑器不再 slot,因为 style-mod 会把 `.cm-*` 基础主题
532
- 以 `adoptedStyleSheets` 挂到 `getRoot()` 解析出的根上 —— 编辑器在 shadow 里,解析出的
533
- 就是同一个 shadow root,样式正好落在用它的那棵树里;反过来把编辑器放 light DOM、样式
534
- 却落进 shadow root,就是第一阶段那个「编辑器没样式」的 bug)。只有 **VTable 结果容器**
535
- 在 light DOM(`slot="dfk-result"`),因为 VTable 往**文档级**注入样式表,shadow 边界
536
- 挡得住它。其 light-DOM 样式(`.dfk-sql-result` 一族)走 `sql/sql.css` → `kit.css` 的全局
537
- 通道,同样全部 `dfk-sql-` 前缀。
538
-
539
- 这条也是第 11 条「light DOM 恒为空」的例外之所以安全的原因:编辑器在构造函数里就挂进
540
- shadow root,light DOM 唯一的节点(结果容器)只在**用户点「执行」之后**才创建,
541
- hydration 早已完成。
542
-
543
- ### 10. SSR 安全
544
-
545
- Docusaurus 预渲染在 Node 里 import 本包。
546
-
547
- - 继承 `HTMLElement` 的类必须 `extends HTMLElementBase`(`src/dom.ts`,Node 下
548
- 回退为空基类),否则模块求值直接崩。
549
- - **类字段初始化器不要碰 `document`**:Node 下类体只被求值、不实例化,所以
550
- 字段初始化器安全的前提是「服务端永远不会 new 这个类」。目前正是如此,
551
- 浏览器侧由元素 upgrade(即 `constructor()`)触达。
552
- - 触碰 `window` / `document` / `customElements` 的入口(`registerDfkElements()`、
553
- `TocToggle.init()`)要么带守卫,要么由消费方在浏览器环境调用。
554
- - `styles.ts` 的 `CSSStyleSheet` 必须**惰性创建**(`homeStyles()` 在构造函数里
555
- 才调用):模块级 `new CSSStyleSheet()` 会在 Node 预渲染 import 时直接崩。
556
- `?inline` import 进来的只是字符串,模块级安全。
557
- - `iconify-icon` 在 Node 里 import 是安全的(官方包已处理)。
558
- - Node 构建期模块只有 `src/remark.ts`、`src/sql/remark.ts` 与 `src/sql/extensions.ts`
559
- (连同无依赖的共享契约 `src/sql/runtimeConfig.ts`),它们不得 import 任何浏览器模块。
560
-
561
- ### 11. React 19 自定义元素
562
-
563
- JSX 类型增强写在 `src/index.ts` 的
564
- `declare module 'react' { namespace JSX { … } }`,新增元素时同步补上。
565
- React 19 的 SSR/hydration 不会把对象 prop 设到自定义元素上(对象无法序列化进
566
- 预渲染 HTML),docs 侧用 callback ref 显式喂内容,见
567
- `docs/src/pages/index.tsx`。
568
-
569
- 三点因此而来的约定:
570
-
571
- - **`mount*()` 助手必须是纯 setter 调用(幂等)**。React 可能对同一节点多次调用
572
- ref(StrictMode 双调用、元素移动后重挂载),重复喂同样的数据必须无副作用。
573
- - **预渲染 HTML 里 `<dfk-*>` 是空壳,这是已知取舍**:结构挂在 shadow root 里,
574
- light DOM 始终为空,所以外壳有、内容没有,hydration 之前那几块是空的
575
- (未定义的自定义元素默认 `display: inline`,高度为 0)。这个一次性高度跳变是刻意
576
- 接受的代价。不要为此把结构改回「预渲染时也能拼出来」的写法 —— 那必然退回拼字符串
577
- 或响应式 render。
578
- - **light DOM 恒为空顺带消除了 hydration mismatch**:以前 `connectedCallback()`
579
- 往 light DOM 挂子树,React hydration 比对子节点数量对不上,会报一次可恢复的
580
- mismatch(minified #418)。现在服务端 HTML 与客户端元素的 light DOM 都是空的,
581
- 比对一致。若控制台再出现 #418 指向 `dfk-*`,说明有代码把节点挂回了 light DOM,
582
- 那是 bug,要修原因而不是用 `suppressHydrationWarning` 掩盖。
583
-
584
- ## 发版流程(npm)
585
-
586
- 发布的是 `duckfn-docs-kit` 这一个包,流程比 Rust 侧短:**切版本 → 提交并打 tag →
587
- `npm publish` → 切下一开发版本**,没有远程流水线要等(`docs-kit-v*` 不匹配任何 workflow
588
- 的 tag 过滤器,理由见第 2 步)。
589
-
590
- 命令都在根目录的 `Justfile` 里(`just --list` 可查),实现是
591
- `scripts/release-docs-kit.sh`。它与 `scripts/release.sh`(两个 crate 的发版)分开:两条流程
592
- 各打各的 tag,也互不触发对方的 CI。
593
-
594
- 版本号形如 `X.Y.Z`(例如 `0.1.0`),tag 形如 `docs-kit-v0.1.0`。只有**正式版本**才打 tag、
595
- 才发 npm;`0.1.1-dev.0` 这类开发版本留在分支上。
596
-
597
- | 步骤 | 命令 |
598
- | --- | --- |
599
- | 0. 前置检查 | `just release_kit_check` |
600
- | 1. 提升版本号 | `just release_kit_bump 0.1.1` |
601
- | 2. 提交并打 tag | `git commit …` 后 `just release_kit_tag 0.1.1` |
602
- | 3. 发布 npm 包 | `just release_kit_publish`(先 `npm login`) |
603
- | 4. 切开发版本 | `just release_kit_dev 0.1.2-dev.0` |
604
-
605
- ### 0. 前置检查
606
-
607
- ```bash
608
- just release_kit_check # 构建 + 类型检查 + npm pack --dry-run
609
- npm whoami # 没登录先 npm login
610
- ```
611
-
612
- `npm pack --dry-run` 打印 tarball 的文件清单,是发布前最值得看的一项:预期是 `dist/**` +
613
- `src/**` + `AGENTS.md` + `README.md` + `LICENSE` + `package.json`(当前 64 个文件)。
614
- 多出别的东西时先查 `files` 白名单,不要靠 `.npmignore` 追着排除。
615
-
616
- ### 1. 提升版本号
617
-
618
- ```bash
619
- just release_kit_bump 0.1.1
620
- ```
621
-
622
- 脚本把版本号从工作区的开发版本(如 `0.1.1-dev.0`)切成正式版本,只改两个文件:
623
- `duckfn-docs-kit/package.json`,以及根 `package-lock.json` 里该 workspace 的条目;然后打印
624
- 残留的旧版本号(应为空)与 `git diff --stat`。
625
-
626
- **不要用 `npm version` / `npm install` 改版本号**:它们会顺手 reify 整个 workspace、触发对
627
- registry 的 fetch(本机被 `EALLOWREMOTE` 拦下),结果是版本号改了、lockfile 没动。脚本直接
628
- 重写这两处 JSON —— 两个文件的既有格式就是 2 空格缩进 + LF,重写是幂等的。
629
-
630
- ### 2. 提交并打 tag
631
-
632
- ```bash
633
- git add -A
634
- git commit -m "chore(release-kit): 发布 duckfn-docs-kit v0.1.1"
635
- just release_kit_tag 0.1.1 # 打 docs-kit-v0.1.1,推送 main 与 tag
636
- ```
637
-
638
- `docs-kit-v*` **故意**不匹配两个 workflow 的 tag 过滤器(`MainDistributionPipeline.yml` 与
639
- `DeployDocs.yml` 都只认 `v*.*.*`):推这个 tag 不构建扩展、也不重发文档站,所以 kit 发版不必
640
- 等流水线;文档站仍然只由 crate 的 `v*.*.*` tag 触发。
641
-
642
- ### 3. 发布到 npm
643
-
644
- ```bash
645
- npm login # 只需一次;启用了 2FA 的话发布时会要 OTP
646
- just release_kit_publish # 先跑 release_kit_guard,再 npm publish -w duckfn-docs-kit
647
- ```
648
-
649
- `release_kit_guard` 在上传之前拦四种情况:版本号还是开发版本(npm 会把预发布版本也挂到
650
- `latest` 上)、工作区不干净、本地没有对应的 `docs-kit-v*` tag、该版本在 npm 上已存在
651
- (同一版本不能覆盖,只能发新版本)。只想打包不上传,用 `just release_kit_publish_dry`。
652
-
653
- 发布后核对:
654
-
655
- ```bash
656
- npm view duckfn-docs-kit version
657
- ```
658
-
659
- ### 4. 切到下一开发版本
660
-
661
- ```bash
662
- just release_kit_dev 0.1.2-dev.0
663
- ```
664
-
665
- 只动同样的两个文件,不打 tag、不发布。开发版本不能被 `npm publish` 直接发出去 ——
666
- `release_kit_guard` 会拒绝,这正是它存在的意义。
667
-
668
- ### 发布内容
669
-
670
- 包内容 = `files` 白名单 + npm 的固定规则,所以**不需要**在包的结构之外维护清单:
671
-
672
- - `files: ["dist", "src", "AGENTS.md"]`:`dist` 是构建产物;`src` 必须带上(CSS 子路径
673
- `duckfn-docs-kit/src/kit.css` 直接指向源文件);`AGENTS.md` 随包发布,下游读者因此能看到
674
- 本包的全部约定。
675
- - `README.md`、`LICENSE`、`package.json` 由 npm 自动收录:本包目录下有自己的 `LICENSE`
676
- (根目录那份不会被带进来),README 是 npm 包页的正文。
677
- - `dist/` 在 `.gitignore` 里,但照样进包 —— `files` 白名单优先于 gitignore。`prepack` 脚本
678
- 会在 `npm pack` / `npm publish` 之前重跑 `npm run build`,所以发布用的产物总是当前源码
679
- 构建出来的,不依赖上一次留下的 `dist/`。
680
-
681
- ## 其它约定
682
-
683
- - 文本文件一律 LF(仓库根 AGENTS.md 有替换命令)。
684
- - 注释解释「为什么」,与 docs/ 现有风格一致;本包面向国际下游用户,注释用英文。
685
- - `exports`/`files` 已按可发布形态维护,**不要改结构**:新增 / 移动模块只改
686
- `vite.config.ts` 的 `entry`,通配映射自己会跟上。
687
- - `README.md` 与 `docs/docs/docs-kit/**` 是两份正文,面向的读者不同:前者给在 npm 上直接看
688
- 这个包的人(英文),后者是本仓库文档站的用户指南。改公开 API 时两边都要看。
689
- - 本包已发布到 npm(`duckfn-docs-kit`),`private` 与 `publishConfig` 的处理见上面的发版流程。
1
+ # duckfn-docs-kit — usage guide for agents and site authors
2
+
3
+ This is the **consumer-facing** companion to [`README.md`](./README.md): the README says what to
4
+ import and how to wire it into `docusaurus.config.ts`, this file states the **contracts and the
5
+ traps** — the things that are easy to get subtly wrong when you write a docs page or review one.
6
+
7
+ Read it before writing runnable SQL blocks or configuring preloads.
8
+
9
+ > Developing the kit itself? Its internal conventions (rendering contract, shadow-DOM rules,
10
+ > release flow) are in `CONVENTIONS.md` in the repository. That file is **not** published; this one
11
+ > is, so it is what a dependency reader sees.
12
+
13
+ ## The three things that most often go wrong
14
+
15
+ 1. **A runnable block shows only the result of its *last* statement.** Stacking several independent
16
+ examples in one block means the reader sees one result and the others silently vanish. One
17
+ example per block; several statements only for a preamble (`SET`, `CREATE`, …) that the last
18
+ query needs.
19
+ 2. **`show: "html"` / `"iframe"` / `"svg"` need to be told which column holds the markup.** With more
20
+ than one result column you **must** name it: `"field": "<column>"`. Without it the renderer has
21
+ nothing to preview. `"tab_name"` names the column that labels each preview tab, and without it
22
+ tabs read `Row 1`, `Row 2`, …
23
+ 3. **Only a block whose info string is JSON with `"type":"duckfn"` becomes runnable.** A bare
24
+ ```` ```sql ```` block (or any other metastring) stays a plain, un-runnable code block — no Run
25
+ button, nothing executed.
26
+
27
+ ## Install and wire it up
28
+
29
+ See [`README.md`](./README.md) — install, the three `docusaurus.config.ts` plugins, and the one CSS
30
+ import. Everything below assumes that is already done.
31
+
32
+ ## Runnable SQL blocks
33
+
34
+ ### The shape
35
+
36
+ A fenced block whose info string is a JSON config. The block itself becomes a CodeMirror editor
37
+ with a Run button; nothing executes until the reader clicks it.
38
+
39
+ ````md
40
+ ```sql {"type":"duckfn"}
41
+ SELECT 40 + 2 AS answer;
42
+ ```
43
+ ````
44
+
45
+ Works in `.md` and `.mdx` alike — the metastring is rewritten during the build, before either format
46
+ is compiled.
47
+
48
+ ### Config reference
49
+
50
+ | Field | Meaning |
51
+ | --- | --- |
52
+ | `type` | `"duckfn"`. Required — this is what makes the block runnable. |
53
+ | `show` | `table` (default), `text`, `html`, `iframe`, `svg`. See *Result renderers*. |
54
+ | `field` | The column holding the markup, for `html` / `iframe` / `svg`. Required when the result has more than one column. |
55
+ | `tab_name` | The column whose value labels each preview tab. Defaults to `Row N`. |
56
+ | `option.width` · `option.height` | CSS lengths for the preview box (`"100%"`, `"640px"`). |
57
+ | `option.sandbox` | Sandbox tokens for the iframe, replacing the default `allow-scripts`. Widen deliberately. |
58
+ | `extensions` | Extra extension names to `LOAD` before this block runs, on top of the site's preloads. |
59
+ | `repository` | Where those extensions come from: `community`, `core`, or a repository URL. |
60
+ | `allowUnsignedExtensions` | Accept an unverifiable signature. Site-wide via the preload config, or per block; the first block to initialise the engine settles it. |
61
+ | `expect` | `ok` (default) or `error`. `error` declares "this block must fail" — see *Testing*. |
62
+
63
+ ### Behaviour you have to design around
64
+
65
+ - **The result shown is the last statement's.** A block with `SET …; CREATE …; SELECT …` shows the
66
+ `SELECT`. A block with three independent `SELECT`s shows the third one only.
67
+ - **Default `show`:** a single column with a single row renders as `text`; anything else renders as
68
+ a `table`. Set `show` explicitly when the shape matters.
69
+ - **One DuckDB-Wasm instance per page, one connection per page.** Blocks on the same page share
70
+ state — a table or macro created in one block is visible to the next — and pages are isolated
71
+ from each other. Do not write a block that depends on another *page*.
72
+ - **The site's preloaded extensions are already loaded.** Call into them directly; do not add
73
+ `extensions` for the extension the site documents.
74
+ - **Errors are a result, not a broken block.** A failing statement renders its message in the result
75
+ area and keeps whatever the reader typed.
76
+ - **Every result has a tab strip** (even a plain table), and the fullscreen toggle lives at its right
77
+ end. Table results bring sorting, resizable rows/columns, a right-click menu and header drag.
78
+
79
+ ### Result renderers
80
+
81
+ | `show` | What it renders | Needs |
82
+ | --- | --- | --- |
83
+ | `table` | The result grid. | — |
84
+ | `text` | A bare scalar, as one line. | A single column/row result. |
85
+ | `html` / `iframe` | One tab per row; the markup goes into a sandboxed `iframe` (`srcdoc`), with a trailing `Table` tab that is always last. | `field` (unless the result has exactly one column). `tab_name` to label tabs. |
86
+ | `svg` | The markup is spliced **into the page** (one tab per row, trailing `Table` tab). | Same as above. |
87
+
88
+ Two facts worth knowing before you pick one:
89
+
90
+ - `html` and `iframe` are the *same* renderer. The frame is sandboxed with `allow-scripts` and
91
+ **without** `allow-same-origin`, so a report's JavaScript runs while the frame keeps an opaque
92
+ origin — that is what makes charts work, and it is also why the parent page cannot read
93
+ `iframe.contentDocument` (it is `null` by design).
94
+ - `svg` shares the page, so anything that could execute or navigate — `script`, `foreignObject`,
95
+ `on*` handlers, `javascript:` links — is stripped before insertion.
96
+
97
+ ### Examples
98
+
99
+ A table result, and a scalar that degrades to text:
100
+
101
+ ````md
102
+ ```sql {"type":"duckfn","show":"table"}
103
+ SELECT * FROM range(10) WHERE range > 5;
104
+ ```
105
+
106
+ ```sql {"type":"duckfn"}
107
+ SELECT 1;
108
+ ```
109
+ ````
110
+
111
+ An HTML (or iframe) report — note `field` and `tab_name`:
112
+
113
+ ````md
114
+ ```sql {"type":"duckfn","show":"iframe","field":"html","tab_name":"label","option":{"height":"170px"}}
115
+ SELECT * FROM (VALUES
116
+ ('Bars', '<!doctype html><body><h4>Quarterly revenue</h4><svg viewBox="0 0 240 80">…</svg></body>')
117
+ ) AS t(label, html);
118
+ ```
119
+ ````
120
+
121
+ An inline SVG, and an extension that is not preloaded:
122
+
123
+ ````md
124
+ ```sql {"type":"duckfn","show":"svg","option":{"height":"140px"}}
125
+ SELECT '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="40"/></svg>';
126
+ ```
127
+
128
+ ```sql {"type":"duckfn","show":"table","extensions":["inet"]}
129
+ SELECT '127.0.0.1'::INET::VARCHAR AS ip;
130
+ ```
131
+ ````
132
+
133
+ ### Mistakes to check for when reviewing a page
134
+
135
+ - Several independent examples stacked in one block — only the last result is visible. **Split
136
+ them into one block per example.**
137
+ - `show: "html"` / `"iframe"` / `"svg"` with a multi-column result and no `field`.
138
+ - No `tab_name`, so the preview tabs read `Row 1`, `Row 2`, … instead of something meaningful.
139
+ - A bare ```` ```sql ```` block where a runnable one was intended (it renders as a plain listing).
140
+ - Declaring `extensions` for the extension the site already preloads.
141
+ - A block that depends on a table created on a *different* page.
142
+
143
+ ## Preloading extensions (the `dfkExtensions` plugin)
144
+
145
+ The site declares an ordered preload list; the kit fetches the release assets at dev/build startup,
146
+ injects the list into every page and loads them — in order — while DuckDB initialises. Blocks can
147
+ then call into those extensions without declaring anything.
148
+
149
+ Three source kinds:
150
+
151
+ | Entry | Meaning |
152
+ | --- | --- |
153
+ | `'json'` | A name: `LOAD json` from the official repository. |
154
+ | `{name: 'h3', repository: 'community'}` | `community`, `core` or a repository URL. On wasm `INSTALL` only records *where* a later `LOAD` fetches from. |
155
+ | `{url: 'duckdb-extensions/x.duckdb_extension.wasm', release: {repository, asset}}` | The site serves the file itself; with `release`, the build fetches that asset from the repository's latest release (cached by sha256 in `<siteDir>/.cache/duckfn-docs-kit/`). |
156
+
157
+ Constraints that bite:
158
+
159
+ - **The file name is a contract**: the text before the first dot is the entry symbol DuckDB looks
160
+ up, so a release asset named `duckfn-wasm_eh.duckdb_extension.wasm` must be served as
161
+ `duckfn.duckdb_extension.wasm` (the `url` decides the file name).
162
+ - **Platforms must match**: a `wasm_eh` extension needs a runtime bundle on the `eh` platform. Pin
163
+ `@duckdb/duckdb-wasm` to the exact version whose bundled DuckDB is ABI-compatible with the
164
+ extension build.
165
+ - **Unsigned third-party extensions need `allowUnsignedExtensions: true`** (the WebAssembly
166
+ equivalent of `duckdb -unsigned`). Community extensions are signed and load without it.
167
+
168
+ ## Testing the blocks (`duckfn-sql-verify`)
169
+
170
+ The kit ships the same runner the browser uses, so a site can execute every block it publishes:
171
+
172
+ ```bash
173
+ duckfn-sql-verify --site . # or: npx duckfn-sql-verify --site .
174
+ ```
175
+
176
+ It collects every runnable block (`docs/` plus each `i18n/<locale>/…/current/` by default), runs it
177
+ in DuckDB-Wasm with the site's extension loaded, and fails the process when a block does not behave
178
+ as it declares. Wire it into `package.json` as `"test": "duckfn-sql-verify --site ."`.
179
+
180
+ - **A block that demonstrates a failure must say so**: `{"type":"duckfn","expect":"error"}`. The
181
+ check is two-way — a block that declares `error` and starts succeeding is reported too — and a
182
+ `-- error:` comment in the SQL is *not* read; only the metadata counts.
183
+ - Useful options: `--extension <path|url>`, `--platform eh|mvp`, `--content <dir>` (repeatable),
184
+ `--timeout <ms>`, `--report <file>`, `--working-dir <dir>`, `--quiet`.
185
+ - The default extension is the single file under `static/duckdb-extensions/`.
186
+
187
+ ## TOC collapse control (`dfkTocToggle()`)
188
+
189
+ ```ts
190
+ plugins: [dfkTocToggle()],
191
+ ```
192
+
193
+ Adds a collapse button to the desktop table of contents and remembers the choice in
194
+ `localStorage` (`duckfn:toc-collapsed`). Custom labels come from the plugin's `labels` option,
195
+ keyed by a lower-cased `html-lang` prefix (`{en: {hide, show}, 'zh-hans': {…}}`).
196
+
197
+ ## Version placeholder (`remarkVersionPlaceholder`)
198
+
199
+ ```ts
200
+ remarkPlugins: [[remarkVersionPlaceholder, {version: DUCKFN_VERSION}]],
201
+ ```
202
+
203
+ Replaces `{{DUCKFN_VERSION}}` inside `text`, `inlineCode` and `code` nodes, so a release updates one
204
+ file instead of every page. It only touches that exact placeholder — anything else is left alone.
205
+
206
+ ## Home-page components
207
+
208
+ `<dfk-hero>`, `<dfk-features>`, `<dfk-next-steps>` (and `<dfk-sql>`) are registered by the barrel
209
+ import (`duckfn-docs-kit`). The home components are **setter-driven and do not reflect attributes**:
210
+ give them named setters (`setTitle`, `setTagline`, `setFeatures`, …), not markup content. They
211
+ render into shadow roots, inherit `--duckfn-*` / `--ifm-*` CSS variables from the page, and are
212
+ safe to call before the element is connected.
213
+
214
+ ## Troubleshooting
215
+
216
+ | Symptom | Likely cause |
217
+ | --- | --- |
218
+ | A code block has no Run button | Its info string is not JSON with `"type":"duckfn"`. |
219
+ | The block runs but nothing appears in the preview | `show: "html"` / `"iframe"` / `"svg"` without `field` on a multi-column result. |
220
+ | The preview tabs are labelled `Row 1`, `Row 2`, … | No `tab_name`. |
221
+ | Clicking Run shows an error like `Table with name … does not exist` | The block depends on something created on another page, or on a statement that is no longer the last one in its block. |
222
+ | `LOAD` fails with a signature error | `allowUnsignedExtensions: true` missing for a third-party asset. |
223
+ | A preloaded extension fails to load | The served file name's pre-dot part does not match the extension's entry symbol, or the platform does not match the runtime bundle. |
224
+ | Only the last of several examples shows a result | That is the contract — split the block. |