@yunzai-ng/jsx 0.1.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/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@yunzai-ng/jsx",
3
+ "version": "0.1.0",
4
+ "description": "Yunzai NG 模板用 JSX 运行时(字符串直出,无虚拟 DOM)",
5
+ "type": "module",
6
+ "license": "AGPL-3.0-or-later",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./jsx-runtime": {
15
+ "types": "./dist/jsx-runtime.d.ts",
16
+ "import": "./dist/jsx-runtime.js"
17
+ },
18
+ "./jsx-dev-runtime": {
19
+ "types": "./dist/jsx-runtime.d.ts",
20
+ "import": "./dist/jsx-runtime.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src"
27
+ ],
28
+ "dependencies": {
29
+ "@yunzai-ng/types": "0.1.0"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc -b"
33
+ }
34
+ }
package/src/element.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 模块职责:把标签名与属性对象序列化成 HTML 文本
3
+ * 依赖方向:仅依赖同包的 `html.ts`
4
+ * 生命周期:无状态,纯函数
5
+ * 注意事项:标签名与属性名均须通过白名单校验。属性名并非全部来自源码字面量 ——
6
+ * `{...props}` 展开可以把任意键带进来,其中若含空格或引号,即可在生成的标签里
7
+ * 插入一个新属性。此处直接抛错而非静默丢弃:这类问题在模板作者一侧一次性可修,
8
+ * 悄悄少输出一个属性反而要到成品图上才被发现。
9
+ */
10
+ import { children, cx, escape, style } from "./html.js"
11
+ import type { Child, ClassValue, StyleDict } from "./types.js"
12
+
13
+ /**
14
+ * 空元素(HTML 规范中不允许有内容、也不写闭合标签的那些)
15
+ *
16
+ * 为它们补一个 `</img>` 会被解析器当作多余的结束标签忽略,看似无害;但 `<br></br>`
17
+ * 在部分场景下会被解析成两个换行。按规范只输出开始标签。
18
+ */
19
+ const VOID_TAGS = new Set([
20
+ "area",
21
+ "base",
22
+ "br",
23
+ "col",
24
+ "embed",
25
+ "hr",
26
+ "img",
27
+ "input",
28
+ "link",
29
+ "meta",
30
+ "param",
31
+ "source",
32
+ "track",
33
+ "wbr"
34
+ ])
35
+
36
+ /** React 为规避 JS 保留字而改名的两个属性,作为别名接纳 */
37
+ const ALIASES: Readonly<Record<string, string>> = { className: "class", htmlFor: "for" }
38
+
39
+ /** 不参与属性输出的键 */
40
+ const DROPPED = new Set(["children", "key", "class", "className"])
41
+
42
+ /** 合法标签名 */
43
+ const VALID_TAG = /^[A-Za-z][\w.:-]*$/
44
+
45
+ /** 合法属性名;与 HTML 规范一致地允许连字符、点与冒号 */
46
+ const VALID_ATTR = /^[A-Za-z_:][\w.:-]*$/
47
+
48
+ /**
49
+ * 序列化属性
50
+ * @param props 属性对象
51
+ * @returns 以空格开头的属性串;无属性时为空串
52
+ * @throws 属性名不合法时抛出 TypeError
53
+ */
54
+ export function attributes(props: Readonly<Record<string, unknown>>): string {
55
+ let out = ""
56
+
57
+ // class 与 className 合并后一次输出:两者都写时分别输出会得到两个 class 属性,
58
+ // 浏览器只认第一个,后一个静默丢失
59
+ const classes = cx(props["class"] as ClassValue, props["className"] as ClassValue)
60
+ if (classes) out += ` class="${escape(classes)}"`
61
+
62
+ for (const [key, value] of Object.entries(props)) {
63
+ if (DROPPED.has(key)) continue
64
+ if (value === null || value === undefined || value === false) continue
65
+
66
+ const name = ALIASES[key] ?? key
67
+ if (!VALID_ATTR.test(name)) throw new TypeError(`非法的属性名:${JSON.stringify(key)}`)
68
+
69
+ // 布尔属性(hidden、disabled 之类)输出裸属性名。写成 `hidden="false"` 反而是生效的,
70
+ // 因为 HTML 只看属性是否存在
71
+ if (value === true) {
72
+ out += ` ${name}`
73
+ continue
74
+ }
75
+
76
+ if (name === "style") {
77
+ const css = style(value as string | StyleDict)
78
+ if (css) out += ` style="${escape(css)}"`
79
+ continue
80
+ }
81
+
82
+ out += ` ${name}="${escape(value)}"`
83
+ }
84
+
85
+ return out
86
+ }
87
+
88
+ /**
89
+ * 序列化一个元素
90
+ * @param tag 标签名
91
+ * @param props 属性对象(含 `children`)
92
+ * @returns HTML 文本
93
+ * @throws 标签名不合法时抛出 TypeError
94
+ */
95
+ export function element(tag: string, props: Readonly<Record<string, unknown>>): string {
96
+ if (!VALID_TAG.test(tag)) throw new TypeError(`非法的标签名:${JSON.stringify(tag)}`)
97
+ const head = `<${tag}${attributes(props)}>`
98
+ if (VOID_TAGS.has(tag.toLowerCase())) return head
99
+ return `${head}${children(props["children"] as Child)}</${tag}>`
100
+ }
package/src/html.ts ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * 模块职责:HTML 文本的载体、转义,以及 `class` / `style` / 子节点的求值
3
+ * 依赖方向:叶子模块,仅取同包的类型
4
+ * 生命周期:无状态,全部为纯函数
5
+ * 注意事项:默认行为是**转义**,`raw()` 是唯一的逃生口。反过来(默认原样输出、需要时
6
+ * 显式转义)在实践中必然遗漏 —— 米游社返回的昵称含一个尖括号即可破坏整张图的
7
+ * 结构,而这种数据只在真机上才会出现,单测覆盖不到。
8
+ */
9
+ import type { Child, ClassValue, StyleDict } from "./types.js"
10
+
11
+ /** 需要转义的五个字符 */
12
+ const ENTITIES: Readonly<Record<string, string>> = {
13
+ "&": "&amp;",
14
+ "<": "&lt;",
15
+ ">": "&gt;",
16
+ '"': "&quot;",
17
+ "'": "&#39;"
18
+ }
19
+
20
+ /** 转义匹配式;单引号一并处理,使属性值以单引号包裹时同样安全 */
21
+ const NEED_ESCAPE = /[&<>"']/g
22
+
23
+ /**
24
+ * 已完成转义、可直接写入文档的 HTML 文本
25
+ *
26
+ * 仅是一个带类型标记的字符串包装:模板的产物是一次性的文本,虚拟 DOM 的 diff 能力在此
27
+ * 毫无用处,而 react / preact 会为此引入数十个传递依赖。Termux 上包体越小越好,
28
+ * 这笔交换不成立。
29
+ */
30
+ export class Html {
31
+ /** HTML 文本 */
32
+ readonly value: string
33
+
34
+ /**
35
+ * @param value 已转义的 HTML 文本
36
+ */
37
+ constructor(value: string) {
38
+ this.value = value
39
+ }
40
+
41
+ /**
42
+ * 判定任意值是否为 Html
43
+ * @param input 待判定的值
44
+ * @returns 是 Html 则为真
45
+ */
46
+ static is(input: unknown): input is Html {
47
+ return input instanceof Html
48
+ }
49
+
50
+ /**
51
+ * 取 HTML 文本
52
+ * @returns HTML 文本
53
+ */
54
+ toString(): string {
55
+ return this.value
56
+ }
57
+
58
+ /**
59
+ * 参与 `JSON.stringify` 时退化为文本,使快照测试可直接序列化
60
+ * @returns HTML 文本
61
+ */
62
+ toJSON(): string {
63
+ return this.value
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 转义为 HTML 文本
69
+ *
70
+ * `null` 与 `undefined` 转为空串而非字面量 "null" —— 模板里 `{user.nick}` 取到空值时
71
+ * 应当什么都不显示,打印出 "undefined" 只会出现在成品图上。
72
+ * @param input 任意值
73
+ * @returns 转义后的文本
74
+ */
75
+ export function escape(input: unknown): string {
76
+ if (input === null || input === undefined) return ""
77
+ return String(input).replace(NEED_ESCAPE, ch => ENTITIES[ch] ?? ch)
78
+ }
79
+
80
+ /**
81
+ * 声明一段文本已经是安全的 HTML,跳过转义
82
+ *
83
+ * 唯一的逃生口,仅用于本模块自己生成的标签、`<!DOCTYPE html>`,以及编译产物这类
84
+ * 确定安全的内容。**不要**用它包裹任何来自接口的数据。
85
+ * @param value HTML 文本
86
+ * @returns 载体
87
+ */
88
+ export function raw(value: string): Html {
89
+ return new Html(value)
90
+ }
91
+
92
+ /**
93
+ * 求值类名
94
+ *
95
+ * 接纳字符串、条件字典与任意嵌套数组三种形态,使 `class={["cont", full && "full"]}`
96
+ * 与 `class={{ up: delta > 0 }}` 都可直接书写,无需在模板里拼字符串。
97
+ * @param inputs 任意个类名输入
98
+ * @returns 以空格分隔、已去重的类名串
99
+ */
100
+ export function cx(...inputs: ClassValue[]): string {
101
+ const out: string[] = []
102
+ push(inputs, out)
103
+ return [...new Set(out)].join(" ")
104
+ }
105
+
106
+ /**
107
+ * `cx` 的递归收集部分
108
+ * @param input 类名输入
109
+ * @param out 收集容器
110
+ */
111
+ function push(input: ClassValue, out: string[]): void {
112
+ if (input === null || input === undefined || input === false || input === "") return
113
+ if (typeof input === "string") {
114
+ for (const part of input.split(/\s+/)) if (part) out.push(part)
115
+ return
116
+ }
117
+ if (typeof input === "number") {
118
+ out.push(String(input))
119
+ return
120
+ }
121
+ if (Array.isArray(input)) {
122
+ for (const item of input) push(item, out)
123
+ return
124
+ }
125
+ for (const [name, on] of Object.entries(input)) if (on) push(name, out)
126
+ }
127
+
128
+ /**
129
+ * 求值内联样式
130
+ *
131
+ * 数值不会被自动补上 `px`:React 的这套隐式补单位规则需要一张"哪些属性是长度"的名单,
132
+ * 名单不全时表现为样式静默失效。此处要求显式书写单位,宽度写成百分号模板串一目了然。
133
+ * 以 `--` 开头的自定义属性保留原始大小写 —— 它们区分大小写,转换会改变含义。
134
+ * @param input 样式字符串或字典
135
+ * @returns CSS 声明串
136
+ */
137
+ export function style(input: string | StyleDict): string {
138
+ if (typeof input === "string") return input
139
+ const out: string[] = []
140
+ for (const [property, value] of Object.entries(input)) {
141
+ if (value === null || value === undefined || value === false || value === "") continue
142
+ const name = property.startsWith("--") ? property : property.replace(/[A-Z]/g, ch => `-${ch.toLowerCase()}`)
143
+ out.push(`${name}: ${String(value)}`)
144
+ }
145
+ return out.join("; ")
146
+ }
147
+
148
+ /**
149
+ * 求值子节点
150
+ * @param child 子节点
151
+ * @returns 拼接好的 HTML 文本
152
+ */
153
+ export function children(child: Child): string {
154
+ if (child === null || child === undefined || typeof child === "boolean") return ""
155
+ if (child instanceof Html) return child.value
156
+ if (Array.isArray(child)) {
157
+ let out = ""
158
+ for (const item of child) out += children(item)
159
+ return out
160
+ }
161
+ return escape(child)
162
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * 模块职责:`@yunzai-ng/jsx` 的行为固定
3
+ * 依赖方向:测试文件,只引本包
4
+ * 生命周期:无状态
5
+ * 注意事项:此处固定的是"默认转义"这一安全口径。米游社返回的昵称、公告标题、兑换码
6
+ * 说明均为用户可控文本,其中出现一个尖括号即可破坏整张图的结构,而这种数据
7
+ * 只在真机上才会出现 —— 必须由单测把住。
8
+ */
9
+ import { describe, expect, it } from "vitest"
10
+ import { attributes, children, cx, defineTemplate, element, escape, Fragment, Html, jsx, jsxs, raw, style } from "./index.js"
11
+
12
+ describe("escape", () => {
13
+ it("转义五个危险字符", () => {
14
+ expect(escape(`&<>"'`)).toBe("&amp;&lt;&gt;&quot;&#39;")
15
+ })
16
+
17
+ it("空值转为空串而不是字面量", () => {
18
+ expect(escape(null)).toBe("")
19
+ expect(escape(undefined)).toBe("")
20
+ })
21
+
22
+ it("数字与零照常输出", () => {
23
+ expect(escape(0)).toBe("0")
24
+ expect(escape(12.5)).toBe("12.5")
25
+ })
26
+ })
27
+
28
+ describe("Html", () => {
29
+ it("raw 跳过转义", () => {
30
+ expect(raw("<b>x</b>").toString()).toBe("<b>x</b>")
31
+ })
32
+
33
+ it("可被 JSON 序列化为文本,便于快照", () => {
34
+ expect(JSON.stringify(raw("<i>"))).toBe('"<i>"')
35
+ })
36
+
37
+ it("能被判定", () => {
38
+ expect(Html.is(raw(""))).toBe(true)
39
+ expect(Html.is("<b>")).toBe(false)
40
+ expect(Html.is(null)).toBe(false)
41
+ })
42
+ })
43
+
44
+ describe("cx", () => {
45
+ it("接纳字符串、字典与嵌套数组", () => {
46
+ expect(cx("cont", ["full", ["wide"]], { up: true, down: false })).toBe("cont full wide up")
47
+ })
48
+
49
+ it("丢弃空值", () => {
50
+ expect(cx(null, undefined, false, "")).toBe("")
51
+ })
52
+
53
+ it("去重", () => {
54
+ expect(cx("cont", "cont cell")).toBe("cont cell")
55
+ })
56
+ })
57
+
58
+ describe("style", () => {
59
+ it("驼峰键转为连字符", () => {
60
+ expect(style({ fontSize: "13px", backgroundColor: "red" })).toBe("font-size: 13px; background-color: red")
61
+ })
62
+
63
+ it("自定义属性保留原始大小写", () => {
64
+ expect(style({ "--gapX": "4px" })).toBe("--gapX: 4px")
65
+ })
66
+
67
+ it("数值原样输出,不自动补 px", () => {
68
+ expect(style({ "z-index": 3 })).toBe("z-index: 3")
69
+ })
70
+
71
+ it("字符串原样通过", () => {
72
+ expect(style("width: 50%")).toBe("width: 50%")
73
+ })
74
+ })
75
+
76
+ describe("children", () => {
77
+ it("布尔与空值渲染为空串,使短路写法可直接书写", () => {
78
+ expect(children([true, false, null, undefined])).toBe("")
79
+ })
80
+
81
+ it("文本被转义,Html 原样通过", () => {
82
+ expect(children(["<b>", raw("<b>")])).toBe("&lt;b&gt;<b>")
83
+ })
84
+
85
+ it("数组任意嵌套", () => {
86
+ expect(children([["a", ["b"]], "c"])).toBe("abc")
87
+ })
88
+
89
+ it("零会被渲染出来", () => {
90
+ expect(children(0)).toBe("0")
91
+ })
92
+ })
93
+
94
+ describe("attributes", () => {
95
+ it("class 与 className 合并成一个属性", () => {
96
+ expect(attributes({ class: "cont", className: { full: true } })).toBe(' class="cont full"')
97
+ })
98
+
99
+ it("htmlFor 映射为 for", () => {
100
+ expect(attributes({ htmlFor: "x" })).toBe(' for="x"')
101
+ })
102
+
103
+ it("true 输出裸属性名,false 与空值整项省略", () => {
104
+ expect(attributes({ hidden: true, disabled: false, title: null })).toBe(" hidden")
105
+ })
106
+
107
+ it("style 接受字典", () => {
108
+ expect(attributes({ style: { width: "50%" } })).toBe(' style="width: 50%"')
109
+ })
110
+
111
+ it("属性值被转义,无法逃出引号", () => {
112
+ expect(attributes({ title: 'a" onload="alert(1)' })).toBe(' title="a&quot; onload=&quot;alert(1)"')
113
+ })
114
+
115
+ it("children 与 key 不进属性", () => {
116
+ expect(attributes({ children: "x", key: 1 })).toBe("")
117
+ })
118
+
119
+ it("非法属性名直接抛错,而不是静默丢弃", () => {
120
+ expect(() => attributes({ "a b": "1" })).toThrow(/非法的属性名/)
121
+ })
122
+ })
123
+
124
+ describe("element", () => {
125
+ it("空元素不输出闭合标签", () => {
126
+ expect(element("img", { src: "a.png" })).toBe('<img src="a.png">')
127
+ expect(element("br", {})).toBe("<br>")
128
+ })
129
+
130
+ it("普通元素带闭合标签与子节点", () => {
131
+ expect(element("div", { class: "cont", children: "文本" })).toBe('<div class="cont">文本</div>')
132
+ })
133
+
134
+ it("非法标签名直接抛错", () => {
135
+ expect(() => element("div onload=x", {})).toThrow(/非法的标签名/)
136
+ })
137
+ })
138
+
139
+ describe("jsx 运行时", () => {
140
+ it("按编译器实际发出的形态调用", () => {
141
+ expect(jsx("span", { children: "x" }).toString()).toBe("<span>x</span>")
142
+ expect(jsxs("ul", { children: [jsx("li", { children: 1 }), jsx("li", { children: 2 })] }).toString()).toBe(
143
+ "<ul><li>1</li><li>2</li></ul>"
144
+ )
145
+ })
146
+
147
+ it("jsxs 与 jsx 是同一实现", () => {
148
+ expect(jsxs).toBe(jsx)
149
+ })
150
+
151
+ it("函数组件被调用并展开", () => {
152
+ const Card = (props: { title: string }): Html => jsx("h1", { children: props.title })
153
+ expect(jsx(Card, { title: "深渊" }).toString()).toBe("<h1>深渊</h1>")
154
+ })
155
+
156
+ it("Fragment 只拼接子节点,不产生标签", () => {
157
+ expect(jsx(Fragment, { children: ["a", "b"] }).toString()).toBe("ab")
158
+ })
159
+
160
+ it("属性缺省时也能渲染", () => {
161
+ expect(jsx("hr").toString()).toBe("<hr>")
162
+ })
163
+ })
164
+
165
+ describe("defineTemplate", () => {
166
+ it("产出带 doctype 的完整页面与页面名", () => {
167
+ const Page = defineTemplate("demo", (props: { uid: string }) => jsx("html", { children: props.uid }))
168
+ expect(Page({ uid: "1" })).toEqual({ name: "demo", html: "<!DOCTYPE html><html>1</html>" })
169
+ })
170
+ })
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * 模块职责:`@yunzai-ng/jsx` 的公开入口
3
+ * 依赖方向:仅依赖 `@yunzai-ng/types`(取 `RenderablePage` 的形状)
4
+ * 生命周期:无状态
5
+ * 注意事项:本包独立于 `@yunzai-ng/core` 而非作为它的一个子路径导出。分层门禁把
6
+ * `@yunzai-ng/core/*` 的任意子路径判为"深引内核内部实现",为一个 JSX 运行时
7
+ * 给门禁开洞并不划算;独立成包后第三方插件也可单独依赖它而不必牵入整个内核。
8
+ */
9
+ import type { RenderablePage } from "@yunzai-ng/types"
10
+ import { children } from "./html.js"
11
+ import type { Child } from "./types.js"
12
+
13
+ export { Html, escape, raw, cx, style, children } from "./html.js"
14
+ export { attributes, element } from "./element.js"
15
+ export { Fragment, jsx, jsxs, jsxDEV } from "./jsx-runtime.js"
16
+ export type { ElementType } from "./jsx-runtime.js"
17
+ export type { Child, ClassDict, ClassValue, Component, HtmlAttributes, StyleDict } from "./types.js"
18
+
19
+ /** HTML5 文档类型声明;缺了它浏览器进入怪异模式,盒模型与行高全部改变 */
20
+ const DOCTYPE = "<!DOCTYPE html>"
21
+
22
+ /**
23
+ * 把一个组件封装成模板
24
+ *
25
+ * 产物可直接交给 `ctx.render()` / `e.renderReply()`:
26
+ *
27
+ * ```tsx
28
+ * export const Abyss = defineTemplate("abyss", (view: AbyssView) => <Page>…</Page>)
29
+ * await e.renderReply(Abyss(view))
30
+ * ```
31
+ *
32
+ * 由此模板数据的类型在调用处即被检查 —— 视图层改了字段名,编译当场失败,而不是等到
33
+ * 真机出图时得到一张空白图。模板同时退化成纯函数,可直接快照测试,不需要浏览器。
34
+ * @param name 页面名,用于日志、临时文件名与统计
35
+ * @param component 组件;返回整份文档(不含 doctype,由本函数补上)
36
+ * @returns 接受同样属性、返回可渲染页面的函数
37
+ */
38
+ export function defineTemplate<P>(name: string, component: (props: P) => Child): (props: P) => RenderablePage {
39
+ return (props: P): RenderablePage => ({ name, html: DOCTYPE + children(component(props)) })
40
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * 模块职责:TypeScript 自动 JSX 转换所要求的运行时入口(`jsx` / `jsxs` / `Fragment`)
3
+ * 依赖方向:仅依赖同包的 `html.ts` 与 `element.ts`
4
+ * 生命周期:无状态,纯函数
5
+ * 注意事项:`jsx` 与 `jsxs` 为同一实现。二者的区别在于 `jsxs` 保证 `children` 已是数组
6
+ * 且不会被改写,React 借此跳过 key 校验;此处不存在协调过程,无从利用该保证。
7
+ *
8
+ * `jsx-dev-runtime` 亦指向本文件:开发版签名多出 `isStaticChildren` / `source`
9
+ * / `self` 三个参数,全部用于 React 的错误提示,与产出的 HTML 无关。
10
+ */
11
+ import { children, Html, raw } from "./html.js"
12
+ import { element } from "./element.js"
13
+ import type { Child, HtmlAttributes } from "./types.js"
14
+
15
+ /**
16
+ * JSX 标签位置可出现的类型
17
+ *
18
+ * 函数组件的属性类型声明为 `never`:函数类型的参数按逆变判定,`never` 可被任意参数类型
19
+ * 接受,因此这是"任意一元函数"的写法。真正的属性类型检查由 TSX 在标签处完成,
20
+ * 与此签名无关。
21
+ */
22
+ export type ElementType = string | ((props: never) => Child)
23
+
24
+ /**
25
+ * 片段:把多个同级节点合成一个返回值
26
+ *
27
+ * 它就是一个普通函数组件,`<>…</>` 编译成 `jsx(Fragment, { children })` 后自然生效,
28
+ * 无需在 `jsx()` 里为它开特例。
29
+ * @param props 仅取 `children`
30
+ * @returns 拼接好的子节点
31
+ */
32
+ export function Fragment(props: { children?: Child }): Html {
33
+ return raw(children(props.children))
34
+ }
35
+
36
+ /**
37
+ * 创建一个节点
38
+ * @param type 标签名或函数组件
39
+ * @param props 属性;`children` 亦在其中
40
+ * @returns 渲染好的 HTML
41
+ * @throws 标签名或属性名不合法时抛出 TypeError
42
+ */
43
+ export function jsx(type: ElementType, props: Readonly<Record<string, unknown>> = {}): Html {
44
+ if (typeof type === "function") {
45
+ return raw(children((type as (props: Readonly<Record<string, unknown>>) => Child)(props)))
46
+ }
47
+ return raw(element(type, props))
48
+ }
49
+
50
+ export { jsx as jsxs, jsx as jsxDEV }
51
+
52
+ /**
53
+ * TSX 类型契约
54
+ *
55
+ * TypeScript 在 `jsxImportSource` 指向的模块上查找这个命名空间,用它判定标签合法性、
56
+ * 子节点属性名与元素类型。
57
+ */
58
+ export namespace JSX {
59
+ /** 一次渲染的产物 */
60
+ export interface Element extends Html {}
61
+
62
+ /** 告知 TypeScript 以 `children` 属性接收子节点 */
63
+ export interface ElementChildrenAttribute {
64
+ /** 属性名本身才是这里唯一有意义的信息,类型不参与判定 */
65
+ children: object
66
+ }
67
+
68
+ /**
69
+ * 内建标签
70
+ *
71
+ * 以索引签名放开全部标签:逐一枚举 HTML 元素及其专属属性需要数千行声明,而模板作者
72
+ * 真正需要的约束是 `class` / `style` / `children` 的形态,这三项已在 `HtmlAttributes`
73
+ * 中给出。拼错标签名的代价是浏览器把它当作未知内联元素,肉眼可见,不必由类型系统兜住。
74
+ */
75
+ export interface IntrinsicElements {
76
+ /** 任意标签 */
77
+ [tag: string]: HtmlAttributes
78
+ }
79
+
80
+ /** 所有元素隐含允许的属性 */
81
+ export interface IntrinsicAttributes {
82
+ /** 兼容 React 写法而接纳,渲染时丢弃 */
83
+ key?: string | number
84
+ }
85
+ }
package/src/types.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 模块职责:JSX 层的公共类型契约
3
+ * 依赖方向:仅依赖同包的 `html.ts`(取 `Html` 作为节点类型),不引任何工作区包
4
+ * 生命周期:纯类型,编译后不留痕迹
5
+ * 注意事项:属性名**不作 camelCase 转换**,一律按真实 HTML 书写(`stroke-width`、
6
+ * `tabindex`、`data-x`)。TSX 语法本身允许带连字符的属性名,无需转换即可书写;
7
+ * 而一旦引入转换,`viewBox` 这类 SVG 属性与 `data-camelCase` 就会出现两套
8
+ * 互相冲突的规则。唯二的例外是 `className` 与 `htmlFor` —— 它们是 React 为
9
+ * 规避 JS 保留字留下的历史包袱,作为别名接纳以降低迁移成本。
10
+ */
11
+ import type { Html } from "./html.js"
12
+
13
+ /** 以键为类名、值为是否启用的字典 */
14
+ export interface ClassDict {
15
+ /** 类名 → 是否启用 */
16
+ [name: string]: boolean | null | undefined
17
+ }
18
+
19
+ /** `class` 属性可接受的形态:字符串、条件字典、以及两者的任意嵌套数组 */
20
+ export type ClassValue = string | number | false | null | undefined | ClassDict | ClassValue[]
21
+
22
+ /** 内联样式字典;键为 CSS 属性名或 `--` 自定义属性 */
23
+ export interface StyleDict {
24
+ /** CSS 属性名 → 取值;数值不会被自动补单位,需要单位时自行拼接 */
25
+ [property: string]: string | number | null | undefined | false
26
+ }
27
+
28
+ /**
29
+ * 子节点
30
+ *
31
+ * `null` / `undefined` / 布尔值渲染为空串,使 `{cond && <div/>}` 与 `{value ?? null}`
32
+ * 这两种最常用的条件写法无需额外处理。
33
+ */
34
+ export type Child = Html | string | number | bigint | boolean | null | undefined | Child[]
35
+
36
+ /** 元素属性 */
37
+ export interface HtmlAttributes {
38
+ /** 类名 */
39
+ class?: ClassValue
40
+ /** 类名,`class` 的别名 */
41
+ className?: ClassValue
42
+ /** 内联样式 */
43
+ style?: string | StyleDict
44
+ /** 子节点 */
45
+ children?: Child
46
+ /** 兼容 React 写法而接纳,渲染时丢弃 —— 此处没有需要复用节点的协调过程 */
47
+ key?: string | number
48
+ /** 其余属性原样输出;值为 `true` 输出裸属性名,为 `false`/`null`/`undefined` 则整项省略 */
49
+ [attr: string]: unknown
50
+ }
51
+
52
+ /** 函数组件:接收属性、返回一段 HTML */
53
+ export type Component<P = Record<string, never>> = (props: P) => Html