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
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Router — File-based SPA routing
|
|
3
|
+
*
|
|
4
|
+
* Naming conventions (auto-discovered via Vite's import.meta.glob):
|
|
5
|
+
* Static pages: landing.page.html
|
|
6
|
+
* Dynamic pages: [id]user.page.html → /user/:id
|
|
7
|
+
* [id-name-age]profile.page.html → /profile/:id/:name/:age
|
|
8
|
+
*
|
|
9
|
+
* Routes are intercepted on all <a href="..."> clicks.
|
|
10
|
+
* History API (pushState) is used for navigation.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { processComponentTags, executeComponentScripts } from './components.js'
|
|
14
|
+
import { globalState } from './state.js'
|
|
15
|
+
|
|
16
|
+
// ─── Route registry ───────────────────────────────────────────────
|
|
17
|
+
class Router {
|
|
18
|
+
#routes = []
|
|
19
|
+
#currentPage = null
|
|
20
|
+
#outlet = null
|
|
21
|
+
#hooks = { before: [], after: [] }
|
|
22
|
+
#pageModules = {}
|
|
23
|
+
|
|
24
|
+
constructor() {
|
|
25
|
+
this.#loadRoutes()
|
|
26
|
+
this.#listen()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Discover all page files via Vite glob
|
|
31
|
+
* Pages live in /src/pages/
|
|
32
|
+
*/
|
|
33
|
+
#loadRoutes() {
|
|
34
|
+
// Vite glob — static + dynamic pages
|
|
35
|
+
const pages = import.meta.glob('/src/pages/**/*.page.html', {
|
|
36
|
+
query: '?raw',
|
|
37
|
+
import: 'default',
|
|
38
|
+
eager: false
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const dynPages = import.meta.glob('/src/pages/**/*dyn.page.html', {
|
|
42
|
+
query: '?raw',
|
|
43
|
+
import: 'default',
|
|
44
|
+
eager: false
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const allPages = { ...pages, ...dynPages }
|
|
48
|
+
|
|
49
|
+
for (const [filePath, loader] of Object.entries(allPages)) {
|
|
50
|
+
const route = this.#filePathToRoute(filePath)
|
|
51
|
+
this.#routes.push({ ...route, loader })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Sort: static before dynamic, longer before shorter
|
|
55
|
+
this.#routes.sort((a, b) => {
|
|
56
|
+
if (a.isDynamic !== b.isDynamic) return a.isDynamic ? 1 : -1
|
|
57
|
+
return b.pattern.length - a.pattern.length
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
console.log('[Router] Registered routes:', this.#routes.map(r => r.path))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Convert a file path to a route config
|
|
65
|
+
*
|
|
66
|
+
* /src/pages/landing.page.html → { path: '/', pattern: '/' }
|
|
67
|
+
* /src/pages/about.page.html → { path: '/about', pattern: '/about' }
|
|
68
|
+
* /src/pages/[id]user.page.html → { path: '/user/:id', pattern: '/user/:id', params: ['id'] }
|
|
69
|
+
* /src/pages/[id-name-age]profile.page.html → { path: '/profile/:id/:name/:age', params: ['id','name','age'] }
|
|
70
|
+
*/
|
|
71
|
+
#filePathToRoute(filePath) {
|
|
72
|
+
// Extract filename without path prefix
|
|
73
|
+
const fileName = filePath.replace('/src/pages/', '').replace(/^.*\//, '')
|
|
74
|
+
|
|
75
|
+
// Check for dynamic route: [params]name.dyn.page.html or [params]name.page.html
|
|
76
|
+
const dynMatch = fileName.match(/^\[([^\]]+)\]([^.]+)(?:dyn)?\.page\.html$/)
|
|
77
|
+
if (dynMatch) {
|
|
78
|
+
const paramNames = dynMatch[1].split('-')
|
|
79
|
+
const pageName = dynMatch[2]
|
|
80
|
+
const paramSegments = paramNames.map(p => `:${p}`).join('/')
|
|
81
|
+
const path = `/${pageName}/${paramSegments}`
|
|
82
|
+
const regex = this.#buildRegex(path)
|
|
83
|
+
return { filePath, path, pattern: path, isDynamic: true, paramNames, regex }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Static page
|
|
87
|
+
const staticMatch = fileName.match(/^(.+)\.page\.html$/)
|
|
88
|
+
if (staticMatch) {
|
|
89
|
+
const name = staticMatch[1]
|
|
90
|
+
const path = name === 'landing' ? '/' : `/${name}`
|
|
91
|
+
const regex = new RegExp(`^${path === '/' ? '/?$' : path + '/?$'}`)
|
|
92
|
+
return { filePath, path, pattern: path, isDynamic: false, paramNames: [], regex }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
throw new Error(`Invalid page filename: ${fileName}`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#buildRegex(pattern) {
|
|
99
|
+
const escaped = pattern
|
|
100
|
+
.replace(/\//g, '\\/')
|
|
101
|
+
.replace(/:([^/]+)/g, '([^/]+)')
|
|
102
|
+
return new RegExp(`^${escaped}\\/?$`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Match a URL pathname to a registered route
|
|
107
|
+
*/
|
|
108
|
+
#match(pathname) {
|
|
109
|
+
for (const route of this.#routes) {
|
|
110
|
+
const m = pathname.match(route.regex)
|
|
111
|
+
if (m) {
|
|
112
|
+
const params = {}
|
|
113
|
+
route.paramNames.forEach((name, i) => {
|
|
114
|
+
params[name] = decodeURIComponent(m[i + 1])
|
|
115
|
+
})
|
|
116
|
+
return { route, params }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Parse ?key=value query string
|
|
124
|
+
*/
|
|
125
|
+
#parseQuery(search) {
|
|
126
|
+
const q = {}
|
|
127
|
+
new URLSearchParams(search).forEach((v, k) => { q[k] = v })
|
|
128
|
+
return q
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Navigate to a path (pushes to history)
|
|
133
|
+
*/
|
|
134
|
+
async navigate(path, options = {}) {
|
|
135
|
+
const { replace = false, state = {} } = options
|
|
136
|
+
const method = replace ? 'replaceState' : 'pushState'
|
|
137
|
+
window.history[method](state, '', path)
|
|
138
|
+
await this.#render(path)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Render the current URL
|
|
143
|
+
*/
|
|
144
|
+
async #render(pathname) {
|
|
145
|
+
if (!this.#outlet) {
|
|
146
|
+
this.#outlet = document.getElementById('router-outlet')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const url = new URL(pathname, window.location.origin)
|
|
150
|
+
const match = this.#match(url.pathname)
|
|
151
|
+
|
|
152
|
+
// Run before hooks
|
|
153
|
+
for (const hook of this.#hooks.before) {
|
|
154
|
+
const cont = await hook({ pathname: url.pathname, match })
|
|
155
|
+
if (cont === false) return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Update global router state
|
|
159
|
+
globalState.set('route', {
|
|
160
|
+
pathname: url.pathname,
|
|
161
|
+
params: match?.params ?? {},
|
|
162
|
+
query: this.#parseQuery(url.search)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
if (!match) {
|
|
166
|
+
this.#outlet.innerHTML = this.#render404(url.pathname)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Transition out
|
|
171
|
+
this.#outlet.classList.add('page-exit')
|
|
172
|
+
await sleep(120)
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const html = await match.route.loader()
|
|
176
|
+
this.#outlet.innerHTML = html
|
|
177
|
+
this.#outlet.classList.remove('page-exit')
|
|
178
|
+
this.#outlet.classList.add('page-enter')
|
|
179
|
+
|
|
180
|
+
// Inject route context into page
|
|
181
|
+
this.#injectRouteContext(match.params, this.#parseQuery(url.search))
|
|
182
|
+
|
|
183
|
+
// Process <component> tags in the loaded page
|
|
184
|
+
await processComponentTags(this.#outlet)
|
|
185
|
+
|
|
186
|
+
// Fire page lifecycle
|
|
187
|
+
await this.#runPageLifecycle(this.#outlet, match.params, this.#parseQuery(url.search))
|
|
188
|
+
|
|
189
|
+
await sleep(10)
|
|
190
|
+
this.#outlet.classList.remove('page-enter')
|
|
191
|
+
} catch (e) {
|
|
192
|
+
console.error('[Router] Failed to load page:', e)
|
|
193
|
+
this.#outlet.innerHTML = this.#renderError(e)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Run after hooks
|
|
197
|
+
for (const hook of this.#hooks.after) {
|
|
198
|
+
await hook({ pathname: url.pathname, match })
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Scroll to top
|
|
202
|
+
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Inject route params/query as data attributes + window.__route
|
|
207
|
+
*/
|
|
208
|
+
#injectRouteContext(params, query) {
|
|
209
|
+
window.__route = { params, query }
|
|
210
|
+
// Also expose on page element
|
|
211
|
+
const page = this.#outlet.firstElementChild
|
|
212
|
+
if (page) {
|
|
213
|
+
Object.entries(params).forEach(([k, v]) => page.dataset[k] = v)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Run onMount lifecycle function if page exports one
|
|
219
|
+
* Pages can define: <script data-lifecycle> export function onMount(ctx) { ... } </script>
|
|
220
|
+
*/
|
|
221
|
+
async #runPageLifecycle(container, params, query) {
|
|
222
|
+
const scripts = container.querySelectorAll('script[data-lifecycle]')
|
|
223
|
+
for (const script of scripts) {
|
|
224
|
+
try {
|
|
225
|
+
// Rewrite root-relative imports to absolute URLs so they resolve
|
|
226
|
+
// correctly when executed from a blob: URL (which has no base path)
|
|
227
|
+
const base = window.location.origin
|
|
228
|
+
const code = script.textContent.replace(
|
|
229
|
+
/from\s+['"](\/[^'"]+)['"]/g,
|
|
230
|
+
(_, path) => `from '${base}${path}'`
|
|
231
|
+
)
|
|
232
|
+
const blob = new Blob([code], { type: 'text/javascript' })
|
|
233
|
+
const blobUrl = URL.createObjectURL(blob)
|
|
234
|
+
const mod = await import(/* @vite-ignore */ blobUrl)
|
|
235
|
+
URL.revokeObjectURL(blobUrl)
|
|
236
|
+
if (typeof mod.onMount === 'function') {
|
|
237
|
+
await mod.onMount({ params, query, globalState })
|
|
238
|
+
}
|
|
239
|
+
} catch (e) {
|
|
240
|
+
console.error('[Router] Page lifecycle error:', e)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Listen for link clicks + popstate
|
|
247
|
+
*/
|
|
248
|
+
#listen() {
|
|
249
|
+
// Intercept all <a href> clicks
|
|
250
|
+
document.addEventListener('click', (e) => {
|
|
251
|
+
const a = e.target.closest('a[href]')
|
|
252
|
+
if (!a) return
|
|
253
|
+
const href = a.getAttribute('href')
|
|
254
|
+
// Skip external, hash-only, or download links
|
|
255
|
+
if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#') || a.hasAttribute('download') || a.target === '_blank') return
|
|
256
|
+
e.preventDefault()
|
|
257
|
+
this.navigate(href)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// Browser back/forward
|
|
261
|
+
window.addEventListener('popstate', () => {
|
|
262
|
+
this.#render(window.location.pathname + window.location.search)
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Hooks API */
|
|
267
|
+
beforeEach(fn) { this.#hooks.before.push(fn); return this }
|
|
268
|
+
afterEach(fn) { this.#hooks.after.push(fn); return this }
|
|
269
|
+
|
|
270
|
+
#render404(path) {
|
|
271
|
+
return `
|
|
272
|
+
<div class="error-page">
|
|
273
|
+
<span class="error-code">404</span>
|
|
274
|
+
<p>No page matches <code>${path}</code></p>
|
|
275
|
+
<a href="/" class="btn">← Back home</a>
|
|
276
|
+
</div>`
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#renderError(e) {
|
|
280
|
+
return `
|
|
281
|
+
<div class="error-page">
|
|
282
|
+
<span class="error-code">ERR</span>
|
|
283
|
+
<p>${e.message}</p>
|
|
284
|
+
<a href="/" class="btn">← Back home</a>
|
|
285
|
+
</div>`
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Get current route state */
|
|
289
|
+
get current() {
|
|
290
|
+
return globalState.get('route')
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function sleep(ms) {
|
|
295
|
+
return new Promise(r => setTimeout(r, ms))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─── Export singleton ────────────────────────────────────────────
|
|
299
|
+
export const router = new Router()
|
|
300
|
+
|
|
301
|
+
export function navigate(path, options) {
|
|
302
|
+
return router.navigate(path, options)
|
|
303
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StateManager — Global + scoped reactive state
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { globalState, createPageState } from './state.js'
|
|
6
|
+
*
|
|
7
|
+
* globalState.set('user', { name: 'Alice' })
|
|
8
|
+
* globalState.get('user')
|
|
9
|
+
* globalState.subscribe('user', (val) => console.log(val))
|
|
10
|
+
*
|
|
11
|
+
* const ps = createPageState({ count: 0 })
|
|
12
|
+
* ps.set('count', ps.get('count') + 1)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
class StateStore {
|
|
16
|
+
#data = {}
|
|
17
|
+
#listeners = {}
|
|
18
|
+
|
|
19
|
+
constructor(initial = {}) {
|
|
20
|
+
this.#data = structuredClone(initial)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get(key) {
|
|
24
|
+
return this.#data[key]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getAll() {
|
|
28
|
+
return structuredClone(this.#data)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
set(key, value) {
|
|
32
|
+
const prev = this.#data[key]
|
|
33
|
+
this.#data[key] = value
|
|
34
|
+
this.#notify(key, value, prev)
|
|
35
|
+
return this
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
update(key, updater) {
|
|
39
|
+
const next = updater(this.#data[key])
|
|
40
|
+
this.set(key, next)
|
|
41
|
+
return this
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
merge(key, partial) {
|
|
45
|
+
const current = this.#data[key] ?? {}
|
|
46
|
+
this.set(key, { ...current, ...partial })
|
|
47
|
+
return this
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
delete(key) {
|
|
51
|
+
const prev = this.#data[key]
|
|
52
|
+
delete this.#data[key]
|
|
53
|
+
this.#notify(key, undefined, prev)
|
|
54
|
+
return this
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
subscribe(key, callback) {
|
|
58
|
+
if (!this.#listeners[key]) this.#listeners[key] = new Set()
|
|
59
|
+
this.#listeners[key].add(callback)
|
|
60
|
+
// Return unsubscribe fn
|
|
61
|
+
return () => this.#listeners[key].delete(callback)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
subscribeAll(callback) {
|
|
65
|
+
return this.subscribe('*', callback)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#notify(key, value, prev) {
|
|
69
|
+
this.#listeners[key]?.forEach(cb => cb(value, prev))
|
|
70
|
+
this.#listeners['*']?.forEach(cb => cb(key, value, prev))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Bind a DOM element's text/value to a state key
|
|
74
|
+
bind(key, element, transform = v => v ?? '') {
|
|
75
|
+
const update = (val) => {
|
|
76
|
+
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT') {
|
|
77
|
+
element.value = transform(val)
|
|
78
|
+
} else {
|
|
79
|
+
element.textContent = transform(val)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
update(this.get(key))
|
|
83
|
+
return this.subscribe(key, update)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Bind with two-way sync (input → state)
|
|
87
|
+
bindInput(key, element) {
|
|
88
|
+
const unsub = this.bind(key, element)
|
|
89
|
+
const handler = (e) => this.set(key, e.target.value)
|
|
90
|
+
element.addEventListener('input', handler)
|
|
91
|
+
return () => {
|
|
92
|
+
unsub()
|
|
93
|
+
element.removeEventListener('input', handler)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
reset(initial = {}) {
|
|
98
|
+
this.#data = structuredClone(initial)
|
|
99
|
+
this.#listeners = {}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Singleton global state ───────────────────────────────────────
|
|
104
|
+
export const globalState = new StateStore({
|
|
105
|
+
theme: 'dark',
|
|
106
|
+
user: null,
|
|
107
|
+
notifications: []
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// ─── Factory for page / component scoped state ───────────────────
|
|
111
|
+
export function createPageState(initial = {}) {
|
|
112
|
+
return new StateStore(initial)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Computed values (derived state) ─────────────────────────────
|
|
116
|
+
export function computed(deps, stores, computeFn) {
|
|
117
|
+
const run = () => computeFn(...deps.map((key, i) => stores[i].get(key)))
|
|
118
|
+
let value = run()
|
|
119
|
+
const listeners = new Set()
|
|
120
|
+
|
|
121
|
+
deps.forEach((key, i) => {
|
|
122
|
+
stores[i].subscribe(key, () => {
|
|
123
|
+
value = run()
|
|
124
|
+
listeners.forEach(cb => cb(value))
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
get: () => value,
|
|
130
|
+
subscribe: (cb) => { listeners.add(cb); return () => listeners.delete(cb) }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ViteFrame — main entry
|
|
3
|
+
* Bootstraps the router, state, and initial render
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { router, navigate } from './core/router.js'
|
|
7
|
+
import { globalState, createPageState, computed } from './core/state.js'
|
|
8
|
+
import { mountComponent, loadComponent, processComponentTags } from './core/components.js'
|
|
9
|
+
|
|
10
|
+
// ─── Theme init ──────────────────────────────────────────────────
|
|
11
|
+
const savedTheme = localStorage.getItem('vf-theme') || 'dark'
|
|
12
|
+
globalState.set('theme', savedTheme)
|
|
13
|
+
document.documentElement.setAttribute('data-theme', savedTheme)
|
|
14
|
+
|
|
15
|
+
globalState.subscribe('theme', (theme) => {
|
|
16
|
+
document.documentElement.setAttribute('data-theme', theme)
|
|
17
|
+
localStorage.setItem('vf-theme', theme)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// ─── Guard example: redirect /admin if not authed ────────────────
|
|
21
|
+
router.beforeEach(({ pathname }) => {
|
|
22
|
+
// if (pathname.startsWith('/admin') && !globalState.get('user')) {
|
|
23
|
+
// router.navigate('/login')
|
|
24
|
+
// return false
|
|
25
|
+
// }
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// ─── Expose ALL core APIs on window.__vf ─────────────────────────
|
|
29
|
+
// Lifecycle scripts access these via window.__vf to avoid bare
|
|
30
|
+
// import() paths that break inside blob: URLs.
|
|
31
|
+
window.__vf = {
|
|
32
|
+
router,
|
|
33
|
+
navigate,
|
|
34
|
+
globalState,
|
|
35
|
+
createPageState,
|
|
36
|
+
computed,
|
|
37
|
+
mountComponent,
|
|
38
|
+
loadComponent,
|
|
39
|
+
processComponentTags,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Initial render ──────────────────────────────────────────────
|
|
43
|
+
router.navigate(window.location.pathname + window.location.search, { replace: true })
|
|
44
|
+
|
|
45
|
+
console.log('%c ViteFrame ⚡ ', 'background:#7c3aed;color:#fff;padding:4px 8px;border-radius:4px;font-weight:bold')
|
|
46
|
+
console.log('Access framework: window.__vf')
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<div class="page">
|
|
2
|
+
<component src="header"></component>
|
|
3
|
+
<main class="page-content">
|
|
4
|
+
<div class="container" style="max-width:680px;">
|
|
5
|
+
|
|
6
|
+
<a href="/" class="btn btn-ghost" style="margin-bottom:24px;display:inline-flex;">← Back</a>
|
|
7
|
+
|
|
8
|
+
<!-- Route info card -->
|
|
9
|
+
<div class="card" style="background:var(--bg-2);border-style:dashed;margin-bottom:24px;">
|
|
10
|
+
<p class="text-xs text-mono text-muted" style="margin-bottom:10px;">
|
|
11
|
+
DYNAMIC ROUTE · [id]post.page.html → /post/:id
|
|
12
|
+
</p>
|
|
13
|
+
<div class="flex gap-6">
|
|
14
|
+
<div>
|
|
15
|
+
<div class="text-xs text-muted">param: id</div>
|
|
16
|
+
<div class="text-mono text-accent" style="font-size:1.4rem;font-weight:700;" id="r-id">—</div>
|
|
17
|
+
</div>
|
|
18
|
+
<div>
|
|
19
|
+
<div class="text-xs text-muted">query string</div>
|
|
20
|
+
<div class="text-mono" style="font-size:.85rem;color:var(--green);" id="r-query">none</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<!-- Post content -->
|
|
26
|
+
<article>
|
|
27
|
+
<span class="badge badge-purple" id="post-cat">—</span>
|
|
28
|
+
<h1 id="post-title" class="mt-4 fade-up">—</h1>
|
|
29
|
+
<p class="text-sm text-muted mt-4" id="post-meta"></p>
|
|
30
|
+
<div class="divider"></div>
|
|
31
|
+
<p id="post-body" class="fade-up delay-1"></p>
|
|
32
|
+
</article>
|
|
33
|
+
|
|
34
|
+
<!-- Prev / Next -->
|
|
35
|
+
<div class="flex-between mt-8">
|
|
36
|
+
<a href="#" id="nav-prev" class="btn btn-ghost">← Prev</a>
|
|
37
|
+
<a href="#" id="nav-next" class="btn btn-ghost">Next →</a>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
</div>
|
|
41
|
+
</main>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<script data-lifecycle>
|
|
45
|
+
const POSTS = [
|
|
46
|
+
{ id:1, title:'Getting started with ViteFrame', cat:'Tutorial', author:'Alice', date:'Jan 2025', body:'ViteFrame is scaffolded directly into your project. Every file in src/core/ is yours to read and modify. Start with router.js to understand how pages are discovered via import.meta.glob, then explore state.js for the reactive store.' },
|
|
47
|
+
{ id:2, title:'Building reactive components', cat:'Guide', author:'Bob', date:'Feb 2025', body:'Components in ViteFrame are plain HTML files with optional <style> and <script> blocks. Props are injected via {{propName}} template syntax before the HTML is inserted into the DOM. Scripts run automatically after mounting.' },
|
|
48
|
+
{ id:3, title:'State management patterns', cat:'Advanced', author:'Carol', date:'Mar 2025', body:'Use globalState for data shared across pages (auth, theme, cart). Use createPageState inside onMount for data scoped to a single page visit — it is garbage collected when you navigate away, keeping memory clean.' },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
export function onMount({ params, query }) {
|
|
52
|
+
const id = parseInt(params.id) || 1
|
|
53
|
+
const post = POSTS.find(p => p.id === id) || POSTS[0]
|
|
54
|
+
const idx = POSTS.indexOf(post)
|
|
55
|
+
|
|
56
|
+
document.getElementById('r-id').textContent = params.id
|
|
57
|
+
document.getElementById('r-query').textContent = Object.keys(query).length
|
|
58
|
+
? JSON.stringify(query) : 'none'
|
|
59
|
+
|
|
60
|
+
document.getElementById('post-cat').textContent = post.cat
|
|
61
|
+
document.getElementById('post-title').textContent = post.title
|
|
62
|
+
document.getElementById('post-meta').textContent = `By ${post.author} · ${post.date}`
|
|
63
|
+
document.getElementById('post-body').textContent = post.body
|
|
64
|
+
|
|
65
|
+
const prev = POSTS[(idx - 1 + POSTS.length) % POSTS.length]
|
|
66
|
+
const next = POSTS[(idx + 1) % POSTS.length]
|
|
67
|
+
const pEl = document.getElementById('nav-prev')
|
|
68
|
+
const nEl = document.getElementById('nav-next')
|
|
69
|
+
pEl.href = `/post/${prev.id}`; pEl.textContent = `← ${prev.title.slice(0,20)}…`
|
|
70
|
+
nEl.href = `/post/${next.id}`; nEl.textContent = `${next.title.slice(0,20)}… →`
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<div class="page">
|
|
2
|
+
<component src="header"></component>
|
|
3
|
+
<main class="page-content">
|
|
4
|
+
<div class="container" style="max-width:680px;">
|
|
5
|
+
|
|
6
|
+
<p class="text-xs text-mono text-muted mb-4 fade-up">about.page.html → /about</p>
|
|
7
|
+
<h1 class="fade-up">About</h1>
|
|
8
|
+
<p class="mt-4 fade-up delay-1">
|
|
9
|
+
ViteFrame is a minimal SPA framework scaffolded into your project — no black boxes,
|
|
10
|
+
no hidden build steps. The entire framework is three files totalling ~380 lines of
|
|
11
|
+
readable JavaScript that live in your <code>src/core/</code> folder.
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<div class="divider mt-8"></div>
|
|
15
|
+
|
|
16
|
+
<h2 class="mt-8 fade-up">Project structure</h2>
|
|
17
|
+
<div class="code-block mt-4 fade-up delay-1">src/
|
|
18
|
+
├── core/
|
|
19
|
+
│ ├── router.js <span class="cmt"># File-based SPA router</span>
|
|
20
|
+
│ ├── state.js <span class="cmt"># Reactive state (global + page-scoped)</span>
|
|
21
|
+
│ └── components.js <span class="cmt"># Dynamic HTML component loader</span>
|
|
22
|
+
├── pages/
|
|
23
|
+
│ ├── landing.page.html <span class="cmt"># → /</span>
|
|
24
|
+
│ ├── about.page.html <span class="cmt"># → /about</span>
|
|
25
|
+
│ ├── docs.page.html <span class="cmt"># → /docs</span>
|
|
26
|
+
│ └── [id]post.page.html <span class="cmt"># → /post/:id (dynamic)</span>
|
|
27
|
+
├── components/
|
|
28
|
+
│ ├── header.comp.html
|
|
29
|
+
│ └── toast.comp.html
|
|
30
|
+
├── styles/
|
|
31
|
+
│ └── main.css <span class="cmt"># Design tokens + utilities</span>
|
|
32
|
+
└── main.js <span class="cmt"># Bootstrap</span></div>
|
|
33
|
+
|
|
34
|
+
<div class="flex gap-4 mt-8 fade-up">
|
|
35
|
+
<a href="/docs" class="btn btn-primary">Read the docs →</a>
|
|
36
|
+
<a href="/" class="btn btn-ghost">← Home</a>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</main>
|
|
40
|
+
</div>
|