create-viteframe 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/README.md +228 -0
- package/bin/create.js +113 -0
- package/package.json +18 -0
- package/template/_gitignore +3 -0
- package/template/index.html +15 -0
- package/template/package.json +13 -0
- package/template/src/components/header.comp.html +42 -0
- package/template/src/components/toast.comp.html +12 -0
- package/template/src/core/components.js +134 -0
- package/template/src/core/router.js +303 -0
- package/template/src/core/state.js +132 -0
- package/template/src/main.js +46 -0
- package/template/src/pages/[id]post.page.html +72 -0
- package/template/src/pages/about.page.html +40 -0
- package/template/src/pages/docs.page.html +197 -0
- package/template/src/pages/landing.page.html +77 -0
- package/template/src/styles/main.css +171 -0
- package/template/vite.config.js +6 -0
package/README.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# create-viteframe
|
|
2
|
+
|
|
3
|
+
Scaffold a new ViteFrame app — a lightweight SPA framework built on Vite with file-based routing, reactive state, and dynamic HTML components. No external JS dependencies.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm create viteframe@latest my-app
|
|
9
|
+
cd my-app
|
|
10
|
+
npm install
|
|
11
|
+
npm run dev
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or with pnpm / yarn:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm create viteframe my-app
|
|
18
|
+
yarn create viteframe my-app
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## What you get
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
src/
|
|
27
|
+
├── core/
|
|
28
|
+
│ ├── router.js # File-based SPA router (~180 lines)
|
|
29
|
+
│ ├── state.js # Reactive state store (~100 lines)
|
|
30
|
+
│ └── components.js # Dynamic component loader (~120 lines)
|
|
31
|
+
├── pages/
|
|
32
|
+
│ ├── landing.page.html # → /
|
|
33
|
+
│ ├── about.page.html # → /about
|
|
34
|
+
│ ├── docs.page.html # → /docs
|
|
35
|
+
│ └── [id]post.page.html # → /post/:id
|
|
36
|
+
├── components/
|
|
37
|
+
│ ├── header.comp.html
|
|
38
|
+
│ └── toast.comp.html
|
|
39
|
+
├── styles/
|
|
40
|
+
│ └── main.css # Design tokens + utility classes
|
|
41
|
+
└── main.js # Bootstrap
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
All framework code lives in your project — read it, modify it, own it.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Routing
|
|
49
|
+
|
|
50
|
+
File name determines the route. Drop files in `src/pages/`.
|
|
51
|
+
|
|
52
|
+
| File | Route |
|
|
53
|
+
|------|-------|
|
|
54
|
+
| `landing.page.html` | `/` |
|
|
55
|
+
| `about.page.html` | `/about` |
|
|
56
|
+
| `[id]post.page.html` | `/post/:id` |
|
|
57
|
+
| `[id-slug]post.page.html` | `/post/:id/:slug` |
|
|
58
|
+
| `[lang-id]post.page.html` | `/post/:lang/:id` |
|
|
59
|
+
|
|
60
|
+
### Programmatic navigation
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
window.__vf.navigate('/about')
|
|
64
|
+
window.__vf.navigate('/post/42', { replace: true })
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## State
|
|
70
|
+
|
|
71
|
+
### Global state
|
|
72
|
+
|
|
73
|
+
Shared across all pages. Persists during navigation.
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
const { globalState } = window.__vf
|
|
77
|
+
|
|
78
|
+
globalState.set('user', { name: 'Alice' })
|
|
79
|
+
globalState.get('user')
|
|
80
|
+
globalState.subscribe('user', (val) => console.log(val))
|
|
81
|
+
|
|
82
|
+
// Bind value → DOM (auto-updates when state changes)
|
|
83
|
+
globalState.bind('user', el, u => u?.name ?? 'Guest')
|
|
84
|
+
|
|
85
|
+
// Two-way bind: input ↔ state
|
|
86
|
+
globalState.bindInput('search', inputEl)
|
|
87
|
+
|
|
88
|
+
// Partial merge
|
|
89
|
+
globalState.merge('settings', { darkMode: true })
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Page state
|
|
93
|
+
|
|
94
|
+
Isolated to a single page visit. Created inside `onMount`, discarded on navigation.
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
export function onMount() {
|
|
98
|
+
const { createPageState } = window.__vf
|
|
99
|
+
const state = createPageState({ count: 0, open: false })
|
|
100
|
+
|
|
101
|
+
state.update('count', n => n + 1)
|
|
102
|
+
state.bind('count', document.getElementById('counter'))
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Computed values
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
const { computed, globalState } = window.__vf
|
|
110
|
+
|
|
111
|
+
const fullName = computed(
|
|
112
|
+
['firstName', 'lastName'],
|
|
113
|
+
[globalState, globalState],
|
|
114
|
+
(first, last) => `${first} ${last}`
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
fullName.get() // 'Alice Smith'
|
|
118
|
+
fullName.subscribe(v => …) // fires when either dep changes
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Components
|
|
124
|
+
|
|
125
|
+
Components are `.comp.html` files in `src/components/`. They support scoped `<style>` and `<script>` blocks.
|
|
126
|
+
|
|
127
|
+
### Use in HTML
|
|
128
|
+
|
|
129
|
+
```html
|
|
130
|
+
<!-- Simple -->
|
|
131
|
+
<component src="header"></component>
|
|
132
|
+
|
|
133
|
+
<!-- With props via data-* attributes -->
|
|
134
|
+
<component src="card" data-title="Hello" data-body="World"></component>
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Template syntax
|
|
138
|
+
|
|
139
|
+
```html
|
|
140
|
+
<!-- src/components/card.comp.html -->
|
|
141
|
+
<div class="card">
|
|
142
|
+
<h3>{{title}}</h3>
|
|
143
|
+
<p>{{body}}</p>
|
|
144
|
+
</div>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Mount from JavaScript
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
const { mountComponent } = window.__vf
|
|
151
|
+
|
|
152
|
+
await mountComponent('#target', 'card', { title: 'Hello', body: 'World' })
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Page lifecycle
|
|
158
|
+
|
|
159
|
+
Add `<script data-lifecycle>` to any page and export `onMount`:
|
|
160
|
+
|
|
161
|
+
```html
|
|
162
|
+
<script data-lifecycle>
|
|
163
|
+
export function onMount({ params, query, globalState }) {
|
|
164
|
+
// params → route params e.g. { id: '42' }
|
|
165
|
+
// query → ?key=val e.g. { tab: 'info' }
|
|
166
|
+
// globalState → shared store
|
|
167
|
+
|
|
168
|
+
console.log('id:', params.id)
|
|
169
|
+
console.log('tab:', query.tab)
|
|
170
|
+
|
|
171
|
+
const { createPageState } = window.__vf
|
|
172
|
+
const state = createPageState({ count: 0 })
|
|
173
|
+
}
|
|
174
|
+
</script>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Navigation guards
|
|
180
|
+
|
|
181
|
+
In `src/main.js`:
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
router.beforeEach(({ pathname }) => {
|
|
185
|
+
if (pathname.startsWith('/admin') && !globalState.get('user')) {
|
|
186
|
+
router.navigate('/login')
|
|
187
|
+
return false // cancel navigation
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Toast notifications
|
|
195
|
+
|
|
196
|
+
Include `<component src="toast">` on a page, then anywhere:
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
window.toast('Saved!', 'success') // green
|
|
200
|
+
window.toast('Something broke', 'error') // red
|
|
201
|
+
window.toast('FYI', 'info', 5000) // purple, 5s
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## CSS design tokens
|
|
207
|
+
|
|
208
|
+
All colors, spacing, and radii are CSS variables in `src/styles/main.css`.
|
|
209
|
+
|
|
210
|
+
```css
|
|
211
|
+
/* Override in your own CSS */
|
|
212
|
+
:root {
|
|
213
|
+
--accent: #your-color;
|
|
214
|
+
--radius: 12px;
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Dark/light theme is toggled by setting `data-theme="light"` on `<html>`:
|
|
219
|
+
|
|
220
|
+
```js
|
|
221
|
+
window.__vf.globalState.set('theme', 'light')
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT
|
package/bin/create.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { execSync } from 'child_process'
|
|
5
|
+
import { fileURLToPath } from 'url'
|
|
6
|
+
import readline from 'readline'
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
const templateDir = path.join(__dirname, '../template')
|
|
10
|
+
|
|
11
|
+
// ─── Colors ──────────────────────────────────────────────────────
|
|
12
|
+
const c = {
|
|
13
|
+
reset: '\x1b[0m',
|
|
14
|
+
bold: '\x1b[1m',
|
|
15
|
+
dim: '\x1b[2m',
|
|
16
|
+
purple: '\x1b[35m',
|
|
17
|
+
green: '\x1b[32m',
|
|
18
|
+
cyan: '\x1b[36m',
|
|
19
|
+
yellow: '\x1b[33m',
|
|
20
|
+
red: '\x1b[31m',
|
|
21
|
+
}
|
|
22
|
+
const bold = s => `${c.bold}${s}${c.reset}`
|
|
23
|
+
const purple = s => `${c.purple}${s}${c.reset}`
|
|
24
|
+
const green = s => `${c.green}${s}${c.reset}`
|
|
25
|
+
const dim = s => `${c.dim}${s}${c.reset}`
|
|
26
|
+
const yellow = s => `${c.yellow}${s}${c.reset}`
|
|
27
|
+
const red = s => `${c.red}${s}${c.reset}`
|
|
28
|
+
|
|
29
|
+
// ─── Prompt helper ───────────────────────────────────────────────
|
|
30
|
+
function prompt(question) {
|
|
31
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
32
|
+
return new Promise(resolve => {
|
|
33
|
+
rl.question(question, answer => { rl.close(); resolve(answer.trim()) })
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Copy template recursively ───────────────────────────────────
|
|
38
|
+
function copyDir(src, dest) {
|
|
39
|
+
fs.mkdirSync(dest, { recursive: true })
|
|
40
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
41
|
+
const srcPath = path.join(src, entry.name)
|
|
42
|
+
const destPath = path.join(dest, entry.name)
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
copyDir(srcPath, destPath)
|
|
45
|
+
} else {
|
|
46
|
+
// Rename _gitignore → .gitignore (npm strips dot-files from published packages)
|
|
47
|
+
const finalDest = destPath.replace(/_gitignore$/, '.gitignore')
|
|
48
|
+
fs.copyFileSync(srcPath, finalDest)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Main ─────────────────────────────────────────────────────────
|
|
54
|
+
async function main() {
|
|
55
|
+
console.log()
|
|
56
|
+
console.log(bold(purple(' ⚡ ViteFrame')))
|
|
57
|
+
console.log(dim(' File-based SPA framework powered by Vite\n'))
|
|
58
|
+
|
|
59
|
+
// Project name from arg or prompt
|
|
60
|
+
let projectName = process.argv[2]
|
|
61
|
+
if (!projectName) {
|
|
62
|
+
projectName = await prompt(` ${bold('Project name')} ${dim('(my-app)')} › `)
|
|
63
|
+
if (!projectName) projectName = 'my-app'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Sanitize
|
|
67
|
+
const dirName = projectName.replace(/\s+/g, '-').toLowerCase()
|
|
68
|
+
const targetDir = path.resolve(process.cwd(), dirName)
|
|
69
|
+
|
|
70
|
+
// Check if dir exists
|
|
71
|
+
if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
|
|
72
|
+
const overwrite = await prompt(` ${yellow('!')} Directory "${dirName}" is not empty. Overwrite? ${dim('(y/N)')} › `)
|
|
73
|
+
if (!overwrite.toLowerCase().startsWith('y')) {
|
|
74
|
+
console.log(`\n ${red('Aborted.')}\n`)
|
|
75
|
+
process.exit(1)
|
|
76
|
+
}
|
|
77
|
+
fs.rmSync(targetDir, { recursive: true, force: true })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Detect package manager
|
|
81
|
+
const agent = process.env.npm_config_user_agent || ''
|
|
82
|
+
const pm = agent.startsWith('pnpm') ? 'pnpm'
|
|
83
|
+
: agent.startsWith('yarn') ? 'yarn'
|
|
84
|
+
: 'npm'
|
|
85
|
+
|
|
86
|
+
// Copy template
|
|
87
|
+
console.log(`\n Creating ${bold(dirName)}...\n`)
|
|
88
|
+
copyDir(templateDir, targetDir)
|
|
89
|
+
|
|
90
|
+
// Write package.json with project name
|
|
91
|
+
const pkgPath = path.join(targetDir, 'package.json')
|
|
92
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
93
|
+
pkg.name = dirName
|
|
94
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
95
|
+
|
|
96
|
+
// Done
|
|
97
|
+
const rel = path.relative(process.cwd(), targetDir)
|
|
98
|
+
const cdCmd = rel === dirName ? dirName : rel
|
|
99
|
+
|
|
100
|
+
console.log(` ${green('✓')} Created ${bold(dirName)}\n`)
|
|
101
|
+
console.log(` Next steps:\n`)
|
|
102
|
+
console.log(` ${dim('$')} ${bold(`cd ${cdCmd}`)}`)
|
|
103
|
+
console.log(` ${dim('$')} ${bold(`${pm} install`)}`)
|
|
104
|
+
console.log(` ${dim('$')} ${bold(`${pm} run dev`)}`)
|
|
105
|
+
console.log()
|
|
106
|
+
console.log(` ${dim('Docs → src/pages/docs.page.html')}`)
|
|
107
|
+
console.log()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
main().catch(e => {
|
|
111
|
+
console.error(red(`\n Error: ${e.message}\n`))
|
|
112
|
+
process.exit(1)
|
|
113
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-viteframe",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Create a new ViteFrame app — file-based routing, reactive state, dynamic components",
|
|
5
|
+
"keywords": ["vite", "spa", "router", "framework", "scaffold"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"create-viteframe": "./bin/create.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"template"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18.0.0"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>ViteFrame App</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/styles/main.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app">
|
|
11
|
+
<div id="router-outlet"></div>
|
|
12
|
+
</div>
|
|
13
|
+
<script type="module" src="/src/main.js"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<header class="vf-header">
|
|
2
|
+
<div class="container flex-between">
|
|
3
|
+
<a href="/" class="vf-logo">⚡ ViteFrame</a>
|
|
4
|
+
<nav class="flex gap-4">
|
|
5
|
+
<a href="/" class="vf-nav-link">Home</a>
|
|
6
|
+
<a href="/about" class="vf-nav-link">About</a>
|
|
7
|
+
<a href="/docs" class="vf-nav-link">Docs</a>
|
|
8
|
+
</nav>
|
|
9
|
+
<button class="btn btn-ghost" id="vf-theme-btn" style="padding:6px 12px;font-size:.8rem;">◑</button>
|
|
10
|
+
</div>
|
|
11
|
+
</header>
|
|
12
|
+
|
|
13
|
+
<style>
|
|
14
|
+
.vf-header {
|
|
15
|
+
position: sticky; top: 0; z-index: 100; height: 56px;
|
|
16
|
+
display: flex; align-items: center;
|
|
17
|
+
background: color-mix(in srgb, var(--bg) 80%, transparent);
|
|
18
|
+
backdrop-filter: blur(12px);
|
|
19
|
+
border-bottom: 1px solid var(--border);
|
|
20
|
+
}
|
|
21
|
+
.vf-logo { font-weight: 800; color: var(--text); font-size: .95rem; }
|
|
22
|
+
.vf-nav-link {
|
|
23
|
+
font-size: .875rem; font-weight: 500; color: var(--text-2);
|
|
24
|
+
padding: 4px 8px; border-radius: 6px; transition: all .15s;
|
|
25
|
+
}
|
|
26
|
+
.vf-nav-link:hover,
|
|
27
|
+
.vf-nav-link.active { color: var(--text); background: var(--surface-2); }
|
|
28
|
+
</style>
|
|
29
|
+
|
|
30
|
+
<script>
|
|
31
|
+
(function () {
|
|
32
|
+
// Theme toggle
|
|
33
|
+
document.getElementById('vf-theme-btn')?.addEventListener('click', () => {
|
|
34
|
+
const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'
|
|
35
|
+
window.__vf?.globalState.set('theme', next)
|
|
36
|
+
})
|
|
37
|
+
// Active link
|
|
38
|
+
document.querySelectorAll('.vf-nav-link').forEach(a => {
|
|
39
|
+
a.classList.toggle('active', a.getAttribute('href') === location.pathname)
|
|
40
|
+
})
|
|
41
|
+
})()
|
|
42
|
+
</script>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<div id="toast-container"></div>
|
|
2
|
+
|
|
3
|
+
<script>
|
|
4
|
+
// window.toast('Message', 'success' | 'error' | 'info', ms?)
|
|
5
|
+
window.toast = function (msg, type = 'info', ms = 3000) {
|
|
6
|
+
let el = document.getElementById('toast-container')
|
|
7
|
+
if (!el) { el = document.createElement('div'); el.id = 'toast-container'; document.body.append(el) }
|
|
8
|
+
const t = Object.assign(document.createElement('div'), { className: `toast toast-${type}`, textContent: msg })
|
|
9
|
+
el.append(t)
|
|
10
|
+
setTimeout(() => { t.classList.add('exit'); t.addEventListener('animationend', () => t.remove()) }, ms)
|
|
11
|
+
}
|
|
12
|
+
</script>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ComponentLoader — Dynamic HTML component loading system
|
|
3
|
+
*
|
|
4
|
+
* Naming: anything ending in .comp.html
|
|
5
|
+
*
|
|
6
|
+
* Usage in HTML:
|
|
7
|
+
* <component src="header"></component>
|
|
8
|
+
* <component src="card" props='{"title":"Hello"}'></component>
|
|
9
|
+
*
|
|
10
|
+
* Usage in JS:
|
|
11
|
+
* import { loadComponent, mountComponent } from './components.js'
|
|
12
|
+
* const html = await loadComponent('header', { user: 'Alice' })
|
|
13
|
+
* await mountComponent('#target', 'card', { title: 'Hello' })
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const componentCache = new Map()
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fetch and parse a .comp.html file
|
|
20
|
+
* Props are injected as {{propName}} template variables
|
|
21
|
+
*/
|
|
22
|
+
export async function loadComponent(name, props = {}) {
|
|
23
|
+
const cacheKey = name
|
|
24
|
+
|
|
25
|
+
if (!componentCache.has(cacheKey)) {
|
|
26
|
+
const path = `/src/components/${name}.comp.html`
|
|
27
|
+
const res = await fetch(path)
|
|
28
|
+
if (!res.ok) throw new Error(`Component not found: ${name} (${path})`)
|
|
29
|
+
const text = await res.text()
|
|
30
|
+
componentCache.set(cacheKey, text)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let html = componentCache.get(cacheKey)
|
|
34
|
+
|
|
35
|
+
// Inject props via {{propName}} syntax
|
|
36
|
+
html = interpolate(html, props)
|
|
37
|
+
|
|
38
|
+
return html
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Mount a component into a DOM element
|
|
43
|
+
*/
|
|
44
|
+
export async function mountComponent(selectorOrEl, name, props = {}) {
|
|
45
|
+
const el = typeof selectorOrEl === 'string'
|
|
46
|
+
? document.querySelector(selectorOrEl)
|
|
47
|
+
: selectorOrEl
|
|
48
|
+
|
|
49
|
+
if (!el) throw new Error(`Mount target not found: ${selectorOrEl}`)
|
|
50
|
+
|
|
51
|
+
const html = await loadComponent(name, props)
|
|
52
|
+
el.innerHTML = html
|
|
53
|
+
|
|
54
|
+
// Auto-process any nested <component> tags
|
|
55
|
+
await processComponentTags(el)
|
|
56
|
+
|
|
57
|
+
// Run any inline <script> tags in the component
|
|
58
|
+
await executeComponentScripts(el)
|
|
59
|
+
|
|
60
|
+
return el
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Process all <component src="name"> tags in a container
|
|
65
|
+
*/
|
|
66
|
+
export async function processComponentTags(container = document) {
|
|
67
|
+
const tags = container.querySelectorAll('component[src]')
|
|
68
|
+
const tasks = Array.from(tags).map(async (tag) => {
|
|
69
|
+
const name = tag.getAttribute('src')
|
|
70
|
+
const propsAttr = tag.getAttribute('props')
|
|
71
|
+
const props = propsAttr ? JSON.parse(propsAttr) : {}
|
|
72
|
+
|
|
73
|
+
// Merge data-* attributes as props too
|
|
74
|
+
Object.entries(tag.dataset).forEach(([k, v]) => {
|
|
75
|
+
try { props[k] = JSON.parse(v) } catch { props[k] = v }
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const html = await loadComponent(name, props)
|
|
80
|
+
const wrapper = document.createElement('div')
|
|
81
|
+
wrapper.className = tag.className || ''
|
|
82
|
+
wrapper.innerHTML = html
|
|
83
|
+
tag.replaceWith(wrapper)
|
|
84
|
+
await processComponentTags(wrapper)
|
|
85
|
+
await executeComponentScripts(wrapper)
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.error(`Failed to load component <${name}>:`, e)
|
|
88
|
+
tag.replaceWith(createErrorPlaceholder(name, e.message))
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
await Promise.all(tasks)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Execute <script> tags inside a freshly-inserted HTML fragment
|
|
96
|
+
*/
|
|
97
|
+
export async function executeComponentScripts(container) {
|
|
98
|
+
const scripts = container.querySelectorAll('script')
|
|
99
|
+
for (const script of scripts) {
|
|
100
|
+
const newScript = document.createElement('script')
|
|
101
|
+
newScript.type = script.type || 'text/javascript'
|
|
102
|
+
if (script.src) {
|
|
103
|
+
newScript.src = script.src
|
|
104
|
+
} else {
|
|
105
|
+
newScript.textContent = script.textContent
|
|
106
|
+
}
|
|
107
|
+
script.replaceWith(newScript)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Simple {{key}} template interpolation
|
|
113
|
+
* Supports nested: {{user.name}}
|
|
114
|
+
*/
|
|
115
|
+
function interpolate(template, data) {
|
|
116
|
+
return template.replace(/\{\{([\w.]+)\}\}/g, (_, path) => {
|
|
117
|
+
const value = path.split('.').reduce((obj, key) => obj?.[key], data)
|
|
118
|
+
return value !== undefined ? String(value) : ''
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function createErrorPlaceholder(name, msg) {
|
|
123
|
+
const div = document.createElement('div')
|
|
124
|
+
div.style.cssText = 'border:1px dashed #ff4444;padding:8px;color:#ff4444;font-size:12px;font-family:monospace'
|
|
125
|
+
div.textContent = `⚠ Component "${name}": ${msg}`
|
|
126
|
+
return div
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Clear the component cache (useful during HMR / dev)
|
|
131
|
+
*/
|
|
132
|
+
export function clearComponentCache() {
|
|
133
|
+
componentCache.clear()
|
|
134
|
+
}
|