@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/view",
3
- "version": "0.2.4",
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.1.4",
32
- "@strav/http": "0.1.4"
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()