@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 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 }