@vobs/vite-plugin 0.3.0 → 1.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/LICENSE +1 -1
- package/README.md +43 -0
- package/package.json +13 -46
- package/src/html-component.ts +137 -0
- package/src/index.test.ts +195 -0
- package/src/index.ts +108 -0
- package/dist/client.d.ts +0 -48
- package/dist/client.js +0 -5
- package/dist/index.d.ts +0 -54
- package/dist/index.js +0 -1127
package/LICENSE
CHANGED
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# @vobs/vite-plugin
|
|
2
|
+
|
|
3
|
+
Vite plugin that compiles Vobs TSX, turns imported HTML files into components, and wires up HMR and i18n key extraction.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vobs/vite-plugin
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// vite.config.ts
|
|
15
|
+
import { defineConfig } from 'vite'
|
|
16
|
+
import { vobsPlugin } from '@vobs/vite-plugin'
|
|
17
|
+
|
|
18
|
+
const collectedKeys = new Set<string>()
|
|
19
|
+
|
|
20
|
+
export default defineConfig({
|
|
21
|
+
plugins: [
|
|
22
|
+
vobsPlugin({
|
|
23
|
+
html: true,
|
|
24
|
+
extractI18n: key => collectedKeys.add(key)
|
|
25
|
+
})
|
|
26
|
+
]
|
|
27
|
+
})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## API
|
|
31
|
+
|
|
32
|
+
| Signature | Description |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| `vobsPlugin(options?)` | Create the plugin; compiles matched modules with `@vobs/compiler`, returns source maps, and throws compiler errors as `VobsError` with code, location and code frame. |
|
|
35
|
+
| `options.include` | `RegExp` selecting modules to compile; defaults to `/\.tsx(?:$|\?)/`. |
|
|
36
|
+
| `options.compiler` | Extra `CompileOptions` forwarded to the compiler (e.g. custom `plugins`). |
|
|
37
|
+
| `options.hmr` | Inject `import.meta.hot` accept/dispose handlers; defaults to on in dev and off for production builds. |
|
|
38
|
+
| `options.extractI18n` | Callback `(key, filename)` receiving each static translation key collected from `t('...')` and `i18n.t('...')` calls. |
|
|
39
|
+
| `options.html` | Compile imported `.html`/`.htm` modules into Vobs components (scripts, inline event attributes and dangerous URLs are rejected); `false` disables, or pass `{ extensions }` for other file types. |
|
|
40
|
+
|
|
41
|
+
## Types
|
|
42
|
+
|
|
43
|
+
VobsVitePluginOptions
|
package/package.json
CHANGED
|
@@ -1,54 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "@vobs/vite-plugin",
|
|
3
|
-
"version": "0.3.0",
|
|
4
|
-
"description": "Vite integration, HTML transforms and file routes for vobs.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"publishConfig": {
|
|
7
|
-
"access": "public"
|
|
8
|
-
},
|
|
9
2
|
"license": "MIT",
|
|
10
|
-
"author": "vobsjs",
|
|
11
|
-
"repository": {
|
|
12
|
-
"type": "git",
|
|
13
|
-
"url": "git+https://github.com/vobsjs/vobs.git",
|
|
14
|
-
"directory": "plugins/vite"
|
|
15
|
-
},
|
|
16
|
-
"bugs": {
|
|
17
|
-
"url": "https://github.com/vobsjs/vobs/issues"
|
|
18
|
-
},
|
|
19
|
-
"homepage": "https://github.com/vobsjs/vobs#readme",
|
|
20
|
-
"dependencies": {
|
|
21
|
-
"@vobs/router": "0.3.0",
|
|
22
|
-
"@vobs/compiler-dom": "0.3.0",
|
|
23
|
-
"@vobs/runtime-core": "0.3.0",
|
|
24
|
-
"@vobs/runtime-dom": "0.3.0",
|
|
25
|
-
"@vobs/icons": "0.3.0"
|
|
26
|
-
},
|
|
27
|
-
"peerDependencies": {
|
|
28
|
-
"vite": "^8.0.0"
|
|
29
|
-
},
|
|
30
|
-
"devDependencies": {
|
|
31
|
-
"vite": "^8.0.0"
|
|
32
|
-
},
|
|
33
3
|
"files": [
|
|
34
|
-
"
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
35
7
|
],
|
|
8
|
+
"name": "@vobs/vite-plugin",
|
|
9
|
+
"version": "1.1.0",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "src/index.ts",
|
|
12
|
+
"types": "src/index.ts",
|
|
36
13
|
"exports": {
|
|
37
|
-
".":
|
|
38
|
-
"types": "./dist/index.d.ts",
|
|
39
|
-
"import": "./dist/index.js"
|
|
40
|
-
},
|
|
41
|
-
"./client": {
|
|
42
|
-
"types": "./dist/client.d.ts",
|
|
43
|
-
"import": "./dist/client.js"
|
|
44
|
-
},
|
|
45
|
-
"./package.json": "./package.json"
|
|
14
|
+
".": "./src/index.ts"
|
|
46
15
|
},
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"engines": {
|
|
52
|
-
"node": ">=22.12.0"
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"parse5": "^8.0.1",
|
|
18
|
+
"@vobs/runtime": "1.1.0",
|
|
19
|
+
"@vobs/compiler": "1.1.0"
|
|
53
20
|
}
|
|
54
21
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { parseFragment, type DefaultTreeAdapterMap, type DefaultTreeAdapterTypes } from 'parse5'
|
|
2
|
+
|
|
3
|
+
type HtmlNode = DefaultTreeAdapterTypes.Node
|
|
4
|
+
type HtmlDocumentFragment = DefaultTreeAdapterTypes.DocumentFragment
|
|
5
|
+
type HtmlElement = DefaultTreeAdapterTypes.Element
|
|
6
|
+
|
|
7
|
+
const blockedTags = new Set(['script', 'iframe', 'object', 'embed', 'base', 'meta', 'link', 'style'])
|
|
8
|
+
const blockedAttributes = /^(?:on[a-z]+|srcdoc|style)$/iu
|
|
9
|
+
const urlAttributes = new Set(['href', 'src', 'action', 'formaction', 'poster', 'xlink:href'])
|
|
10
|
+
const safeProtocols = new Set(['http:', 'https:', 'mailto:', 'tel:'])
|
|
11
|
+
const directivePattern = /^data-vobs-(text|slot|on|bind|prop)(?:-(.+))?$/iu
|
|
12
|
+
const safePropertyNames = new Set([
|
|
13
|
+
'value', 'checked', 'selected', 'disabled', 'multiple', 'readonly', 'required',
|
|
14
|
+
'autofocus', 'hidden', 'tabindex'
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
export interface HtmlComponentOptions {
|
|
18
|
+
readonly filename?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Converts a trusted static HTML fragment to Vobs runtime node creation code. */
|
|
22
|
+
export function compileHtmlComponent(source: string, options: HtmlComponentOptions = {}): string {
|
|
23
|
+
const fragment = parseFragment(source)
|
|
24
|
+
const body = compileChildren(fragment, options.filename ?? 'component.html')
|
|
25
|
+
const result = body.length === 1 ? body[0] : `createFragment((parent, anchor) => {${body.map(code => `insertBefore(parent, ${code}, anchor);`).join('')}})`
|
|
26
|
+
|
|
27
|
+
return `import { addEventListener, bindAttribute, bindProperty, bindText, createElement, createFragment, createText, insertBefore, insertDynamic, setAttribute } from '@vobs/vobs';\nfunction resolveHtmlSlot(value) { const resolved = typeof value === 'function' ? value() : value; if (resolved === undefined || resolved === null || resolved === false) return null; if (typeof resolved === 'string' || typeof resolved === 'number') return createText(String(resolved)); if (Array.isArray(resolved)) return createFragment((parent, anchor) => { for (const item of resolved) { const child = resolveHtmlSlot(item); if (child) insertBefore(parent, child, anchor); } }); return resolved; }\nfunction sanitizeHtmlAttribute(name, value) { const stringValue = String(value ?? ''); if (!['href', 'src', 'action', 'formaction', 'poster', 'xlink:href'].includes(name.toLowerCase())) return stringValue; const normalized = stringValue.trim().toLowerCase(); if (normalized.startsWith('#') || normalized.startsWith('/') || normalized.startsWith('./') || normalized.startsWith('../')) return stringValue; try { const protocol = new URL(normalized, 'https://vobs.invalid/').protocol; return ['http:', 'https:', 'mailto:', 'tel:'].includes(protocol) ? stringValue : ''; } catch { return ''; } }\nexport default function HtmlComponent(props = {}) { return ${result ?? "createFragment(() => {})"}; }`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function compileChildren(parent: HtmlDocumentFragment | HtmlElement, filename: string): string[] {
|
|
31
|
+
return parent.childNodes.flatMap(node => compileNode(node, filename))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function compileNode(node: HtmlNode, filename: string): string[] {
|
|
35
|
+
if (node.nodeName === '#text') {
|
|
36
|
+
const value = (node as DefaultTreeAdapterMap['textNode']).value
|
|
37
|
+
return value ? [`createText(${JSON.stringify(value)})`] : []
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (node.nodeName === '#comment') return []
|
|
41
|
+
if (node.nodeName !== '#document-fragment' && !isElement(node)) return []
|
|
42
|
+
|
|
43
|
+
if (isElement(node)) {
|
|
44
|
+
const tag = node.tagName.toLowerCase()
|
|
45
|
+
if (blockedTags.has(tag)) throw new Error(`Vobs HTML component: 禁止使用 <${tag}> (${filename})`)
|
|
46
|
+
|
|
47
|
+
const children = compileChildren(node, filename)
|
|
48
|
+
const statements = [`const element = createElement(${JSON.stringify(tag)})`]
|
|
49
|
+
const dynamicText = [] as string[]
|
|
50
|
+
for (const attribute of node.attrs) {
|
|
51
|
+
const name = attribute.name.toLowerCase()
|
|
52
|
+
const value = attribute.value
|
|
53
|
+
const directive = parseDirective(name, value, filename)
|
|
54
|
+
if (directive) {
|
|
55
|
+
if (directive.kind === 'text') {
|
|
56
|
+
dynamicText.push(directive.prop)
|
|
57
|
+
} else if (directive.kind === 'slot') {
|
|
58
|
+
statements.push(`insertDynamic(element, null, () => resolveHtmlSlot(props[${JSON.stringify(directive.prop)}]))`)
|
|
59
|
+
} else if (directive.kind === 'event') {
|
|
60
|
+
statements.push(`addEventListener(element, ${JSON.stringify(directive.name)}, (event) => { const handler = props[${JSON.stringify(directive.prop)}]; if (typeof handler === 'function') handler(event); })`)
|
|
61
|
+
} else if (directive.kind === 'attribute') {
|
|
62
|
+
statements.push(`bindAttribute(element, ${JSON.stringify(directive.name)}, () => sanitizeHtmlAttribute(${JSON.stringify(directive.name)}, props[${JSON.stringify(directive.prop)}] ?? ''))`)
|
|
63
|
+
} else if (directive.kind === 'property') {
|
|
64
|
+
statements.push(`bindProperty(element, ${JSON.stringify(directive.name)}, () => props[${JSON.stringify(directive.prop)}])`)
|
|
65
|
+
}
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
if (blockedAttributes.test(name)) throw new Error(`Vobs HTML component: 禁止使用危险属性 ${attribute.name} (${filename})`)
|
|
69
|
+
if (urlAttributes.has(name) && !isSafeUrl(value)) {
|
|
70
|
+
throw new Error(`Vobs HTML component: 禁止使用危险 URL 属性 ${attribute.name} (${filename})`)
|
|
71
|
+
}
|
|
72
|
+
statements.push(`setAttribute(element, ${JSON.stringify(attribute.name)}, ${JSON.stringify(value)})`)
|
|
73
|
+
}
|
|
74
|
+
if (dynamicText.length > 1) throw new Error(`Vobs HTML component: 一个元素只能使用一个 data-vobs-text 指令 (${filename})`)
|
|
75
|
+
if (dynamicText.length === 1) {
|
|
76
|
+
const text = 'createText("")'
|
|
77
|
+
statements.push(`const text = ${text}`)
|
|
78
|
+
statements.push('insertBefore(element, text, null)')
|
|
79
|
+
statements.push(`bindText(text, () => props[${JSON.stringify(dynamicText[0])}] ?? '')`)
|
|
80
|
+
} else {
|
|
81
|
+
for (const child of children) statements.push(`insertBefore(element, ${child}, null)`)
|
|
82
|
+
}
|
|
83
|
+
statements.push('return element')
|
|
84
|
+
return [`(() => {${statements.join(';')};})()`]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return compileChildren(node as HtmlDocumentFragment, filename)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isElement(node: HtmlNode): node is HtmlElement {
|
|
91
|
+
return !node.nodeName.startsWith('#')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isSafeUrl(value: string): boolean {
|
|
95
|
+
const normalized = value.trim().toLowerCase()
|
|
96
|
+
if (normalized.startsWith('#') || normalized.startsWith('/') || normalized.startsWith('./') || normalized.startsWith('../')) return true
|
|
97
|
+
try {
|
|
98
|
+
return safeProtocols.has(new URL(normalized, 'https://vobs.invalid/').protocol)
|
|
99
|
+
} catch {
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type HtmlDirective =
|
|
105
|
+
| { readonly kind: 'text' | 'slot'; readonly prop: string }
|
|
106
|
+
| { readonly kind: 'event' | 'attribute' | 'property'; readonly name: string; readonly prop: string }
|
|
107
|
+
|
|
108
|
+
function parseDirective(name: string, value: string, filename: string): HtmlDirective | null {
|
|
109
|
+
const match = directivePattern.exec(name)
|
|
110
|
+
if (!match) return null
|
|
111
|
+
const kind = match[1].toLowerCase()
|
|
112
|
+
const namePart = match[2]
|
|
113
|
+
const prop = value.trim()
|
|
114
|
+
if (!prop || !/^[A-Za-z_$][\w$]*$/u.test(prop)) {
|
|
115
|
+
throw new Error(`Vobs HTML component: ${name} 必须引用有效的 props 名称 (${filename})`)
|
|
116
|
+
}
|
|
117
|
+
if (kind === 'text' || kind === 'slot') {
|
|
118
|
+
if (namePart) throw new Error(`Vobs HTML component: ${name} 不接受额外名称 (${filename})`)
|
|
119
|
+
return { kind, prop }
|
|
120
|
+
}
|
|
121
|
+
if (!namePart || !/^[a-z][a-z0-9:-]*$/iu.test(namePart)) {
|
|
122
|
+
throw new Error(`Vobs HTML component: ${name} 必须包含有效名称 (${filename})`)
|
|
123
|
+
}
|
|
124
|
+
if (kind === 'on') return { kind: 'event', name: namePart.toLowerCase(), prop }
|
|
125
|
+
if (kind === 'bind') {
|
|
126
|
+
if (namePart.toLowerCase() === 'style') throw new Error(`Vobs HTML component: 不允许动态绑定 style (${filename})`)
|
|
127
|
+
return { kind: 'attribute', name: namePart, prop }
|
|
128
|
+
}
|
|
129
|
+
if (!safePropertyNames.has(namePart.toLowerCase())) {
|
|
130
|
+
throw new Error(`Vobs HTML component: 不允许动态绑定 property ${namePart} (${filename})`)
|
|
131
|
+
}
|
|
132
|
+
return { kind: 'property', name: normalizePropertyName(namePart), prop }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizePropertyName(name: string): string {
|
|
136
|
+
return name.toLowerCase() === 'readonly' ? 'readOnly' : name.toLowerCase() === 'tabindex' ? 'tabIndex' : name
|
|
137
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import ts from 'typescript'
|
|
3
|
+
import { compileHtmlComponent } from './html-component'
|
|
4
|
+
import { vobsPlugin } from './index'
|
|
5
|
+
|
|
6
|
+
describe('vobsPlugin', () => {
|
|
7
|
+
it('将编译器插件配置透传给 TSX 转换', () => {
|
|
8
|
+
const plugin = vobsPlugin({
|
|
9
|
+
compiler: {
|
|
10
|
+
plugins: [{
|
|
11
|
+
name: 'replace-message',
|
|
12
|
+
transform: {
|
|
13
|
+
node(node, context) {
|
|
14
|
+
if (ts.isIdentifier(node) && node.text === 'message') {
|
|
15
|
+
return context.factory.createIdentifier('title')
|
|
16
|
+
}
|
|
17
|
+
return node
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}]
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
const transform = plugin.transform
|
|
24
|
+
if (typeof transform !== 'function') throw new Error('Vobs Vite plugin: 缺少 transform 钩子')
|
|
25
|
+
|
|
26
|
+
const result = transform.call({} as ThisParameterType<typeof transform>,
|
|
27
|
+
`export function App() { return <span>{message}</span> }`,
|
|
28
|
+
'src/App.tsx'
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
expect(result).toMatchObject({ code: expect.stringContaining('() => title') })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('返回源码映射并注入可关闭的 HMR 接受器', () => {
|
|
35
|
+
const plugin = vobsPlugin()
|
|
36
|
+
const transform = plugin.transform
|
|
37
|
+
if (typeof transform !== 'function') throw new Error('Vobs Vite plugin: 缺少 transform 钩子')
|
|
38
|
+
|
|
39
|
+
const result = transform.call({} as ThisParameterType<typeof transform>,
|
|
40
|
+
`export function App() { return <span>hello</span> }`,
|
|
41
|
+
'src/App.tsx'
|
|
42
|
+
) as { code: string; map: { sources: string[] } }
|
|
43
|
+
|
|
44
|
+
expect(result.map.sources).toEqual(['src/App.tsx'])
|
|
45
|
+
expect(result.code).toContain('import.meta.hot.accept')
|
|
46
|
+
expect(result.code).toContain('updateHmrModule')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('可以关闭 HMR 注入', () => {
|
|
50
|
+
const plugin = vobsPlugin({ hmr: false })
|
|
51
|
+
const transform = plugin.transform
|
|
52
|
+
if (typeof transform !== 'function') throw new Error('Vobs Vite plugin: 缺少 transform 钩子')
|
|
53
|
+
|
|
54
|
+
const result = transform.call({} as ThisParameterType<typeof transform>,
|
|
55
|
+
`export function App() { return <span>hello</span> }`,
|
|
56
|
+
'src/App.tsx'
|
|
57
|
+
) as { code: string }
|
|
58
|
+
|
|
59
|
+
expect(result.code).not.toContain('import.meta.hot.accept')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('生产构建默认不注入 HMR 代码', () => {
|
|
63
|
+
const plugin = vobsPlugin()
|
|
64
|
+
const configResolved = plugin.configResolved as ((config: unknown) => void) | undefined
|
|
65
|
+
configResolved?.({ command: 'build' })
|
|
66
|
+
const transform = plugin.transform
|
|
67
|
+
if (typeof transform !== 'function') throw new Error('Vobs Vite plugin: 缺少 transform 钩子')
|
|
68
|
+
const result = transform.call({} as ThisParameterType<typeof transform>,
|
|
69
|
+
`export const App = () => <main>hello</main>`, 'src/App.tsx') as { code: string }
|
|
70
|
+
expect(result.code).not.toContain('import.meta.hot.accept')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('生产构建默认剔除组件源码位置,开发构建保留', () => {
|
|
74
|
+
const compile = (plugin: ReturnType<typeof vobsPlugin>, source: string) => {
|
|
75
|
+
const transform = plugin.transform
|
|
76
|
+
if (typeof transform !== 'function') throw new Error('Vobs Vite plugin: 缺少 transform 钩子')
|
|
77
|
+
return transform.call({} as ThisParameterType<typeof transform>,
|
|
78
|
+
source, 'src/App.tsx') as { code: string }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const production = vobsPlugin({ hmr: false })
|
|
82
|
+
;(production.configResolved as (config: unknown) => void)?.({ command: 'build' })
|
|
83
|
+
const productionCode = compile(production, `export function App() { return <Panel /> }`).code
|
|
84
|
+
expect(productionCode).toContain('createComponent')
|
|
85
|
+
expect(productionCode).not.toContain('file:')
|
|
86
|
+
|
|
87
|
+
const development = compile(vobsPlugin(), `export function App() { return <Panel /> }`).code
|
|
88
|
+
expect(development).toContain('file: "src/App.tsx"')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('显式 compiler.sourceLocation 配置优先于生产默认值', () => {
|
|
92
|
+
const plugin = vobsPlugin({ hmr: false, compiler: { sourceLocation: true } })
|
|
93
|
+
const configResolved = plugin.configResolved as ((config: unknown) => void) | undefined
|
|
94
|
+
configResolved?.({ command: 'build' })
|
|
95
|
+
const transform = plugin.transform
|
|
96
|
+
if (typeof transform !== 'function') throw new Error('Vobs Vite plugin: 缺少 transform 钩子')
|
|
97
|
+
const result = transform.call({} as ThisParameterType<typeof transform>,
|
|
98
|
+
`export function App() { return <Panel /> }`, 'src/App.tsx') as { code: string }
|
|
99
|
+
expect(result.code).toContain('file: "src/App.tsx"')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('转换不含 export function 的合法 TSX 模块', () => {
|
|
103
|
+
const plugin = vobsPlugin({ hmr: false })
|
|
104
|
+
const transform = plugin.transform
|
|
105
|
+
if (typeof transform !== 'function') throw new Error('Vobs Vite plugin: 缺少 transform 钩子')
|
|
106
|
+
|
|
107
|
+
const result = transform.call({} as ThisParameterType<typeof transform>,
|
|
108
|
+
`export const App = () => <main>hello</main>`,
|
|
109
|
+
'src/App.tsx'
|
|
110
|
+
) as { code: string }
|
|
111
|
+
|
|
112
|
+
// 完全静态的 JSX 提升为模板克隆
|
|
113
|
+
expect(result.code).toContain('createTemplate("<main>hello</main>")')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('转换默认箭头导出和带 query 的 TSX 模块', () => {
|
|
117
|
+
const plugin = vobsPlugin({ hmr: false })
|
|
118
|
+
const transform = plugin.transform
|
|
119
|
+
if (typeof transform !== 'function') throw new Error('Vobs Vite plugin: 缺少 transform 钩子')
|
|
120
|
+
|
|
121
|
+
const result = transform.call({} as ThisParameterType<typeof transform>,
|
|
122
|
+
`export default () => <main>hello</main>`,
|
|
123
|
+
'src/App.tsx?direct'
|
|
124
|
+
) as { code: string }
|
|
125
|
+
|
|
126
|
+
// 完全静态的 JSX 提升为模板克隆
|
|
127
|
+
expect(result.code).toContain('createTemplate("<main>hello</main>")')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('通过 extractI18n 回调收集静态翻译 key', () => {
|
|
131
|
+
const keys: string[] = []
|
|
132
|
+
const plugin = vobsPlugin({ hmr: false, extractI18n: key => keys.push(key) })
|
|
133
|
+
const transform = plugin.transform
|
|
134
|
+
if (typeof transform !== 'function') throw new Error('Vobs Vite Plugin: 缺少 transform 钩子')
|
|
135
|
+
|
|
136
|
+
transform.call({} as ThisParameterType<typeof transform>,
|
|
137
|
+
`export const Page = () => <main>{t('page.title')} {i18n.t('common.ok')}</main>`,
|
|
138
|
+
'src/Page.tsx'
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
expect(keys).toEqual(['page.title', 'common.ok'])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('将 HTML 模块编译为无 innerHTML 的 Vobs 组件', () => {
|
|
145
|
+
const result = compileHtmlComponent('<article class="copy"><h1>Hello</h1><p>Safe</p></article>', {
|
|
146
|
+
filename: 'src/content.html'
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
expect(result).toContain("createElement(\"article\")")
|
|
150
|
+
expect(result).toContain("setAttribute(element, \"class\", \"copy\")")
|
|
151
|
+
expect(result).toContain('createText(\"Hello\")')
|
|
152
|
+
expect(result).not.toContain('innerHTML')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('将受限的 Vobs 指令连接到响应式 props 和事件', () => {
|
|
156
|
+
const result = compileHtmlComponent(`
|
|
157
|
+
<button data-vobs-on-click="onSave" data-vobs-prop-disabled="disabled">
|
|
158
|
+
<span data-vobs-text="label"></span>
|
|
159
|
+
<span data-vobs-slot="actions"></span>
|
|
160
|
+
</button>
|
|
161
|
+
`)
|
|
162
|
+
|
|
163
|
+
expect(result).toContain('addEventListener(element, "click"')
|
|
164
|
+
expect(result).toContain('bindProperty(element, "disabled"')
|
|
165
|
+
expect(result).toContain('bindText(text')
|
|
166
|
+
expect(result).toContain('insertDynamic(element, null')
|
|
167
|
+
expect(result).toContain('props["onSave"]')
|
|
168
|
+
expect(result).toContain('bindProperty(element, "disabled"')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('拒绝脚本、事件属性和危险 URL', () => {
|
|
172
|
+
expect(() => compileHtmlComponent('<script>alert(1)</script>')).toThrow('禁止使用 <script>')
|
|
173
|
+
expect(() => compileHtmlComponent('<button onclick="alert(1)">run</button>')).toThrow('禁止使用危险属性')
|
|
174
|
+
expect(() => compileHtmlComponent('<a href="javascript:alert(1)">run</a>')).toThrow('禁止使用危险 URL')
|
|
175
|
+
expect(() => compileHtmlComponent('<button data-vobs-on-click="on-click">run</button>')).toThrow('有效的 props 名称')
|
|
176
|
+
expect(() => compileHtmlComponent('<div data-vobs-bind-style="styleValue"></div>')).toThrow('不允许动态绑定 style')
|
|
177
|
+
expect(() => compileHtmlComponent('<div data-vobs-prop-innerHTML="content"></div>')).toThrow('不允许动态绑定 property')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('忽略注释和文档类型声明', () => {
|
|
181
|
+
const result = compileHtmlComponent('<!doctype html><!-- note --><main>content</main>')
|
|
182
|
+
expect(result).toContain('createElement(\"main\")')
|
|
183
|
+
expect(result).not.toContain('tagName')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('只将被导入的 HTML 文件交给组件加载器', async () => {
|
|
187
|
+
const plugin = vobsPlugin({ hmr: false })
|
|
188
|
+
const resolveId = plugin.resolveId
|
|
189
|
+
const load = plugin.load
|
|
190
|
+
if (typeof resolveId !== 'function' || typeof load !== 'function') throw new Error('Vobs Vite Plugin: 缺少 HTML 模块钩子')
|
|
191
|
+
|
|
192
|
+
expect(resolveId.call({} as ThisParameterType<typeof resolveId>, './content.html', 'src/Page.tsx', { attributes: {}, isEntry: false })).toBeTruthy()
|
|
193
|
+
expect(resolveId.call({} as ThisParameterType<typeof resolveId>, './index.html', undefined, { attributes: {}, isEntry: false })).toBeNull()
|
|
194
|
+
})
|
|
195
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Vite 插件:集成 Vobs 编译器
|
|
2
|
+
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import type { Plugin } from 'vite'
|
|
6
|
+
import { compileWithSourceMap, createI18nExtractor, type CompileOptions } from '@vobs/compiler'
|
|
7
|
+
import { VobsError } from '@vobs/runtime/error'
|
|
8
|
+
import { compileHtmlComponent } from './html-component.ts'
|
|
9
|
+
|
|
10
|
+
export interface VobsVitePluginOptions {
|
|
11
|
+
include?: RegExp
|
|
12
|
+
compiler?: CompileOptions
|
|
13
|
+
hmr?: boolean
|
|
14
|
+
extractI18n?: (key: string, filename: string) => void
|
|
15
|
+
html?: boolean | { readonly extensions?: readonly string[] }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function vobsPlugin(options: VobsVitePluginOptions = {}): Plugin {
|
|
19
|
+
const include = options.include ?? /\.tsx(?:$|\?)/
|
|
20
|
+
const htmlModules = new Set<string>()
|
|
21
|
+
let productionBuild = false
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
name: 'vobs',
|
|
25
|
+
|
|
26
|
+
enforce: 'pre',
|
|
27
|
+
|
|
28
|
+
configResolved(config) {
|
|
29
|
+
productionBuild = config.command === 'build'
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
resolveId(source: string, importer: string | undefined) {
|
|
33
|
+
if (!importer || !isHtmlComponent(source, options.html) || !isRelativeModule(source)) return null
|
|
34
|
+
const cleanImporter = importer.split(/[?#]/u, 1)[0]
|
|
35
|
+
const cleanSource = source.split(/[?#]/u, 1)[0]
|
|
36
|
+
const resolved = path.resolve(path.dirname(cleanImporter), cleanSource)
|
|
37
|
+
htmlModules.add(resolved)
|
|
38
|
+
return resolved
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async load(id: string) {
|
|
42
|
+
if (!htmlModules.has(id)) return null
|
|
43
|
+
return compileHtmlComponent(await readFile(id, 'utf8'), { filename: id })
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
transform(code: string, id: string) {
|
|
47
|
+
include.lastIndex = 0
|
|
48
|
+
if (!include.test(id)) return null
|
|
49
|
+
|
|
50
|
+
const extractor = options.extractI18n
|
|
51
|
+
? createI18nExtractor({ onKey: options.extractI18n })
|
|
52
|
+
: undefined
|
|
53
|
+
const result = compileWithSourceMap(code, {
|
|
54
|
+
...options.compiler,
|
|
55
|
+
// 生产构建默认剔除组件源码位置(错误定位走 source map);显式配置优先。
|
|
56
|
+
sourceLocation: options.compiler?.sourceLocation ?? !productionBuild,
|
|
57
|
+
filename: id,
|
|
58
|
+
plugins: [
|
|
59
|
+
...(options.compiler?.plugins ?? []),
|
|
60
|
+
...(extractor ? [extractor.plugin] : [])
|
|
61
|
+
]
|
|
62
|
+
})
|
|
63
|
+
const diagnostic = result.diagnostics.find(item => item.severity === 'error')
|
|
64
|
+
if (diagnostic) {
|
|
65
|
+
throw new VobsError({
|
|
66
|
+
code: diagnostic.code,
|
|
67
|
+
layer: 'compiler',
|
|
68
|
+
message: diagnostic.message,
|
|
69
|
+
location: diagnostic.location,
|
|
70
|
+
codeFrame: diagnostic.codeFrame,
|
|
71
|
+
fix: diagnostic.fix
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
const hmr = options.hmr ?? !productionBuild
|
|
75
|
+
const hmrCode = hmr ? createHmrCode(id) : ''
|
|
76
|
+
return {
|
|
77
|
+
code: `${result.code}${hmrCode}`,
|
|
78
|
+
map: result.map
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isRelativeModule(source: string): boolean {
|
|
85
|
+
return source.startsWith('./') || source.startsWith('../')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isHtmlComponent(id: string, option: VobsVitePluginOptions['html']): boolean {
|
|
89
|
+
if (option === false) return false
|
|
90
|
+
const extensions = typeof option === 'object' && option.extensions?.length ? option.extensions : ['.html', '.htm']
|
|
91
|
+
const cleanId = id.split(/[?#]/u, 1)[0]
|
|
92
|
+
return extensions.some(extension => cleanId.endsWith(extension))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
function createHmrCode(moduleId: string): string {
|
|
97
|
+
const encodedId = JSON.stringify(moduleId)
|
|
98
|
+
return `
|
|
99
|
+
import { disposeHmrModule, updateHmrModule } from '@vobs/vobs'
|
|
100
|
+
|
|
101
|
+
if (import.meta.hot) {
|
|
102
|
+
import.meta.hot.accept((module) => {
|
|
103
|
+
if (module) updateHmrModule(${encodedId}, module)
|
|
104
|
+
})
|
|
105
|
+
import.meta.hot.dispose(() => disposeHmrModule(${encodedId}))
|
|
106
|
+
}
|
|
107
|
+
`
|
|
108
|
+
}
|
package/dist/client.d.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/** @license MIT
|
|
2
|
-
* Copyright (c) 2026 vobsjs
|
|
3
|
-
* @vobs/vite-plugin
|
|
4
|
-
*/
|
|
5
|
-
declare module '*.html' {
|
|
6
|
-
const template: import('@vobs/runtime-dom').CompiledTemplate<object>;
|
|
7
|
-
export default template;
|
|
8
|
-
}
|
|
9
|
-
declare module '*?vobs-image' {
|
|
10
|
-
export interface VobsImageMetadata {
|
|
11
|
-
readonly src: string;
|
|
12
|
-
readonly width: number;
|
|
13
|
-
readonly height: number;
|
|
14
|
-
readonly format: 'jpeg' | 'png' | 'svg' | 'webp';
|
|
15
|
-
}
|
|
16
|
-
const metadata: VobsImageMetadata;
|
|
17
|
-
export default metadata;
|
|
18
|
-
}
|
|
19
|
-
declare module '*.svg?raw' {
|
|
20
|
-
const source: string;
|
|
21
|
-
export default source;
|
|
22
|
-
}
|
|
23
|
-
declare module 'vobs:icons' {
|
|
24
|
-
const icons: import('@vobs/icons').IconRegistry;
|
|
25
|
-
export { icons };
|
|
26
|
-
export default icons;
|
|
27
|
-
}
|
|
28
|
-
declare module 'vobs:routes' {
|
|
29
|
-
import type { RouteComponent, RouteRecord, RouterOptions } from '@vobs/router';
|
|
30
|
-
import type { RouterPort } from '@vobs/runtime-core';
|
|
31
|
-
export interface GeneratedRoutePathMap {
|
|
32
|
-
}
|
|
33
|
-
export type RoutePath = keyof GeneratedRoutePathMap extends never ? string : Extract<keyof GeneratedRoutePathMap, string>;
|
|
34
|
-
const generatedRoutes: readonly RouteRecord[];
|
|
35
|
-
const generatedRoutePaths: readonly RoutePath[];
|
|
36
|
-
const generatedNotFound: RouteComponent | undefined;
|
|
37
|
-
const generatedError: RouteComponent | undefined;
|
|
38
|
-
export type GeneratedRoutingManifest = {
|
|
39
|
-
readonly routes: typeof generatedRoutes;
|
|
40
|
-
readonly routePaths: typeof generatedRoutePaths;
|
|
41
|
-
readonly notFound: typeof generatedNotFound;
|
|
42
|
-
readonly error: typeof generatedError;
|
|
43
|
-
};
|
|
44
|
-
export function createRoutingRouter(options?: Omit<RouterOptions, 'routes' | 'notFound' | 'error'>): RouterPort | undefined;
|
|
45
|
-
const router: RouterPort | undefined;
|
|
46
|
-
export { generatedError as error, generatedNotFound as notFound, generatedRoutePaths as routePaths, generatedRoutes as routes, };
|
|
47
|
-
export default router;
|
|
48
|
-
}
|
package/dist/client.js
DELETED
package/dist/index.d.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/** @license MIT
|
|
2
|
-
* Copyright (c) 2026 vobsjs
|
|
3
|
-
* @vobs/vite-plugin
|
|
4
|
-
*/
|
|
5
|
-
export interface VobsVitePlugin {
|
|
6
|
-
readonly name: 'vobs-vite-plugin';
|
|
7
|
-
readonly enforce: 'pre';
|
|
8
|
-
/** Vite/Rollup binds this hook helper at call time. */
|
|
9
|
-
addWatchFile?(fileName: string): void;
|
|
10
|
-
configResolved(config: {
|
|
11
|
-
readonly root: string;
|
|
12
|
-
}): void;
|
|
13
|
-
buildStart(this: VobsBuildStartContext): void;
|
|
14
|
-
closeWatcher(): void;
|
|
15
|
-
resolveId(id: string, importer?: string): string | null;
|
|
16
|
-
load(this: VobsLoadContext, id: string): Promise<string | {
|
|
17
|
-
readonly code: string;
|
|
18
|
-
readonly map: object;
|
|
19
|
-
} | null>;
|
|
20
|
-
transform(this: VobsTransformContext, code: string, id: string): {
|
|
21
|
-
readonly code: string;
|
|
22
|
-
readonly map: object;
|
|
23
|
-
} | null;
|
|
24
|
-
}
|
|
25
|
-
interface VobsTransformContext {
|
|
26
|
-
addWatchFile?(fileName: string): void;
|
|
27
|
-
}
|
|
28
|
-
interface VobsLoadContext {
|
|
29
|
-
addWatchFile?(fileName: string): void;
|
|
30
|
-
}
|
|
31
|
-
interface VobsBuildStartContext {
|
|
32
|
-
addWatchFile?(fileName: string): void;
|
|
33
|
-
}
|
|
34
|
-
export interface VobsVitePluginOptions {
|
|
35
|
-
readonly runtimeModule?: string;
|
|
36
|
-
readonly check?: boolean | VobsViteCheckOptions;
|
|
37
|
-
readonly routes?: boolean | VobsViteRoutesOptions;
|
|
38
|
-
readonly images?: boolean;
|
|
39
|
-
readonly icons?: boolean | VobsViteIconsOptions;
|
|
40
|
-
}
|
|
41
|
-
export interface VobsViteCheckOptions {
|
|
42
|
-
readonly tsconfigPath?: string;
|
|
43
|
-
readonly rootNames?: readonly string[];
|
|
44
|
-
readonly viewFiles?: readonly string[];
|
|
45
|
-
}
|
|
46
|
-
export interface VobsViteRoutesOptions {
|
|
47
|
-
readonly pagesDir?: string;
|
|
48
|
-
readonly typesFile?: string;
|
|
49
|
-
}
|
|
50
|
-
export interface VobsViteIconsOptions {
|
|
51
|
-
readonly config?: string;
|
|
52
|
-
}
|
|
53
|
-
export declare function vobs(options?: VobsVitePluginOptions): VobsVitePlugin;
|
|
54
|
-
export default vobs;
|