@vobs/compiler 1.0.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/LICENSE +21 -0
- package/README.md +59 -0
- package/package.json +21 -0
- package/src/compile.test.ts +505 -0
- package/src/compile.ts +1156 -0
- package/src/i18n-extractor.ts +51 -0
- package/src/index.ts +19 -0
- package/src/plugin.ts +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vobs contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @vobs/compiler
|
|
2
|
+
|
|
3
|
+
The TSX compiler of the vobs framework. Transforms JSX into targeted DOM operations with real statement-level source maps and structured diagnostics.
|
|
4
|
+
|
|
5
|
+
No virtual DOM is emitted: attributes compile into independent effects (`bindText`, `bindAttribute`, `bindProperty`), control flow into `insertDynamic`, lists into `insertList`, and components into run-once calls wrapped in an `Owner`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @vobs/compiler typescript
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Used by [`@vobs/vite-plugin`](../vite-plugin); can also be driven programmatically.
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { compileWithSourceMap } from '@vobs/compiler'
|
|
19
|
+
|
|
20
|
+
const { code, map } = compileWithSourceMap(source, { filename: 'src/counter.tsx' })
|
|
21
|
+
// code: executable module referencing @vobs/vops runtime helpers
|
|
22
|
+
// map: standard v3 source map with statement-level mappings back to the original file
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Custom compiler plugins
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { createCompiler } from '@vobs/compiler'
|
|
29
|
+
|
|
30
|
+
const compiler = createCompiler({
|
|
31
|
+
plugins: [
|
|
32
|
+
{
|
|
33
|
+
name: 'my-plugin',
|
|
34
|
+
transform(context) {
|
|
35
|
+
// context.helperRef(name) — collision-safe reference to a runtime helper
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Plugins can analyze or rewrite the compile tree; `context.helperRef` guarantees injected helpers never collide with user bindings, and diagnostics flow through the same structured channel as the core compiler.
|
|
43
|
+
|
|
44
|
+
## Diagnostics
|
|
45
|
+
|
|
46
|
+
Unsupported JSX shapes fail with structured errors — code `VOBS_Cxxx`, source location, code frame, and a fix hint — instead of silently emitting broken output (e.g. member-expression tags like `<Foo.Bar>` used as a DOM tag).
|
|
47
|
+
|
|
48
|
+
## API
|
|
49
|
+
|
|
50
|
+
| Signature | Description |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `compile(source, options)` | Compiles TSX; throws a structured `VobsError` on error diagnostics. |
|
|
53
|
+
| `compileWithSourceMap(source, options)` | Same, plus a v3 source map with `sourcesContent`. |
|
|
54
|
+
| `createCompiler(options)` | Creates a reusable compiler instance with plugins. |
|
|
55
|
+
| `createI18nExtractor(options)` | Analysis plugin that extracts `t()` message keys during compilation. |
|
|
56
|
+
|
|
57
|
+
## Types
|
|
58
|
+
|
|
59
|
+
`CompilerPlugin`, `CompilerContext`, `CompilerOptions`, `CompileResult`, `VobsSourceMap`, `CompilerDiagnostic`, `VobsCompiler`, `I18nExtractor(Options)`.
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"license": "MIT",
|
|
3
|
+
"files": [
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
7
|
+
],
|
|
8
|
+
"name": "@vobs/compiler",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "src/index.ts",
|
|
12
|
+
"types": "src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./compile": "./src/compile.ts"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"typescript": "^5.9.3",
|
|
19
|
+
"@vobs/runtime": "1.0.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import ts from 'typescript'
|
|
3
|
+
import { compile, compileWithSourceMap, createCompiler, createI18nExtractor, type CompilerPlugin } from './index'
|
|
4
|
+
import { VobsError } from '@vobs/runtime'
|
|
5
|
+
|
|
6
|
+
describe('compiler', () => {
|
|
7
|
+
it('提取静态 i18n key,不提取动态 key', () => {
|
|
8
|
+
const extractor = createI18nExtractor()
|
|
9
|
+
compile(`
|
|
10
|
+
const title = t('page.title')
|
|
11
|
+
const label = i18n.t("common.label")
|
|
12
|
+
const dynamic = t(key)
|
|
13
|
+
`, { plugins: [extractor.plugin], filename: 'src/Page.tsx' })
|
|
14
|
+
|
|
15
|
+
expect(extractor.getKeys()).toEqual(['common.label', 'page.title'])
|
|
16
|
+
})
|
|
17
|
+
it('解析 JSX', () => {
|
|
18
|
+
const code = `const el = <div>hello</div>`
|
|
19
|
+
|
|
20
|
+
const result = compile(code)
|
|
21
|
+
|
|
22
|
+
expect(result).toContain('div')
|
|
23
|
+
expect(result).toContain('hello')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('编译 JSX 为 DOM 渲染调用', () => {
|
|
27
|
+
const code = `const el = <div>hello</div>`
|
|
28
|
+
|
|
29
|
+
const result = compile(code)
|
|
30
|
+
|
|
31
|
+
expect(result).toContain('createElement')
|
|
32
|
+
expect(result).toContain('createText')
|
|
33
|
+
expect(result).toContain('insertBefore')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('编译函数组件', () => {
|
|
37
|
+
const code = `
|
|
38
|
+
function Counter() {
|
|
39
|
+
const count = state(0)
|
|
40
|
+
return <div>hello</div>
|
|
41
|
+
}
|
|
42
|
+
`
|
|
43
|
+
|
|
44
|
+
const result = compile(code)
|
|
45
|
+
|
|
46
|
+
expect(result).toContain('function Counter')
|
|
47
|
+
expect(result).toContain('createElement')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('编译事件绑定', () => {
|
|
51
|
+
const code = `
|
|
52
|
+
const el = <button onClick={() => { console.log('clicked') }}>点击</button>
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
const result = compile(code)
|
|
56
|
+
|
|
57
|
+
expect(result).toContain('addEventListener')
|
|
58
|
+
expect(result).toContain('click')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('将静态属性合并为一次 setStaticProps 调用', () => {
|
|
62
|
+
const result = compile(`const el = <input className="field" disabled id="name" />`)
|
|
63
|
+
expect(result).toContain('setStaticProps')
|
|
64
|
+
expect(result).not.toContain('setAttribute(_el')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('将动态文本和属性编译为 getter 绑定', () => {
|
|
68
|
+
const result = compile(`const el = <input value={name.value}>{name.value}</input>`)
|
|
69
|
+
|
|
70
|
+
expect(result).toContain('bindProperty')
|
|
71
|
+
expect(result).toContain('() => name.value')
|
|
72
|
+
expect(result).toContain('bindText')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('将首字母大写的标签编译为组件实例', () => {
|
|
76
|
+
const result = compile(`const el = <Counter value={count.value} />`)
|
|
77
|
+
|
|
78
|
+
expect(result).toContain('createComponent(resolveComponent(Counter')
|
|
79
|
+
expect(result).toContain('get value()')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('保留非 JSX 声明的初始化参数', () => {
|
|
83
|
+
const result = compile(`function Counter() { const count = state(0); return <span>{count.value}</span> }`)
|
|
84
|
+
|
|
85
|
+
expect(result).toContain('state(0)')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('编译条件 JSX 为动态块', () => {
|
|
89
|
+
const result = compile(`const el = <div>{show.value && <span>visible</span>}</div>`)
|
|
90
|
+
|
|
91
|
+
expect(result).toContain('insertDynamic')
|
|
92
|
+
expect(result).toContain('show.value ?')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('编译 JSX map 为 keyed 列表', () => {
|
|
96
|
+
const result = compile(`const el = <ul>{items.value.map(item => <li key={item.id}>{item.name}</li>)}</ul>`)
|
|
97
|
+
|
|
98
|
+
expect(result).toContain('insertList')
|
|
99
|
+
expect(result).toContain('() => items.value')
|
|
100
|
+
expect(result).toContain('(item) => item.id')
|
|
101
|
+
expect(result).not.toContain('setAttribute(_el2, "key"')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('在 JSX 转换前执行编译器插件的分析、程序与节点钩子', () => {
|
|
105
|
+
const filenames: string[] = []
|
|
106
|
+
const plugin: CompilerPlugin = {
|
|
107
|
+
name: 'replace-label',
|
|
108
|
+
analyze(program, context) {
|
|
109
|
+
filenames.push(`${context.filename}:${program.fileName}`)
|
|
110
|
+
context.addRuntimeImport('pluginRuntime')
|
|
111
|
+
},
|
|
112
|
+
transform: {
|
|
113
|
+
program(program, context) {
|
|
114
|
+
const declaration = context.factory.createVariableStatement(
|
|
115
|
+
undefined,
|
|
116
|
+
context.factory.createVariableDeclarationList([
|
|
117
|
+
context.factory.createVariableDeclaration(
|
|
118
|
+
'enabled',
|
|
119
|
+
undefined,
|
|
120
|
+
undefined,
|
|
121
|
+
context.factory.createTrue()
|
|
122
|
+
)
|
|
123
|
+
], ts.NodeFlags.Const)
|
|
124
|
+
)
|
|
125
|
+
return context.factory.updateSourceFile(program, [declaration, ...program.statements])
|
|
126
|
+
},
|
|
127
|
+
node(node, context) {
|
|
128
|
+
if (ts.isStringLiteral(node) && node.text === 'before') {
|
|
129
|
+
return context.factory.createStringLiteral('after')
|
|
130
|
+
}
|
|
131
|
+
return node
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const result = compile(`const label = 'before'; const el = <div>{label}</div>`, {
|
|
137
|
+
filename: 'src/App.tsx',
|
|
138
|
+
plugins: [plugin]
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
expect(filenames).toEqual(['src/App.tsx:src/App.tsx'])
|
|
142
|
+
expect(result).toContain('pluginRuntime')
|
|
143
|
+
expect(result).toContain('const enabled = true;')
|
|
144
|
+
expect(result).toContain('const label = "after";')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('支持 createCompiler 与旧版 transformNode 钩子', () => {
|
|
148
|
+
const compiler = createCompiler({
|
|
149
|
+
plugins: [{
|
|
150
|
+
name: 'legacy-transform',
|
|
151
|
+
transformNode(node, context) {
|
|
152
|
+
if (ts.isIdentifier(node) && node.text === 'message') {
|
|
153
|
+
return context.factory.createIdentifier('title')
|
|
154
|
+
}
|
|
155
|
+
return node
|
|
156
|
+
}
|
|
157
|
+
}]
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
expect(compiler.compile(`const el = <span>{message}</span>`)).toContain('() => title')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('拒绝重复命名的编译器插件', () => {
|
|
164
|
+
const plugins: CompilerPlugin[] = [{ name: 'duplicate' }, { name: 'duplicate' }]
|
|
165
|
+
expect(() => compile(`const el = <div />`, { plugins })).toThrow('重复插件')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('只导入生成代码实际使用的运行时 helper', () => {
|
|
169
|
+
const result = compile(`const el = <div>hello</div>`)
|
|
170
|
+
|
|
171
|
+
expect(result).toContain('createElement')
|
|
172
|
+
expect(result).toContain('createText')
|
|
173
|
+
expect(result).toContain('insertBefore')
|
|
174
|
+
expect(result).not.toContain('bindAttribute')
|
|
175
|
+
expect(result).not.toContain('bindText')
|
|
176
|
+
expect(result).not.toContain('insertDynamic')
|
|
177
|
+
expect(result).not.toContain('insertList')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('编译 Fragment 为无包装节点范围', () => {
|
|
181
|
+
const result = compile(`const el = <><span>one</span><span>two</span></>`)
|
|
182
|
+
|
|
183
|
+
expect(result).toContain('createFragment')
|
|
184
|
+
expect(result).toContain('insertBefore(_fragmentParent')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('支持显式 Fragment 和 Vobs.Fragment 写法', () => {
|
|
188
|
+
const explicit = compile(`const el = <Fragment><span>one</span><span>two</span></Fragment>`)
|
|
189
|
+
const namespaced = compile(`const el = <Vobs.Fragment><span>one</span><span>two</span></Vobs.Fragment>`)
|
|
190
|
+
expect(explicit).toContain('createFragment')
|
|
191
|
+
expect(namespaced).toContain('createFragment')
|
|
192
|
+
expect(namespaced).not.toContain('createElement("Vobs.Fragment")')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('将 JSX 动态表达式统一编译为可归一化的动态节点', () => {
|
|
196
|
+
const result = compile(`const content = <strong>ready</strong>; const el = <div>{content}</div>`)
|
|
197
|
+
expect(result).toContain('insertDynamicValue')
|
|
198
|
+
expect(result).toContain('() => content')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('支持 DOM spread 和对象样式', () => {
|
|
202
|
+
const result = compile(`const props = { className: 'card' }; const el = <div {...props} style={{ backgroundColor: 'red' }} />`)
|
|
203
|
+
expect(result).toContain('spreadProps')
|
|
204
|
+
expect(result).toContain('bindAttribute')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('编译 ResourceBoundary 为范围内的资源边界指令', () => {
|
|
208
|
+
const result = compile(`
|
|
209
|
+
const el = <ResourceBoundary resource={users} loading={<p>loading</p>}>
|
|
210
|
+
<p>ready</p>
|
|
211
|
+
</ResourceBoundary>
|
|
212
|
+
`)
|
|
213
|
+
|
|
214
|
+
expect(result).toContain('from "@vobs/resource"')
|
|
215
|
+
expect(result).toContain('insertResourceBoundary')
|
|
216
|
+
expect(result).toContain('createFragment')
|
|
217
|
+
expect(result).toContain('loading: () =>')
|
|
218
|
+
expect(result).not.toContain('createComponent(ResourceBoundary')
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('编译 ErrorBoundary 为范围内的错误边界指令', () => {
|
|
222
|
+
const result = compile(`
|
|
223
|
+
const el = <ErrorBoundary fallback={(error, retry) => <button onClick={retry}>{error.message}</button>}>
|
|
224
|
+
<p>ready</p>
|
|
225
|
+
</ErrorBoundary>
|
|
226
|
+
`)
|
|
227
|
+
|
|
228
|
+
expect(result).toContain('insertErrorBoundary')
|
|
229
|
+
expect(result).toContain('createFragment')
|
|
230
|
+
expect(result).not.toContain('createComponent(ErrorBoundary')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('不为没有运行时调用的源码注入 import', () => {
|
|
234
|
+
const result = compile(`export const version = '0.1.0'`)
|
|
235
|
+
|
|
236
|
+
expect(result).not.toContain('@vobs/vobs')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('源文件已有同名导入时注入别名 import', () => {
|
|
240
|
+
const result = compile(`
|
|
241
|
+
import { createElement } from './shim'
|
|
242
|
+
export const tag = createElement('span')
|
|
243
|
+
const el = <div>hello</div>
|
|
244
|
+
`)
|
|
245
|
+
|
|
246
|
+
// 用户导入与调用保持不变,编译器 helper 走别名,不再产生重复声明
|
|
247
|
+
expect(result).toContain('import { createElement } from "./shim"')
|
|
248
|
+
expect(result).toContain('createElement as _vobs_createElement')
|
|
249
|
+
expect(result).toContain('from "@vobs/vobs"')
|
|
250
|
+
expect(result).toContain('_vobs_createElement("div")')
|
|
251
|
+
// 用户自己的调用原样保留(保留原始引号风格)
|
|
252
|
+
expect(result).toContain("createElement('span')")
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('源文件本地声明与 helper 同名时注入别名 import', () => {
|
|
256
|
+
const result = compile(`
|
|
257
|
+
const createText = (value: string) => value
|
|
258
|
+
const el = <div>hello</div>
|
|
259
|
+
`)
|
|
260
|
+
|
|
261
|
+
expect(result).toContain('createText as _vobs_createText')
|
|
262
|
+
expect(result).toContain('from "@vobs/vobs"')
|
|
263
|
+
expect(result).toContain('insertBefore(_el0, _vobs_createText("hello")')
|
|
264
|
+
// 用户本地声明不受影响
|
|
265
|
+
expect(result).toContain('const createText = (value: string) => value')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('嵌套作用域的同名绑定也触发别名 import', () => {
|
|
269
|
+
const result = compile(`
|
|
270
|
+
export function Page() {
|
|
271
|
+
const insertList = (items: unknown[]) => items.length
|
|
272
|
+
const el = <ul>{[1, 2].map(item => <li key={item}>{item}</li>)}</ul>
|
|
273
|
+
return { el, count: insertList([1, 2]) }
|
|
274
|
+
}
|
|
275
|
+
`)
|
|
276
|
+
|
|
277
|
+
expect(result).toContain('insertList as _vobs_insertList')
|
|
278
|
+
expect(result).toContain('from "@vobs/vobs"')
|
|
279
|
+
expect(result).toContain('_vobs_insertList(')
|
|
280
|
+
expect(result).toContain('const insertList = (items: unknown[]) => items.length')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('同名绑定触发别名时保留别名一致性(同一 helper 只注入一次)', () => {
|
|
284
|
+
const result = compile(`
|
|
285
|
+
const createElement = String
|
|
286
|
+
const el = <div><span>one</span></div>
|
|
287
|
+
`)
|
|
288
|
+
|
|
289
|
+
expect(result.match(/_vobs_createElement/g)?.length).toBeGreaterThanOrEqual(2)
|
|
290
|
+
// import 别名声明只出现一次,产物引用与之一致
|
|
291
|
+
expect(result.match(/ as _vobs_createElement/g)?.length).toBe(1)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('生成的临时变量避开用户已声明的名称', () => {
|
|
295
|
+
const result = compile(`
|
|
296
|
+
const _el0 = 'reserved'
|
|
297
|
+
const el = <div>hello</div>
|
|
298
|
+
`)
|
|
299
|
+
|
|
300
|
+
// _el0 被用户占用,编译产物必须改用下一个可用名称
|
|
301
|
+
expect(result).toContain('const _el1 = createElement("div")')
|
|
302
|
+
expect(result).not.toContain('const _el0 = createElement')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('组件 children 中的 JSX 表达式保持惰性并递归编译', () => {
|
|
306
|
+
const result = compile(`
|
|
307
|
+
const el = <Layout>{show.value && <Panel>{title.value}</Panel>}</Layout>
|
|
308
|
+
`)
|
|
309
|
+
|
|
310
|
+
expect(result).toContain('get children()')
|
|
311
|
+
expect(result).toContain('show.value && createComponent(resolveComponent(Panel')
|
|
312
|
+
expect(result).toContain('return title.value')
|
|
313
|
+
expect(result).not.toContain('<Panel>')
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('组件属性中的 JSX slot 保持惰性并递归编译', () => {
|
|
317
|
+
const result = compile(`
|
|
318
|
+
const el = <Button icon={<Icon name="search" />}>Search</Button>
|
|
319
|
+
`)
|
|
320
|
+
|
|
321
|
+
expect(result).toContain('get icon()')
|
|
322
|
+
expect(result).toContain('createComponent(resolveComponent(Icon')
|
|
323
|
+
expect(result).not.toContain('<Icon')
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('动态 DOM property 使用 bindProperty,并为组件生成源码位置', () => {
|
|
327
|
+
const result = compile(`
|
|
328
|
+
const el = <Editor><input disabled={locked.value} value={text.value} /></Editor>
|
|
329
|
+
`, { filename: 'src/editor.tsx' })
|
|
330
|
+
|
|
331
|
+
expect(result).toContain('bindProperty')
|
|
332
|
+
expect(result).toContain('"disabled"')
|
|
333
|
+
expect(result).toContain('"value"')
|
|
334
|
+
expect(result).toContain('file: "src/editor.tsx"')
|
|
335
|
+
expect(result).toContain('line: 2')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('生成包含原始内容的 Source Map', () => {
|
|
339
|
+
const result = compileWithSourceMap(`const el = <div>hello</div>`, { filename: 'src/App.tsx' })
|
|
340
|
+
|
|
341
|
+
expect(result.map.version).toBe(3)
|
|
342
|
+
expect(result.map.sources).toEqual(['src/App.tsx'])
|
|
343
|
+
expect(result.map.sourcesContent).toEqual(['const el = <div>hello</div>'])
|
|
344
|
+
expect(result.map.mappings.split(';').length).toBe(result.code.split('\n').length)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('Source Map 映射真实源码位置(语句级)', () => {
|
|
348
|
+
const source = [
|
|
349
|
+
`import { state } from '@vobs/vobs'`,
|
|
350
|
+
``,
|
|
351
|
+
`export function Counter() {`,
|
|
352
|
+
` const count = state(0)`,
|
|
353
|
+
` return (`,
|
|
354
|
+
` <div class="c" onClick={() => count.value++}>`,
|
|
355
|
+
` {count.value}`,
|
|
356
|
+
` </div>`,
|
|
357
|
+
` )`,
|
|
358
|
+
`}`
|
|
359
|
+
].join('\n')
|
|
360
|
+
const { code, map } = compileWithSourceMap(source, { filename: 'src/Counter.tsx' })
|
|
361
|
+
const codeLines = code.split('\n')
|
|
362
|
+
const segments = decodeMappings(map.mappings)
|
|
363
|
+
expect(segments.length).toBeGreaterThan(0)
|
|
364
|
+
|
|
365
|
+
// 生成行号必须落在产物范围内
|
|
366
|
+
for (const segment of segments) {
|
|
367
|
+
expect(segment.genLine).toBeLessThan(codeLines.length)
|
|
368
|
+
expect(segment.srcLine).toBeLessThan(source.split('\n').length)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 用户自己的语句 1:1 映射
|
|
372
|
+
expect(findSourceLine(segments, codeLines, 'const count')).toBe(3)
|
|
373
|
+
// JSX 元素与属性映射回标签所在行(含列号)
|
|
374
|
+
expect(findSourceLine(segments, codeLines, 'createElement("div")')).toBe(5)
|
|
375
|
+
expect(findSourceLine(segments, codeLines, 'addEventListener(')).toBe(5)
|
|
376
|
+
// IIFE 内的文本绑定映射回表达式子节点所在行
|
|
377
|
+
expect(findSourceLine(segments, codeLines, 'bindText(')).toBe(6)
|
|
378
|
+
// 注入的 import 不产生错误映射
|
|
379
|
+
const importLine = codeLines.findIndex(line => line.startsWith('import { createElement'))
|
|
380
|
+
expect(segments.some(segment => segment.genLine === importLine)).toBe(false)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('Source Map 覆盖嵌套函数体内的列表语句', () => {
|
|
384
|
+
const source = [
|
|
385
|
+
`const items = []`,
|
|
386
|
+
`function List() {`,
|
|
387
|
+
` return <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>`,
|
|
388
|
+
`}`
|
|
389
|
+
].join('\n')
|
|
390
|
+
const { code, map } = compileWithSourceMap(source, { filename: 'src/List.tsx' })
|
|
391
|
+
const codeLines = code.split('\n')
|
|
392
|
+
const segments = decodeMappings(map.mappings)
|
|
393
|
+
expect(findSourceLine(segments, codeLines, 'insertList(')).toBe(2)
|
|
394
|
+
expect(findSourceLine(segments, codeLines, 'createElement("li")')).toBe(2)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('返回结构化编译诊断,并在 compile 中抛出 VobsError', () => {
|
|
398
|
+
const result = compileWithSourceMap('const el = <div>', { filename: 'src/Broken.tsx' })
|
|
399
|
+
expect(result.diagnostics.length).toBeGreaterThan(0)
|
|
400
|
+
expect(result.diagnostics[0]).toMatchObject({
|
|
401
|
+
severity: 'error',
|
|
402
|
+
location: { file: 'src/Broken.tsx' }
|
|
403
|
+
})
|
|
404
|
+
expect(() => compile('const el = <div>', { filename: 'src/Broken.tsx' })).toThrow(VobsError)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('成员表达式标签产生 VOBS_C101 诊断', () => {
|
|
408
|
+
const result = compileWithSourceMap(`const el = <Foo.Bar />`, { filename: 'src/Member.tsx' })
|
|
409
|
+
const diagnostic = result.diagnostics.find(item => item.code === 'VOBS_C101')
|
|
410
|
+
|
|
411
|
+
expect(diagnostic).toBeDefined()
|
|
412
|
+
expect(diagnostic?.severity).toBe('error')
|
|
413
|
+
expect(diagnostic?.message).toContain('Foo.Bar')
|
|
414
|
+
expect(diagnostic?.location).toMatchObject({ file: 'src/Member.tsx', line: 1 })
|
|
415
|
+
expect(diagnostic?.codeFrame).toContain('Foo.Bar')
|
|
416
|
+
expect(diagnostic?.fix).toContain('<Component />')
|
|
417
|
+
expect(() => compile(`const el = <Foo.Bar />`)).toThrow(VobsError)
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('命名空间标签产生 VOBS_C101 诊断', () => {
|
|
421
|
+
const result = compileWithSourceMap(`const el = <svg:rect width="1" />`)
|
|
422
|
+
const diagnostic = result.diagnostics.find(item => item.code === 'VOBS_C101')
|
|
423
|
+
|
|
424
|
+
expect(diagnostic).toBeDefined()
|
|
425
|
+
expect(diagnostic?.message).toContain('svg:rect')
|
|
426
|
+
expect(diagnostic?.message).toContain('命名空间')
|
|
427
|
+
expect(() => compile(`const el = <svg:rect width="1" />`)).toThrow(VobsError)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('小写成员表达式标签同样不被放行', () => {
|
|
431
|
+
const result = compileWithSourceMap(`const el = <foo.bar />`)
|
|
432
|
+
expect(result.diagnostics.find(item => item.code === 'VOBS_C101')).toBeDefined()
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('Fragment 成员表达式形态仍受支持', () => {
|
|
436
|
+
const result = compileWithSourceMap(`const el = <Vobs.Fragment><span>one</span></Vobs.Fragment>`)
|
|
437
|
+
|
|
438
|
+
expect(result.diagnostics.filter(item => item.severity === 'error')).toHaveLength(0)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('诊断信息随 compileWithSourceMap 稳定返回(不改变产物结构)', () => {
|
|
442
|
+
const result = compileWithSourceMap(`const el = <Foo.Bar />`)
|
|
443
|
+
|
|
444
|
+
// 报诊断的同时产物仍可打印,便于 source map 配对与调试
|
|
445
|
+
expect(result.code).toContain('createElement("Foo.Bar")')
|
|
446
|
+
expect(result.map).toBeDefined()
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
451
|
+
const CHAR_TO_INT = new Map([...BASE64].map((char, index) => [char, index] as const))
|
|
452
|
+
|
|
453
|
+
interface MappingSegment {
|
|
454
|
+
genLine: number
|
|
455
|
+
genCol: number
|
|
456
|
+
srcLine: number
|
|
457
|
+
srcCol: number
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function decodeVlq(segment: string): number[] {
|
|
461
|
+
const values: number[] = []
|
|
462
|
+
let shift = 0
|
|
463
|
+
let value = 0
|
|
464
|
+
for (const char of segment) {
|
|
465
|
+
const digit = CHAR_TO_INT.get(char)
|
|
466
|
+
if (digit === undefined) throw new Error(`invalid VLQ char: ${char}`)
|
|
467
|
+
value += (digit & 31) << shift
|
|
468
|
+
if (digit & 32) {
|
|
469
|
+
shift += 5
|
|
470
|
+
} else {
|
|
471
|
+
const negate = value & 1
|
|
472
|
+
value >>= 1
|
|
473
|
+
values.push(negate ? -value : value)
|
|
474
|
+
shift = 0
|
|
475
|
+
value = 0
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return values
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function decodeMappings(mappings: string): MappingSegment[] {
|
|
482
|
+
const segments: MappingSegment[] = []
|
|
483
|
+
let srcLine = 0
|
|
484
|
+
let srcCol = 0
|
|
485
|
+
mappings.split(';').forEach((line, genLine) => {
|
|
486
|
+
let genCol = 0
|
|
487
|
+
for (const segment of line.split(',')) {
|
|
488
|
+
if (!segment) continue
|
|
489
|
+
const [genColDelta, , srcLineDelta, srcColDelta] = decodeVlq(segment)
|
|
490
|
+
genCol += genColDelta
|
|
491
|
+
srcLine += srcLineDelta
|
|
492
|
+
srcCol += srcColDelta
|
|
493
|
+
segments.push({ genLine, genCol, srcLine, srcCol })
|
|
494
|
+
}
|
|
495
|
+
})
|
|
496
|
+
return segments
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function findSourceLine(segments: MappingSegment[], codeLines: string[], needle: string): number {
|
|
500
|
+
const genLine = codeLines.findIndex(line => line.includes(needle))
|
|
501
|
+
expect(genLine).toBeGreaterThan(-1)
|
|
502
|
+
const mapping = segments.find(segment => segment.genLine === genLine)
|
|
503
|
+
expect(mapping).toBeDefined()
|
|
504
|
+
return mapping!.srcLine
|
|
505
|
+
}
|