# ice-web-components-dsl **Repository Path**: ice-render/ice-web-components-dsl ## Basic Information - **Project Name**: ice-web-components-dsl - **Description**: No description available - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-15 - **Last Updated**: 2026-09-21 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # ice-web-components-dsl JSON-first DSL for AI agents to build forms with [`ice-web-components`](https://www.npmjs.com/package/ice-web-components). `ice-web-components` 的表单是**三层**的:控件(`ICETextField` / `ICESelect` / `ICEInputNumber`…) + `ICEFormItem`(标签与错误排版)+ `ICEForm`(把控件与模型接起来)。每个字段要写三层构造, 而 `name` / `label` / `control` / `rules` 分散在两个对象里。 **所以这个包不是「把 `new ICEForm(...)` 换个写法」**。它补的是那三层不做、 而模型最容易写错的三件事: | | 原生写法 | ice-web-components-dsl | | --- | --- | --- | | 输入 | 每个字段三层构造,`name`/`label`/`control`/`rules` 分散 | **一个扁平 `fields[]`**:`{ name, type, label, ... }` | | 约束 | `new ICEInputNumber({ min: 18 })` 与 `rules: [{ min: 18 }]` **互不相干** | **写一次 `min: 18`,控件夹取与校验两处都有了** | | 出错时 | 控件画出来了但行为不对,只能自己猜 | **结构化诊断**:类型不认识列出可用类型、依赖写错列出可用字段名 | 编译产物就是普通的 `ICEPanel` + `ICEForm` + `ICEFormItem` + 控件 —— 命中测试、键盘导航、 无障碍镜像、主题、序列化全部照旧。 ## 1. 安装 ```bash npm install ice-web-components-dsl ice-web-components ice-render ``` ## 2. 30 秒 ```json { "schemaVersion": 1, "kind": "form", "title": "泵站参数确认", "description": "这三项确认后才会下发控制指令。", "fields": [ { "name": "station", "type": "text", "label": "泵站名称", "required": true, "maxLength": 20 }, { "name": "mode", "type": "select", "label": "运行模式", "required": true, "default": "auto", "options": [{ "value": "auto", "label": "自动" }, { "value": "manual", "label": "手动" }] }, { "name": "flow", "type": "number", "label": "目标流量 (m³/h)", "required": true, "min": 0, "max": 5000, "step": 10 } ], "submitText": "确认下发" } ``` ```ts import { renderFormDsl, validateFormDsl, compileFormDsl } from 'ice-web-components-dsl'; const { compiled, diagnostics } = renderFormDsl('canvas-id', dsl, { onSubmit(values) { console.log(values); // { station: '一号泵站', mode: 'auto', flow: 800 } }, }); // 或者只要诊断(validate 不抛异常,任何输入都能吃) const result = validateFormDsl(dsl); if (!result.valid) console.log(result.errors.map((e) => e.message).join('\n')); // 或者只要组件树(自己挂到已有的 ICE 实例上) const { container, form, model } = compileFormDsl(dsl); ice.addChild(container); ``` 浏览器直接用(UMD,**注意顺序**): ```html ``` ## 3. 四个导出 | 导出 | 说明 | | --- | --- | | `validateFormDsl(dsl)` | **任何输入都不抛异常**。返回 `{ valid, errors, warnings }`,每条诊断带 `severity` / `code` / `message` / `path` | | `formatDiagnostics(result)` | 把校验结果转成可读文本 | | `compileFormDsl(dsl, opts?)` | → `{ container, form, model, submitButton, fieldNames, getValues, setValues, reset, setWidth, submit, submitAsync, onSubmit, destroy }`。`opts.width` / `opts.maxWidth` 见 §8.1。校验不过抛 `FormDslCompileError`(带诊断) | | `renderFormDsl(target, dsl, opts?)` | 一步到位:建 ICE 实例 + 挂组件 + 接提交。返回 `{ ice, compiled, diagnostics, width, resize, setWidth, measureContentHeight, destroy }`。`width` 是表单**最终**宽度(已夹过 `maxWidth`),见 §8.1 | ## 4. 字段类型与它们接受的属性 共 **20 个类型**(`FORM_DSL_FIELD_TYPES`)。加新类型的**硬条件**是"值能经 JSON 往返" —— 判据是运行时的,不是从文档看来的(见 §4.1)。 | `type` | 取值类型 | 该类型额外接受 | | --- | --- | --- | | `text` / `textarea` | string | `allowClear` `showCount` | | `password` | string | `allowClear` `showCount` `showToggle` | | `number` | number | `step` `precision` | | `slider` | number | `step` `range` | | `checkbox` / `switch` | boolean | — | | `radio-group` / `checkbox-group` | string / string[] | `options` `direction`(多选组还有 `maxChecked`) | | `select` | string(`mode: multiple` 时 string[]) | `options` `mode`(`single`/`multiple`/`tags`)`showSearch` | | `date` | string(`YYYY-MM-DD`) | `placement` | | `color` | string(hex) | `options`(色板) | | `rate` | number | —(`max` = 满分几颗星) | | `time` | string(`HH:mm:ss`) | `format`(`HH:mm:ss` / `HH:mm`) | | `segmented` | string | `options` `block` | | `autocomplete` | string | `options`(候选) | | `cascader` | string(最深一层的叶子) | `options`(带 `children`)`separator` | | `tree-select` | string(`mode: multiple` 时 string[]) | `options`(带 `children`)`mode` `showSearch` | | `transfer` | string[] | `options`(候选池) | | `date-range` | `[起, 止]` | —(`required` = 两头都在) | 所有类型都还接受:`name` `type` `label` `placeholder` `default` `required` `min` `max` `minLength` `maxLength` `pattern` `message` `rules` `dependencies` `width` `props`。 **白名单之外的键会被忽略,并给出警告** —— 警告里会列出这个类型接受哪些键。这就是"字段表封闭"的好处: 模型不会静默地写一个不生效的属性。 **`options` 两种写法都收**:`[{ "value": "a", "label": "甲" }]` 或直接 `["a", "b"]`。 库里不同控件要的形状不一样(`colors: string[]` / `options: string[]` / `{value,label}[]` / `nodes: {key,label}` / `dataSource: {key,title}`),**那些差别由编译期归一化** —— 否则就是在收"模型记不住哪个是哪个"的税。 ### 4.1 判据是运行时的:值能不能经 JSON 往返 `ice-web-components` 有 84 个 UI 组件,但不是每个都能当字段。判据不是"有没有 `value` 构造参数", 也不是"有没有 `getFormValue`"(那是 `ICEWidget` **基类**给的,人人都有),而是 **`setFormValue(v)` 之后 `getFormValue()` 还回不还得出同一个东西**: | 组件 | 运行时表现 | 结论 | | --- | --- | --- | | `ICEColorPicker` | `3 → "3"`、`["a","b"] → "a,b"`(强制转字符串) | ✅ 真实现 | | `ICEDateRangePicker` | 只认两头齐全的元组,其余回落成 `[null,null]` | ✅ 真实现 | | `ICETransfer` | 只认 `string[]`,其余回落成 `[]`(按数据源过滤) | ✅ 真实现 | | `ICERadioButton` | `getFormValue()` 返回**布尔**(只表示自己勾没勾,互斥要调用方维护) | ❌ 假单选 | | `ICEUpload` | `setFormValue` **照收不误**(基类默认),组件本身不参与取值 | ❌ 没实现 | 证据在 `tests/field-values.test.ts`(构造出来真调一遍),`tools/probe-field-values.mjs` 是同一件事的交互版。`ICERadioButton` / `ICEUpload` 因此**不在**类型表里 —— 不是漏了,是判过不能用。 ## 5. 一处声明、两处生效 ```json { "name": "age", "type": "number", "label": "年龄", "min": 18, "max": 65 } ``` `min` / `max` 会**同时**: 1. 传给 `ICEInputNumber`,约束步进按钮的夹取范围; 2. 生成 `ICEFormRule`,拦住用户手输的越界值。 原生路径这两件事是分开的:`new ICEInputNumber({ min: 18 })` 只影响步进按钮, **手输 `10` 不会报错**,除非你再写一条 `rules: [{ min: 18 }]`。模型只会写其中一个。 文本类字段的 `maxLength` 同理:既限制输入长度,也生成校验规则。 ## 6. 规则:shorthand 与 `rules` | shorthand | 说明 | | --- | --- | | `required` | 必填;`null` / 空串 / 空数组 / `false` 都算缺失 | | `min` / `max` | 数值上下界 | | `minLength` / `maxLength` | 字符串或数组长度 | | `pattern` | 正则**字符串**(JSON 里写不出正则字面量,编译时转 `RegExp`) | | `message` | 自定义错误文案 | shorthand 会合成**一条**规则(原生 `ICEFormRule` 本来就允许一条规则带多个约束)。 需要多条独立规则时用 `rules`,按数组顺序接在 shorthand 之后: ```json { "name": "code", "type": "text", "required": true, "rules": [{ "minLength": 4 }, { "pattern": "^[A-Z0-9-]+$", "message": "只能是大写字母、数字与连字符" }] } ``` `validator` / `asyncValidator` 是函数,JSON 表达不了,**不收**(写了会被 `unknown-rule-key` 告警拦下)。 ## 7. 跨字段依赖 ```json { "name": "confirm", "type": "password", "label": "确认密码", "required": true, "dependencies": ["password"] } ``` 被依赖字段一变,本字段立刻重算 —— 不用等用户再动本字段一次。 依赖名写错会被拦下,**诊断里列出可用字段名**(含前向引用正确、后向引用也正确)。 ## 8. 布局:为什么没有坐标 DSL 里**不可表达** `left` / `top`。字段纵向堆叠交给 `ICEForm` 的箱式布局, 标签在上还是左侧由顶层 `layout` 决定。 理由很实际:`ice-web-components` 有 84 个组件,一旦放开坐标,模型就会产出互相重叠的控件 —— "让它自己摆"是唯一可行的口径。 宿主负责的是**画布多大**(`renderFormDsl` 的 `resize(w, h)`,内部走引擎的 `ICE.fitCanvasToDisplaySize()`),而不是每个控件摆在哪。 ### 8.1 宽度:宿主说了算,但必须显式告诉它 宽度不在 DSL 里定,因为**表单多宽取决于它被放在哪儿**,而 DSL 不知道这件事。 宿主通过 `width` 传进来: ```js const result = renderFormDsl(canvas, dsl, { width: card.clientWidth }); // 或者只要组件树 const compiled = compileFormDsl(dsl, { width: card.clientWidth }); ``` 不传则退回 `dsl.width`,再退回 `360`。宿主给的宽度会被 **`maxWidth`(默认 640)** 夹住 —— 把 896 全铺满不是"排满了",是难看:一行 896 宽的输入框没人读得过来。 (`maxWidth: Infinity` 就是不设上限;也可以用 `dsl.maxWidth` 在文档里声明。) **表单最终多宽**读 `result.width` —— 宿主需要它来决定画布/容器多宽。 拿自己传进去的宽度去定画布就会宽出一截、右边空一块,看起来跟没修一样。 容器尺寸变了要调 `setWidth()` —— **`resize()` 管画布,`setWidth()` 管内容**,两件事都要做: ```js window.addEventListener('resize', () => { result.setWidth(card.clientWidth); // 表单与控件重新对齐(同样会被 maxWidth 夹住) result.resize(result.width, height); // 画布跟**表单实际宽度**,不是容器宽度 }); ``` **为什么宽度要一层层显式写下去**:宽度在 ICE 里是每个组件自己的属性, **没有"父级拉满"的自动传导**。`ICEForm` 的 `ICEBoxLayout({ align: 'stretch' })` 拉的是 `ICEFormItem`,不拉控件;而 `ICEFormItem.doLayout` 只按 `control.state.width` (缺省 `200`)**定位**控件,不改变它。所以只写 `align: 'stretch'` 对视觉结果完全没有作用, 每个控件会落到自己的出厂默认 —— `ICETextField` 200、`ICEInputNumber` 140、`ICESelect` 200, 同一张表单里几个控件宽度还互不相同(实测宿主 896 宽时右侧空掉 667px,74%)。 编译期给的默认规则: | 字段类型 | 控件宽度 | | --- | --- | | 除 `number` 以外全部 | 表单宽度(`horizontal` 布局下是 `max(120, 表单宽度 - 80)`,那 80 是 `ICEFormItem` 的默认 `labelWidth`,让给标签) | | `number` | **200,不拉伸** | `number` 是唯一的例外,理由是 `ICEInputNumber` 自己的内部布局:减号贴最左端、数值居中, 宽度一拉大这两样就天各一方(实测 890px 时看着像坏了)。 反过来,其余类型**必须**拉伸,因为它们的出厂默认是**退化的**: `slider` 默认 10px、`checkbox` 默认 0px、`radio-group` 默认 35px —— 不给宽度就会画出一个看不见的控件。(这一条是实测出来的:第一版只让"文本类"拉伸, 结果滑块变成 10px 宽的一条。) 逐字段写的 `width` 优先级最高,且 `setWidth()` **不会**动它 —— 显式意图不该被重排抹掉。 ## 9. 诊断码 `validate` 返回的每条诊断都带 `code` 与 `path`,便于程序化处理与回灌给 agent。 共 **38 个错误码 + 6 个警告码**。 **错误**(会导致不渲染): `default-not-in-options` · `default-type-mismatch` · `duplicate-name` · `duplicate-option-value` · `empty-fields` · `empty-options` · `field-constraint-not-number` · `field-not-object` · `invalid-dependencies` · `invalid-dependency` · `invalid-direction` · `invalid-fields` · `invalid-layout` · `invalid-mode` · `invalid-name` · `invalid-options` · `invalid-pattern` · `invalid-placement` · `invalid-root` · `invalid-rule` · `invalid-rules` · `length-range-inverted` · `missing-fields` · `missing-kind` · `missing-name` · `missing-type` · `option-invalid` · `option-missing-value` · `option-value-not-string` · `options-required` · `range-inverted` · `required-not-boolean` · `rule-value-not-number` · `self-dependency` · `unknown-dependency` · `unsupported-field-type` · `unsupported-kind` · `unsupported-schema-version`。 **警告**(会渲染,但值得看一眼): `invalid-number` · `invalid-submit-text` · `options-not-allowed` · `rule-not-applicable` · `unknown-field` · `unknown-rule-key`。 每条错误都尽量带上**可操作的替代信息**: ``` [错误] 不支持的字段类型「richtext」。可用类型:text / textarea / password / number / slider / …(fields[0].type) [错误] 依赖的字段「passwrod」不存在。可用字段:password / confirm。(fields[1].dependencies[0]) [错误] 默认值「auto」不在 options 里。可用取值:on / off。(fields[0].default) [警告] 「number」不认识字段属性「nope」会被忽略。它接受:step / precision,以及通用属性 name / type / … ``` ## 10. 逃生舱 DSL 只覆盖能写成 JSON 的部分。需要更强的东西时,**在编译产物上继续做**: ```ts const { model, form } = compileFormDsl(dsl); // 自定义同步校验 model.getField('name')!.rules!.push({ validator: (value) => (value === 'admin' ? '这个名字被占用了' : null), }); // 异步校验(DSL 表达不了;按钮走 submitAsync,会先同步后异步) model.getField('name')!.rules!.push({ asyncValidator: (value) => fetch('/api/check?name=' + value).then((r) => r.json()).then((d) => (d.taken ? '已占用' : null)), }); // 换个日期显示格式(原生的 format 是函数) form.getItems()[3].getControl().setFormat((v) => v.replace(/-/g, '/')); ``` 字段上的 `props` 也直通控件构造函数(内容不校验)。 ## 11. Agent 发现路径 Agent 可以通过这几处使用本项目: 1. npm 包导出(`validateFormDsl` / `compileFormDsl` / `renderFormDsl`) 2. `AGENTS.md` 3. `skills/ice-web-components-dsl/SKILL.md` —— 完整规范(§7「库里还有什么」由清单自动生成) 4. `prompts/agent-prompt.md` —— 短版系统提示词(输出契约 + 自检清单) 5. `src/schema/form-dsl.schema.json` —— JSON Schema 6. **`catalog/components.json` —— 组件清单**(机器可读,见 §11.1) > README、SKILL 与提示词里的 JSON 例子由 `tests/doc-examples.test.ts` 自动校验: > 示例一旦不合法(类型写错、依赖悬空、选项为空),测试就红 —— 保证 Agent 照抄的是"能跑的文档"。 示例页:`examples/form-dsl.html`(带 JSON 编辑器与诊断面板,含四个预设:合法 / 类型写错 / 选项有问题 / 依赖指向空)。 ### 11.1 组件清单:让 agent 知道"库里还有什么" 这份 DSL 只覆盖 11 个字段类型,而 `ice-web-components` 有 84 个 UI 组件。 **agent 的真正瓶颈不是画布,是它不知道自己有什么可选** —— 之前 SKILL 里只有那 11 行表, 剩下的它看不见,于是"要个日期区间"也只能退回两个 `date` 字段。 `catalog/components.json`(`npm run catalog` 生成)把整库摊开:分组、摘要、构造参数、 方法、**值的形状**,以及本包加的标注 —— 哪些已接入、哪些能接但还没接、哪些根本不是字段。 三条边界,都不是随手定的: 1. **分组与摘要来自上游的生成产物,不是我自己列的。** 上游 `scripts/gen-docs.mjs` 已经有一套抽取器,而且有 `docs:check` 门禁保证 "每个组件都被登记过"。本包再写一个 TypeScript 解析器就是第二份抽取器, 两份会各自漂移,而漂移的症状是"清单里少了个字段"这种没人会发现的形态。 所以这里**只做搬运与重组**(用法见 `tools/gen-catalog.mjs` 顶部注释)。 2. **"能不能当字段"是手写的** —— 那是编辑判断,源码里推不出来。 但两侧都有门禁:标注里的组件名要真实存在、`fieldType` 要合法, **且每个已实现的类型都必须有组件认领**(加了类型却忘了说它由谁实现 → 测试红)。 3. **清单自己知道缺什么。** `gaps` 一节列出"构造参数没进生成文档的类"(48/113,含 `ICEButton` / `ICETextField` 这些最常用的 —— 它们继承基类的 Options,或构造函数就是 `props?: any`)和"上游没写类注释的条目"。空数组不等于"没有参数", 所以宁可把缺口列出来,也不糊一个 `[]` 过去。 SKILL 的 §7 由同一份清单生成(标记块 ``)。 `npm run verify` 里带 `catalog` 重生成 + `tests/catalog.test.ts` 的同步门禁 —— **上游一改、或本包标注一改而忘了重生成,测试就红**。 ## 12. 验证 ```bash npm run verify # types:check + build + jest npm run verify:full # 上面 + playwright(示例页真机冒烟) ``` 当前规模:**132 单测 / 7 套件**,**10 e2e / 1 spec**,**20 个字段类型**,清单覆盖 **84 个 UI 组件**(9 组 / 185 个条目)。 示例页 e2e 的判据不是"按钮存在",而是:画布上真的有墨、 **真实点中画布上的提交按钮**能走完校验 → 提交这条链、诊断里带可操作的替代信息。 其中三例按**着墨包围盒**判排布("表单铺满内容宽度" / "宽屏上停在 maxWidth" / "窗口变窄后跟着重新对齐")—— `countInk` 那类"画了没有"的断言抓不到 "画出来了但只占左边一小块":着墨量照样几千。这一组是实测缺陷的回归,见 §8.1。 > 判据要**两边都判**。"占画布 x%" 这类单边判据对"把整张卡片全铺满"照样成立, > 所以那两条用例同时断言"不缩成一小块"**和**"不拉满整张卡片"。 > 这一点是 A/B 时发现的:故意把 `maxWidth` 传成 `Infinity`,用例居然还是绿的。 > 知道这个数字从哪来很重要:宽度这条链上没有一处会**报错**。 > 修复前宿主 896 宽、表单只画了 229px —— 校验通过、编译通过、渲染成功, > 只有人会看出"右边怎么空了那么多"。 ## 13. 与家族其它包的关系 | 包 | 面向 | 补的是什么 | | --- | --- | --- | | `ice-chart-dsl` | Agent 画图 | 一张表 + `encoding`;轴/图例/提示框的意图级默认 | | `ice-render-dsl` | Agent 画图形 | 节点/连线 DSL + 编排 | | `ice-entity-designer-dsl` | Agent 建数据模型 | 实体/关系 DSL | | **`ice-web-components-dsl`** | **Agent 收集输入** | 扁平字段表;约束"写一次两处生效";表单诊断 | 四个 DSL 是同一套口径:**JSON 输入、编译成原生产物、诊断可回灌**。 它们与 MCP / AG-UI 的关系是"同一份声明,两条通道" —— MCP 让 agent 能生成, AG-UI 把生成结果送到用户屏幕上。 ## 14. 许可 MIT