@strav/view 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +49 -0
- package/src/asset_versioner.ts +92 -0
- package/src/cache.ts +47 -0
- package/src/client/islands.ts +84 -0
- package/src/client/router.ts +272 -0
- package/src/compiler.ts +293 -0
- package/src/engine.ts +162 -0
- package/src/escape.ts +14 -0
- package/src/index.ts +17 -0
- package/src/islands/island_builder.ts +437 -0
- package/src/islands/vue_plugin.ts +136 -0
- package/src/providers/view_provider.ts +43 -0
- package/src/route_types.ts +33 -0
- package/src/spa_routes.ts +25 -0
- package/src/tokenizer.ts +186 -0
- package/tsconfig.json +5 -0
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strav/view",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "View layer for the Strav framework — template engine, Vue SFC islands, and SPA client router",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"bun",
|
|
9
|
+
"framework",
|
|
10
|
+
"typescript",
|
|
11
|
+
"strav",
|
|
12
|
+
"view",
|
|
13
|
+
"template",
|
|
14
|
+
"vue",
|
|
15
|
+
"islands"
|
|
16
|
+
],
|
|
17
|
+
"files": [
|
|
18
|
+
"src/",
|
|
19
|
+
"package.json",
|
|
20
|
+
"tsconfig.json",
|
|
21
|
+
"CHANGELOG.md"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./src/index.ts",
|
|
25
|
+
"./client/*": "./src/client/*.ts",
|
|
26
|
+
"./islands/*": "./src/islands/*.ts"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"vue": "^3.5.0",
|
|
30
|
+
"sass": "^1.80.0",
|
|
31
|
+
"@strav/kernel": "0.1.0",
|
|
32
|
+
"@strav/http": "0.1.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"sass": {
|
|
36
|
+
"optional": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@vue/compiler-sfc": "^3.5.28"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"sass": "^1.80.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "bun test tests/",
|
|
47
|
+
"typecheck": "tsc --noEmit"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { join, resolve } from 'node:path'
|
|
2
|
+
import { watch, type FSWatcher } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Appends content-based hashes to public asset URLs for cache busting.
|
|
6
|
+
*
|
|
7
|
+
* Pre-compute hashes at boot with `add()`, then use the sync `resolve()`
|
|
8
|
+
* in templates via a ViewEngine global.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* const assets = new AssetVersioner('./public')
|
|
13
|
+
* await assets.add('/css/app.css')
|
|
14
|
+
*
|
|
15
|
+
* ViewEngine.setGlobal('asset', (path: string) => assets.resolve(path))
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* In templates: `{{ asset('/css/app.css') }}` → `/css/app.css?v=abc12345`
|
|
19
|
+
*/
|
|
20
|
+
export class AssetVersioner {
|
|
21
|
+
private publicDir: string
|
|
22
|
+
private cache = new Map<string, string>()
|
|
23
|
+
private watchers = new Map<string, FSWatcher>()
|
|
24
|
+
|
|
25
|
+
constructor(publicDir: string) {
|
|
26
|
+
this.publicDir = resolve(publicDir)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Compute the content hash for a public asset and cache the versioned URL.
|
|
31
|
+
* Returns the versioned URL (e.g. `/css/app.css?v=abc12345`).
|
|
32
|
+
* If the file doesn't exist, caches and returns the path as-is.
|
|
33
|
+
*/
|
|
34
|
+
async add(publicPath: string): Promise<string> {
|
|
35
|
+
const filePath = join(this.publicDir, publicPath)
|
|
36
|
+
const file = Bun.file(filePath)
|
|
37
|
+
|
|
38
|
+
if (!(await file.exists())) {
|
|
39
|
+
this.cache.set(publicPath, publicPath)
|
|
40
|
+
return publicPath
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const content = new Uint8Array(await file.arrayBuffer())
|
|
44
|
+
const hash = this.computeHash(content)
|
|
45
|
+
const versioned = `${publicPath}?v=${hash}`
|
|
46
|
+
this.cache.set(publicPath, versioned)
|
|
47
|
+
return versioned
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sync lookup — returns the cached versioned URL, or the original path
|
|
52
|
+
* if the asset hasn't been added yet.
|
|
53
|
+
*/
|
|
54
|
+
resolve(publicPath: string): string {
|
|
55
|
+
return this.cache.get(publicPath) ?? publicPath
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Watch a previously added asset for changes and re-hash automatically.
|
|
60
|
+
* Useful in development when CSS/JS is rebuilt by external watchers.
|
|
61
|
+
*/
|
|
62
|
+
watch(publicPath: string): void {
|
|
63
|
+
if (this.watchers.has(publicPath)) return
|
|
64
|
+
|
|
65
|
+
const filePath = join(this.publicDir, publicPath)
|
|
66
|
+
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
67
|
+
|
|
68
|
+
const watcher = watch(filePath, () => {
|
|
69
|
+
// Debounce — file may be written in chunks
|
|
70
|
+
if (timeout) clearTimeout(timeout)
|
|
71
|
+
timeout = setTimeout(() => {
|
|
72
|
+
this.add(publicPath)
|
|
73
|
+
}, 100)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
this.watchers.set(publicPath, watcher)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Stop all file watchers. */
|
|
80
|
+
close(): void {
|
|
81
|
+
for (const watcher of this.watchers.values()) {
|
|
82
|
+
watcher.close()
|
|
83
|
+
}
|
|
84
|
+
this.watchers.clear()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private computeHash(content: Uint8Array): string {
|
|
88
|
+
const hasher = new Bun.CryptoHasher('md5')
|
|
89
|
+
hasher.update(content)
|
|
90
|
+
return hasher.digest('hex').slice(0, 8)
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface RenderResult {
|
|
2
|
+
output: string
|
|
3
|
+
blocks: Record<string, string>
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type RenderFunction = (
|
|
7
|
+
data: Record<string, unknown>,
|
|
8
|
+
includeFn: IncludeFn
|
|
9
|
+
) => Promise<RenderResult>
|
|
10
|
+
|
|
11
|
+
export type IncludeFn = (name: string, data: Record<string, unknown>) => Promise<string>
|
|
12
|
+
|
|
13
|
+
export interface CacheEntry {
|
|
14
|
+
fn: RenderFunction
|
|
15
|
+
layout?: string
|
|
16
|
+
mtime: number
|
|
17
|
+
filePath: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default class TemplateCache {
|
|
21
|
+
private entries = new Map<string, CacheEntry>()
|
|
22
|
+
|
|
23
|
+
get(name: string): CacheEntry | undefined {
|
|
24
|
+
return this.entries.get(name)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
set(name: string, entry: CacheEntry): void {
|
|
28
|
+
this.entries.set(name, entry)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async isStale(name: string): Promise<boolean> {
|
|
32
|
+
const entry = this.entries.get(name)
|
|
33
|
+
if (!entry) return true
|
|
34
|
+
const file = Bun.file(entry.filePath)
|
|
35
|
+
const exists = await file.exists()
|
|
36
|
+
if (!exists) return true
|
|
37
|
+
return file.lastModified > entry.mtime
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
delete(name: string): void {
|
|
41
|
+
this.entries.delete(name)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
clear(): void {
|
|
45
|
+
this.entries.clear()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @ts-nocheck — Client-side script; requires DOM types provided by the app's bundler config.
|
|
2
|
+
/**
|
|
3
|
+
* Vue Islands Bootstrap
|
|
4
|
+
*
|
|
5
|
+
* Auto-discovers elements with [data-vue] attributes and mounts
|
|
6
|
+
* Vue components on them via a single shared Vue app instance.
|
|
7
|
+
* All islands share the same app context (plugins, provide/inject,
|
|
8
|
+
* global components), connected to their marker elements via Teleport.
|
|
9
|
+
*
|
|
10
|
+
* Register your components on the window before this script runs:
|
|
11
|
+
*
|
|
12
|
+
* import Counter from './components/Counter.vue'
|
|
13
|
+
* window.__vue_components = { counter: Counter }
|
|
14
|
+
*
|
|
15
|
+
* Optionally provide a setup function to install plugins:
|
|
16
|
+
*
|
|
17
|
+
* window.__vue_setup = (app) => {
|
|
18
|
+
* app.use(somePlugin)
|
|
19
|
+
* app.provide('key', value)
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* Then in your .strav templates:
|
|
23
|
+
* <vue:counter :initial="{{ count }}" label="Click me" />
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createApp, defineComponent, h, Teleport } from 'vue'
|
|
27
|
+
|
|
28
|
+
declare global {
|
|
29
|
+
interface Window {
|
|
30
|
+
__vue_components?: Record<string, any>
|
|
31
|
+
__vue_setup?: (app: any) => void
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function toPascalCase(str: string): string {
|
|
36
|
+
return str.replace(/(^|-)(\w)/g, (_match, _sep, char) => char.toUpperCase())
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mountIslands(): void {
|
|
40
|
+
const components = window.__vue_components ?? {}
|
|
41
|
+
|
|
42
|
+
const islands: { Component: any; props: Record<string, any>; el: HTMLElement }[] = []
|
|
43
|
+
|
|
44
|
+
document.querySelectorAll<HTMLElement>('[data-vue]').forEach(el => {
|
|
45
|
+
const name = el.dataset.vue
|
|
46
|
+
if (!name) return
|
|
47
|
+
|
|
48
|
+
const Component = components[name] ?? components[toPascalCase(name)]
|
|
49
|
+
if (!Component) {
|
|
50
|
+
console.warn(`[islands] Unknown component: ${name}`)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const props = JSON.parse(el.dataset.props ?? '{}')
|
|
55
|
+
islands.push({ Component, props, el })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if (islands.length === 0) return
|
|
59
|
+
|
|
60
|
+
const Root = defineComponent({
|
|
61
|
+
render() {
|
|
62
|
+
return islands.map(island =>
|
|
63
|
+
h(Teleport, { to: island.el }, [h(island.Component, island.props)])
|
|
64
|
+
)
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const app = createApp(Root)
|
|
69
|
+
|
|
70
|
+
if (typeof window.__vue_setup === 'function') {
|
|
71
|
+
window.__vue_setup(app)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const root = document.createElement('div')
|
|
75
|
+
root.style.display = 'contents'
|
|
76
|
+
document.body.appendChild(root)
|
|
77
|
+
app.mount(root)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (document.readyState === 'loading') {
|
|
81
|
+
document.addEventListener('DOMContentLoaded', mountIslands)
|
|
82
|
+
} else {
|
|
83
|
+
mountIslands()
|
|
84
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// @ts-nocheck — Client-side script; requires DOM types provided by the app's bundler config.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stravigor Client Router
|
|
5
|
+
*
|
|
6
|
+
* A lightweight Vue 3 router for SPA navigation within the islands architecture.
|
|
7
|
+
* Uses shared route definitions (from defineRoutes) so server and client stay in sync.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* import { createRouter, useRouter, useRoute } from '@stravigor/view/client/router'
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
ref,
|
|
15
|
+
computed,
|
|
16
|
+
inject,
|
|
17
|
+
defineComponent,
|
|
18
|
+
h,
|
|
19
|
+
type App,
|
|
20
|
+
type Plugin,
|
|
21
|
+
type Ref,
|
|
22
|
+
type ComputedRef,
|
|
23
|
+
type InjectionKey,
|
|
24
|
+
type Component,
|
|
25
|
+
type PropType,
|
|
26
|
+
} from 'vue'
|
|
27
|
+
import type { SpaRouteDefinition } from '../route_types.ts'
|
|
28
|
+
export { defineRoutes } from '../route_types.ts'
|
|
29
|
+
export type { SpaRouteDefinition } from '../route_types.ts'
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Types
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** Resolved route — the current matched state. */
|
|
36
|
+
export interface RouteLocation {
|
|
37
|
+
/** The full URL path */
|
|
38
|
+
path: string
|
|
39
|
+
/** Route name, or null if no match */
|
|
40
|
+
name: string | null
|
|
41
|
+
/** Extracted URL params (all strings) */
|
|
42
|
+
params: Record<string, string>
|
|
43
|
+
/** Resolved component props (from route.props function) */
|
|
44
|
+
resolvedProps: Record<string, unknown>
|
|
45
|
+
/** The matched view key, or null if 404 */
|
|
46
|
+
view: string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Navigation target — string path or named route. */
|
|
50
|
+
export type RouteTarget = string | { name: string; params?: Record<string, string | number> }
|
|
51
|
+
|
|
52
|
+
/** Router instance exposed via useRouter(). */
|
|
53
|
+
export interface RouterInstance {
|
|
54
|
+
push(to: RouteTarget): void
|
|
55
|
+
replace(to: RouteTarget): void
|
|
56
|
+
back(): void
|
|
57
|
+
forward(): void
|
|
58
|
+
readonly route: ComputedRef<RouteLocation>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RouterOptions {
|
|
62
|
+
/** Route definitions (from defineRoutes) */
|
|
63
|
+
routes: readonly SpaRouteDefinition[]
|
|
64
|
+
/** Map of view name → Vue component */
|
|
65
|
+
views: Record<string, Component>
|
|
66
|
+
/** Fallback component when no route matches */
|
|
67
|
+
fallback?: Component
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Injection Keys
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
const ROUTER_KEY: InjectionKey<RouterInstance> = Symbol('strav-router')
|
|
75
|
+
const ROUTE_KEY: InjectionKey<ComputedRef<RouteLocation>> = Symbol('strav-route')
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Route Matching
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
interface CompiledRoute {
|
|
82
|
+
definition: SpaRouteDefinition
|
|
83
|
+
regex: RegExp
|
|
84
|
+
paramNames: string[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Compile a route pattern into a RegExp, extracting param names. */
|
|
88
|
+
function compilePattern(pattern: string): { regex: RegExp; paramNames: string[] } {
|
|
89
|
+
const paramNames: string[] = []
|
|
90
|
+
const regexStr = pattern.replace(/:(\w+)/g, (_, name) => {
|
|
91
|
+
paramNames.push(name)
|
|
92
|
+
return '([^/]+)'
|
|
93
|
+
})
|
|
94
|
+
return { regex: new RegExp(`^${regexStr}$`), paramNames }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Build the path for a named route with param substitution. */
|
|
98
|
+
function buildPath(
|
|
99
|
+
route: CompiledRoute,
|
|
100
|
+
params: Record<string, string | number>,
|
|
101
|
+
): string {
|
|
102
|
+
return route.definition.path.replace(/:(\w+)/g, (_, name) => String(params[name]))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Match a path against compiled routes. Returns the first match. */
|
|
106
|
+
function matchRoute(path: string, compiled: CompiledRoute[]): RouteLocation {
|
|
107
|
+
for (const route of compiled) {
|
|
108
|
+
const match = route.regex.exec(path)
|
|
109
|
+
if (match) {
|
|
110
|
+
const params: Record<string, string> = {}
|
|
111
|
+
for (let i = 0; i < route.paramNames.length; i++) {
|
|
112
|
+
params[route.paramNames[i]] = match[i + 1]
|
|
113
|
+
}
|
|
114
|
+
const resolvedProps = route.definition.props
|
|
115
|
+
? route.definition.props(params)
|
|
116
|
+
: {}
|
|
117
|
+
return {
|
|
118
|
+
path,
|
|
119
|
+
name: route.definition.name,
|
|
120
|
+
params,
|
|
121
|
+
resolvedProps,
|
|
122
|
+
view: route.definition.view,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// No match — 404
|
|
127
|
+
return { path, name: null, params: {}, resolvedProps: {}, view: null }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// createRouter — Vue Plugin Factory
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
export function createRouter(options: RouterOptions): Plugin {
|
|
135
|
+
const compiled: CompiledRoute[] = options.routes.map(def => ({
|
|
136
|
+
definition: def,
|
|
137
|
+
...compilePattern(def.path),
|
|
138
|
+
}))
|
|
139
|
+
|
|
140
|
+
const currentPath = ref(window.location.pathname)
|
|
141
|
+
|
|
142
|
+
const currentRoute = computed(() => matchRoute(currentPath.value, compiled))
|
|
143
|
+
|
|
144
|
+
function resolvePath(to: RouteTarget): string {
|
|
145
|
+
if (typeof to === 'string') return to
|
|
146
|
+
const match = compiled.find(r => r.definition.name === to.name)
|
|
147
|
+
if (!match) {
|
|
148
|
+
console.warn(`[strav-router] Unknown route name: ${to.name}`)
|
|
149
|
+
return '/'
|
|
150
|
+
}
|
|
151
|
+
return buildPath(match, to.params ?? {})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function push(to: RouteTarget): void {
|
|
155
|
+
const path = resolvePath(to)
|
|
156
|
+
history.pushState(null, '', path)
|
|
157
|
+
currentPath.value = path
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function replace(to: RouteTarget): void {
|
|
161
|
+
const path = resolvePath(to)
|
|
162
|
+
history.replaceState(null, '', path)
|
|
163
|
+
currentPath.value = path
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const router: RouterInstance = {
|
|
167
|
+
push,
|
|
168
|
+
replace,
|
|
169
|
+
back: () => history.back(),
|
|
170
|
+
forward: () => history.forward(),
|
|
171
|
+
route: currentRoute,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- RouterView --------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
const RouterView = defineComponent({
|
|
177
|
+
name: 'RouterView',
|
|
178
|
+
setup() {
|
|
179
|
+
return () => {
|
|
180
|
+
const r = currentRoute.value
|
|
181
|
+
if (r.view && options.views[r.view]) {
|
|
182
|
+
return h(options.views[r.view], r.resolvedProps)
|
|
183
|
+
}
|
|
184
|
+
if (options.fallback) {
|
|
185
|
+
return h(options.fallback)
|
|
186
|
+
}
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// ---- RouterLink --------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
const RouterLink = defineComponent({
|
|
195
|
+
name: 'RouterLink',
|
|
196
|
+
props: {
|
|
197
|
+
to: { type: [String, Object] as PropType<RouteTarget>, required: true },
|
|
198
|
+
replace: { type: Boolean, default: false },
|
|
199
|
+
},
|
|
200
|
+
setup(props, { slots, attrs }) {
|
|
201
|
+
const href = computed(() => resolvePath(props.to))
|
|
202
|
+
|
|
203
|
+
const isActive = computed(() => {
|
|
204
|
+
const h = href.value
|
|
205
|
+
const p = currentRoute.value.path
|
|
206
|
+
return p === h || p.startsWith(h + '/')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const isExactActive = computed(() => currentRoute.value.path === href.value)
|
|
210
|
+
|
|
211
|
+
function onClick(e: MouseEvent) {
|
|
212
|
+
if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return
|
|
213
|
+
e.preventDefault()
|
|
214
|
+
if (props.replace) {
|
|
215
|
+
replace(href.value)
|
|
216
|
+
} else {
|
|
217
|
+
push(href.value)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return () => h(
|
|
222
|
+
'a',
|
|
223
|
+
{ href: href.value, onClick, ...attrs },
|
|
224
|
+
slots.default?.({
|
|
225
|
+
href: href.value,
|
|
226
|
+
isActive: isActive.value,
|
|
227
|
+
isExactActive: isExactActive.value,
|
|
228
|
+
navigate: onClick,
|
|
229
|
+
}),
|
|
230
|
+
)
|
|
231
|
+
},
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// ---- Plugin install ----------------------------------------------------
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
install(app: App) {
|
|
238
|
+
window.addEventListener('popstate', () => {
|
|
239
|
+
currentPath.value = window.location.pathname
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
app.provide(ROUTER_KEY, router)
|
|
243
|
+
app.provide(ROUTE_KEY, currentRoute)
|
|
244
|
+
|
|
245
|
+
app.component('RouterView', RouterView)
|
|
246
|
+
app.component('RouterLink', RouterLink)
|
|
247
|
+
|
|
248
|
+
// Backward compat: components that inject('navigate') still work
|
|
249
|
+
app.provide('navigate', (to: string) => push(to))
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Composables
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/** Access the router instance for programmatic navigation. */
|
|
259
|
+
export function useRouter(): RouterInstance {
|
|
260
|
+
const router = inject(ROUTER_KEY)
|
|
261
|
+
if (!router) throw new Error('[strav-router] useRouter() called outside of router context')
|
|
262
|
+
return router
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Access the current reactive route location. */
|
|
266
|
+
export function useRoute(): ComputedRef<RouteLocation> {
|
|
267
|
+
const route = inject(ROUTE_KEY)
|
|
268
|
+
if (!route) throw new Error('[strav-router] useRoute() called outside of router context')
|
|
269
|
+
return route
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export type { SpaRouteDefinition }
|