@strav/view 0.2.4 → 0.2.7
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 +4 -3
- package/src/client/index.ts +13 -0
- package/src/client/route_helper.ts +181 -0
- package/src/index.ts +4 -0
- package/src/islands/island_builder.ts +58 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/view",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "View layer for the Strav framework — template engine, Vue SFC islands, and SPA client router",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,14 +22,15 @@
|
|
|
22
22
|
],
|
|
23
23
|
"exports": {
|
|
24
24
|
".": "./src/index.ts",
|
|
25
|
+
"./client": "./src/client/index.ts",
|
|
25
26
|
"./client/*": "./src/client/*.ts",
|
|
26
27
|
"./islands/*": "./src/islands/*.ts"
|
|
27
28
|
},
|
|
28
29
|
"peerDependencies": {
|
|
29
30
|
"vue": "^3.5.0",
|
|
30
31
|
"sass": "^1.80.0",
|
|
31
|
-
"@strav/kernel": "0.
|
|
32
|
-
"@strav/http": "0.
|
|
32
|
+
"@strav/kernel": "0.2.6",
|
|
33
|
+
"@strav/http": "0.2.6"
|
|
33
34
|
},
|
|
34
35
|
"peerDependenciesMeta": {
|
|
35
36
|
"sass": {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe exports from @strav/view
|
|
3
|
+
*
|
|
4
|
+
* This module contains only client-side functionality that can be safely
|
|
5
|
+
* bundled for the browser without pulling in Node.js dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Re-export only browser-safe route helpers
|
|
9
|
+
export { route, routeUrl, registerRoutes } from './route_helper.ts'
|
|
10
|
+
export type { RouteOptions } from './route_helper.ts'
|
|
11
|
+
|
|
12
|
+
// Re-export client-side router (SPA router)
|
|
13
|
+
export * from './router.ts'
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe route helpers that work without the server-side router instance.
|
|
3
|
+
*
|
|
4
|
+
* These functions maintain the same API as the server-side route helpers but
|
|
5
|
+
* require route definitions to be provided at runtime or fallback to URL construction.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface RouteOptions extends Omit<RequestInit, 'body'> {
|
|
9
|
+
params?: Record<string, any>
|
|
10
|
+
body?: any
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Global registry for client-side route definitions
|
|
14
|
+
const clientRoutes = new Map<string, { method: string; pattern: string }>()
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register route definitions for client-side use.
|
|
18
|
+
* This should be called during app initialization with route data from the server.
|
|
19
|
+
*/
|
|
20
|
+
export function registerRoutes(routes: Record<string, { method: string; pattern: string }>) {
|
|
21
|
+
Object.entries(routes).forEach(([name, def]) => {
|
|
22
|
+
clientRoutes.set(name, def)
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate a URL for a named route with optional parameters.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* const profileUrl = routeUrl('users.profile', { id: 456 })
|
|
31
|
+
* // Returns '/users/456'
|
|
32
|
+
*/
|
|
33
|
+
export function routeUrl(name: string, params?: Record<string, any>): string {
|
|
34
|
+
const routeDef = clientRoutes.get(name)
|
|
35
|
+
if (!routeDef) {
|
|
36
|
+
throw new Error(`Route '${name}' not found. Make sure to call registerRoutes() with route definitions.`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return generateUrl(routeDef.pattern, params)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Invoke a named route with automatic method detection and smart defaults.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* // Simple POST with JSON body
|
|
47
|
+
* await route('auth.register', {
|
|
48
|
+
* name: 'John',
|
|
49
|
+
* email: 'john@example.com',
|
|
50
|
+
* password: 'secret'
|
|
51
|
+
* })
|
|
52
|
+
*
|
|
53
|
+
* // GET with URL parameters
|
|
54
|
+
* await route('users.show', { params: { id: 123 } })
|
|
55
|
+
*/
|
|
56
|
+
export async function route(
|
|
57
|
+
name: string,
|
|
58
|
+
data?: any,
|
|
59
|
+
options?: RouteOptions
|
|
60
|
+
): Promise<Response> {
|
|
61
|
+
const routeDef = clientRoutes.get(name)
|
|
62
|
+
if (!routeDef) {
|
|
63
|
+
throw new Error(`Route '${name}' not found. Make sure to call registerRoutes() with route definitions.`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Determine if data is the body or options
|
|
67
|
+
let body: any
|
|
68
|
+
let opts: RouteOptions = {}
|
|
69
|
+
|
|
70
|
+
if (data !== undefined) {
|
|
71
|
+
// If data has params, body, or any RequestInit properties, treat it as options
|
|
72
|
+
if (
|
|
73
|
+
typeof data === 'object' &&
|
|
74
|
+
!Array.isArray(data) &&
|
|
75
|
+
!(data instanceof FormData) &&
|
|
76
|
+
!(data instanceof Blob) &&
|
|
77
|
+
!(data instanceof ArrayBuffer) &&
|
|
78
|
+
!(data instanceof URLSearchParams) &&
|
|
79
|
+
('params' in data || 'body' in data || 'headers' in data || 'cache' in data ||
|
|
80
|
+
'credentials' in data || 'mode' in data || 'redirect' in data || 'referrer' in data)
|
|
81
|
+
) {
|
|
82
|
+
opts = data
|
|
83
|
+
body = opts.body
|
|
84
|
+
} else {
|
|
85
|
+
// Otherwise, treat data as the body
|
|
86
|
+
body = data
|
|
87
|
+
opts = options || {}
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
opts = options || {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Generate URL with parameters
|
|
94
|
+
const generatedUrl = generateUrl(routeDef.pattern, opts.params)
|
|
95
|
+
|
|
96
|
+
// Determine method from route definition
|
|
97
|
+
const method = opts.method || routeDef.method
|
|
98
|
+
|
|
99
|
+
// Build headers with smart defaults
|
|
100
|
+
const headers = new Headers(opts.headers)
|
|
101
|
+
|
|
102
|
+
// Set default Accept header if not provided
|
|
103
|
+
if (!headers.has('Accept')) {
|
|
104
|
+
headers.set('Accept', 'application/json')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Handle body and Content-Type
|
|
108
|
+
let requestBody: string | FormData | Blob | ArrayBuffer | URLSearchParams | undefined
|
|
109
|
+
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
|
110
|
+
if (body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer || body instanceof URLSearchParams) {
|
|
111
|
+
// Let fetch set the Content-Type for FormData, or use the existing type for Blob/ArrayBuffer
|
|
112
|
+
requestBody = body
|
|
113
|
+
} else if (typeof body === 'object') {
|
|
114
|
+
// JSON body
|
|
115
|
+
if (!headers.has('Content-Type')) {
|
|
116
|
+
headers.set('Content-Type', 'application/json')
|
|
117
|
+
}
|
|
118
|
+
requestBody = JSON.stringify(body)
|
|
119
|
+
} else {
|
|
120
|
+
// String or other primitive
|
|
121
|
+
requestBody = String(body)
|
|
122
|
+
if (!headers.has('Content-Type')) {
|
|
123
|
+
headers.set('Content-Type', 'text/plain')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Set default credentials if not provided
|
|
129
|
+
const credentials = opts.credentials || 'same-origin'
|
|
130
|
+
|
|
131
|
+
// Build final fetch options
|
|
132
|
+
const fetchOptions: RequestInit = {
|
|
133
|
+
...opts,
|
|
134
|
+
method,
|
|
135
|
+
headers,
|
|
136
|
+
credentials,
|
|
137
|
+
...(requestBody !== undefined && { body: requestBody })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Remove our custom properties
|
|
141
|
+
delete (fetchOptions as any).params
|
|
142
|
+
|
|
143
|
+
return fetch(generatedUrl, fetchOptions)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Generate URL from pattern and parameters (browser-safe version)
|
|
148
|
+
*/
|
|
149
|
+
function generateUrl(pattern: string, params?: Record<string, any>): string {
|
|
150
|
+
let url = pattern
|
|
151
|
+
const queryParams: Record<string, string> = {}
|
|
152
|
+
|
|
153
|
+
if (params) {
|
|
154
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
155
|
+
const paramPattern = `:${key}`
|
|
156
|
+
const wildcardPattern = `*${key}`
|
|
157
|
+
|
|
158
|
+
if (url.includes(paramPattern)) {
|
|
159
|
+
// Replace route parameter
|
|
160
|
+
url = url.replace(paramPattern, encodeURIComponent(String(value)))
|
|
161
|
+
} else if (url.includes(wildcardPattern)) {
|
|
162
|
+
// Replace wildcard parameter
|
|
163
|
+
url = url.replace(`/${wildcardPattern}`, `/${encodeURIComponent(String(value))}`)
|
|
164
|
+
} else {
|
|
165
|
+
// Add as query parameter
|
|
166
|
+
queryParams[key] = String(value)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Append query parameters
|
|
172
|
+
const queryString = Object.keys(queryParams)
|
|
173
|
+
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key] ?? '')}`)
|
|
174
|
+
.join('&')
|
|
175
|
+
|
|
176
|
+
if (queryString) {
|
|
177
|
+
url += (url.includes('?') ? '&' : '?') + queryString
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return url
|
|
181
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,8 +10,12 @@ export { defineRoutes } from './route_types.ts'
|
|
|
10
10
|
export { spaRoutes } from './spa_routes.ts'
|
|
11
11
|
export { default as ViewProvider } from './providers/view_provider.ts'
|
|
12
12
|
|
|
13
|
+
// Client-side route helpers (browser-safe)
|
|
14
|
+
export { route, routeUrl, registerRoutes } from './client/route_helper.ts'
|
|
15
|
+
|
|
13
16
|
export type { Token, TokenType, VueAttr } from './tokenizer.ts'
|
|
14
17
|
export type { CompilationResult } from './compiler.ts'
|
|
15
18
|
export type { CacheEntry, RenderFunction, IncludeFn, RenderResult } from './cache.ts'
|
|
16
19
|
export type { CssOptions, IslandBuilderOptions, IslandManifest } from './islands/island_builder.ts'
|
|
17
20
|
export type { SpaRouteDefinition } from './route_types.ts'
|
|
21
|
+
export type { RouteOptions } from './client/route_helper.ts'
|
|
@@ -12,6 +12,15 @@ import { vueSfcPlugin } from './vue_plugin.ts'
|
|
|
12
12
|
import ViewEngine from '../engine.ts'
|
|
13
13
|
import type { BunPlugin } from 'bun'
|
|
14
14
|
|
|
15
|
+
// Router type for route injection (optional dependency)
|
|
16
|
+
interface Router {
|
|
17
|
+
getAllRoutes(): readonly {
|
|
18
|
+
name?: string
|
|
19
|
+
method: string
|
|
20
|
+
pattern: string
|
|
21
|
+
}[]
|
|
22
|
+
}
|
|
23
|
+
|
|
15
24
|
export interface CssOptions {
|
|
16
25
|
/** Sass entry file path. e.g. 'resources/css/app.scss' */
|
|
17
26
|
entry: string
|
|
@@ -62,6 +71,7 @@ export class IslandBuilder {
|
|
|
62
71
|
private _manifest: IslandManifest | null = null
|
|
63
72
|
private cssOpts: { entry: string; outFile: string; outDir: string; basePath: string } | null = null
|
|
64
73
|
private _cssVersion: string | null = null
|
|
74
|
+
private router: Router | null = null
|
|
65
75
|
|
|
66
76
|
constructor(options: IslandBuilderOptions = {}) {
|
|
67
77
|
this.islandsDir = resolve(options.islandsDir ?? './resources/islands')
|
|
@@ -137,6 +147,25 @@ export class IslandBuilder {
|
|
|
137
147
|
const lines: string[] = []
|
|
138
148
|
|
|
139
149
|
lines.push(`import { createApp, defineComponent, h, Teleport } from 'vue';`)
|
|
150
|
+
|
|
151
|
+
// Auto-inject route definitions if router is available
|
|
152
|
+
if (this.router) {
|
|
153
|
+
const routeDefinitions = this.extractRouteDefinitions()
|
|
154
|
+
const routeCount = Object.keys(routeDefinitions).length
|
|
155
|
+
|
|
156
|
+
if (routeCount > 0) {
|
|
157
|
+
lines.push(`import { registerRoutes } from '@strav/view/client';`)
|
|
158
|
+
lines.push('')
|
|
159
|
+
lines.push(`// Auto-injected route definitions (${routeCount} routes)`)
|
|
160
|
+
lines.push(`registerRoutes(${JSON.stringify(routeDefinitions, null, 2)});`)
|
|
161
|
+
console.log(`[islands] ✅ Auto-injecting ${routeCount} route definitions:`, Object.keys(routeDefinitions))
|
|
162
|
+
} else {
|
|
163
|
+
console.log(`[islands] ⚠️ Router provided but no named routes found - skipping route auto-injection`)
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
// Silent: No router provided - skipping route auto-injection
|
|
167
|
+
}
|
|
168
|
+
|
|
140
169
|
lines.push('')
|
|
141
170
|
|
|
142
171
|
if (setupPath) {
|
|
@@ -297,6 +326,35 @@ export class IslandBuilder {
|
|
|
297
326
|
)
|
|
298
327
|
}
|
|
299
328
|
|
|
329
|
+
/** Extract route definitions for client-side registration. */
|
|
330
|
+
private extractRouteDefinitions(): Record<string, { method: string; pattern: string }> {
|
|
331
|
+
if (!this.router) return {}
|
|
332
|
+
|
|
333
|
+
const routeMap: Record<string, { method: string; pattern: string }> = {}
|
|
334
|
+
|
|
335
|
+
for (const route of this.router.getAllRoutes()) {
|
|
336
|
+
if (route.name) { // Only include named routes
|
|
337
|
+
routeMap[route.name] = {
|
|
338
|
+
method: route.method,
|
|
339
|
+
pattern: route.pattern
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return routeMap
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Build the islands bundle with route auto-injection.
|
|
349
|
+
* @param router - Router instance containing routes to inject
|
|
350
|
+
*/
|
|
351
|
+
async buildWithRoutes(router: Router): Promise<boolean> {
|
|
352
|
+
this.router = router // Set the router for this build
|
|
353
|
+
const result = await this.build() // Use existing build method
|
|
354
|
+
this.router = null // Clear router reference after build
|
|
355
|
+
return result
|
|
356
|
+
}
|
|
357
|
+
|
|
300
358
|
/** Build the islands bundle. Returns true if islands were found and built. */
|
|
301
359
|
async build(): Promise<boolean> {
|
|
302
360
|
const islands = this.discoverIslands()
|