@strav/view 0.2.4 → 0.2.8

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.8",
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": {
package/src/cache.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export interface RenderResult {
2
2
  output: string
3
3
  blocks: Record<string, string>
4
+ stacks: Record<string, string[]>
4
5
  }
5
6
 
6
7
  export type RenderFunction = (
@@ -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/compiler.ts CHANGED
@@ -7,9 +7,10 @@ export interface CompilationResult {
7
7
  }
8
8
 
9
9
  interface StackEntry {
10
- type: 'if' | 'each' | 'section'
10
+ type: 'if' | 'each' | 'section' | 'push' | 'prepend'
11
11
  line: number
12
12
  blockName?: string
13
+ stackName?: string
13
14
  }
14
15
 
15
16
  function escapeJs(str: string): string {
@@ -94,6 +95,7 @@ export function compile(tokens: Token[]): CompilationResult {
94
95
 
95
96
  lines.push('let __out = "";')
96
97
  lines.push('const __blocks = {};')
98
+ lines.push('const __stacks = {};')
97
99
 
98
100
  for (const token of tokens) {
99
101
  switch (token.type) {
@@ -147,7 +149,7 @@ export function compile(tokens: Token[]): CompilationResult {
147
149
  throw new TemplateError(`Unclosed @${unclosed.type} block (opened at line ${unclosed.line})`)
148
150
  }
149
151
 
150
- lines.push('return { output: __out, blocks: __blocks };')
152
+ lines.push('return { output: __out, blocks: __blocks, stacks: __stacks };')
151
153
 
152
154
  return { code: lines.join('\n'), layout }
153
155
  }
@@ -274,6 +276,40 @@ function compileDirective(
274
276
  )
275
277
  break
276
278
 
279
+ case 'push': {
280
+ if (!token.args) throw new TemplateError(`@push requires a name at line ${token.line}`)
281
+ const name = token.args.replace(/^['"]|['"]$/g, '').trim()
282
+ const nameStr = JSON.stringify(name)
283
+ // Initialize stack array if it doesn't exist, then capture content and push
284
+ lines.push(`if (!__stacks[${nameStr}]) __stacks[${nameStr}] = [];`)
285
+ lines.push(`__stacks[${nameStr}].push((function() { let __out = "";`)
286
+ stack.push({ type: 'push', line: token.line, stackName: name })
287
+ break
288
+ }
289
+
290
+ case 'prepend': {
291
+ if (!token.args) throw new TemplateError(`@prepend requires a name at line ${token.line}`)
292
+ const name = token.args.replace(/^['"]|['"]$/g, '').trim()
293
+ const nameStr = JSON.stringify(name)
294
+ // Initialize stack array if it doesn't exist, then capture content and unshift
295
+ lines.push(`if (!__stacks[${nameStr}]) __stacks[${nameStr}] = [];`)
296
+ lines.push(`__stacks[${nameStr}].unshift((function() { let __out = "";`)
297
+ stack.push({ type: 'prepend', line: token.line, stackName: name })
298
+ break
299
+ }
300
+
301
+ case 'stack': {
302
+ if (!token.args) throw new TemplateError(`@stack requires a name at line ${token.line}`)
303
+ const name = token.args.replace(/^['"]|['"]$/g, '').trim()
304
+ const nameStr = JSON.stringify(name)
305
+ // Output joined stack content - merge local and passed stacks
306
+ lines.push(`{`)
307
+ lines.push(` const __mergedStack = [...((__data.__stacks && __data.__stacks[${nameStr}]) || []), ...(__stacks[${nameStr}] || [])];`)
308
+ lines.push(` __out += __mergedStack.join('');`)
309
+ lines.push(`}`)
310
+ break
311
+ }
312
+
277
313
  case 'end': {
278
314
  if (!stack.length) {
279
315
  throw new TemplateError(`Unexpected @end at line ${token.line} — no open block`)
@@ -281,6 +317,8 @@ function compileDirective(
281
317
  const top = stack.pop()!
282
318
  if (top.type === 'section') {
283
319
  lines.push(` return __out; })();`)
320
+ } else if (top.type === 'push' || top.type === 'prepend') {
321
+ lines.push(` return __out; })());`)
284
322
  } else if (top.type === 'each') {
285
323
  lines.push(` }`) // close for loop
286
324
  lines.push(`}`) // close block scope
package/src/engine.ts CHANGED
@@ -48,7 +48,8 @@ export default class ViewEngine {
48
48
  private async renderWithDepth(
49
49
  name: string,
50
50
  data: Record<string, unknown>,
51
- depth: number
51
+ depth: number,
52
+ parentStacks: Record<string, string[]> = {}
52
53
  ): Promise<string> {
53
54
  if (depth > MAX_INCLUDE_DEPTH) {
54
55
  throw new TemplateError(
@@ -58,21 +59,82 @@ export default class ViewEngine {
58
59
 
59
60
  const entry = await this.resolve(name)
60
61
 
61
- const includeFn: IncludeFn = (includeName, includeData) => {
62
- return this.renderWithDepth(includeName, { ...data, ...includeData }, depth + 1)
62
+ // Create an include function that merges stacks from includes
63
+ const includeFn: IncludeFn = async (includeName, includeData) => {
64
+ const includeResult = await this.renderWithDepthInternal(
65
+ includeName,
66
+ { ...data, ...includeData },
67
+ depth + 1,
68
+ {}
69
+ )
70
+ // Merge include's stacks into parent stacks
71
+ this.mergeStacks(parentStacks, includeResult.stacks)
72
+ return includeResult.output
63
73
  }
64
74
 
65
- const result = await entry.fn(data, includeFn)
75
+ // Pass parent stacks to the template
76
+ const dataWithStacks = { ...data, __stacks: { ...parentStacks } }
77
+ const result = await entry.fn(dataWithStacks, includeFn)
78
+
79
+ // Merge current template's stacks with parent stacks
80
+ this.mergeStacks(parentStacks, result.stacks)
66
81
 
67
- // Layout inheritance: render child first, then render layout with blocks merged
82
+ // Layout inheritance: render child first, then render layout with blocks and stacks merged
68
83
  if (entry.layout) {
69
- const layoutData = { ...data, ...result.blocks }
70
- return this.renderWithDepth(entry.layout, layoutData, depth + 1)
84
+ const layoutData = { ...data, ...result.blocks, __stacks: parentStacks }
85
+ return this.renderWithDepth(entry.layout, layoutData, depth + 1, parentStacks)
71
86
  }
72
87
 
73
88
  return result.output
74
89
  }
75
90
 
91
+ private async renderWithDepthInternal(
92
+ name: string,
93
+ data: Record<string, unknown>,
94
+ depth: number,
95
+ parentStacks: Record<string, string[]> = {}
96
+ ): Promise<{ output: string; stacks: Record<string, string[]> }> {
97
+ if (depth > MAX_INCLUDE_DEPTH) {
98
+ throw new TemplateError(
99
+ `Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded — possible circular include`
100
+ )
101
+ }
102
+
103
+ const entry = await this.resolve(name)
104
+
105
+ const includeFn: IncludeFn = async (includeName, includeData) => {
106
+ const includeResult = await this.renderWithDepthInternal(
107
+ includeName,
108
+ { ...data, ...includeData },
109
+ depth + 1,
110
+ {}
111
+ )
112
+ this.mergeStacks(parentStacks, includeResult.stacks)
113
+ return includeResult.output
114
+ }
115
+
116
+ const dataWithStacks = { ...data, __stacks: { ...parentStacks } }
117
+ const result = await entry.fn(dataWithStacks, includeFn)
118
+ this.mergeStacks(parentStacks, result.stacks)
119
+
120
+ if (entry.layout) {
121
+ const layoutData = { ...data, ...result.blocks, __stacks: parentStacks }
122
+ const layoutOutput = await this.renderWithDepth(entry.layout, layoutData, depth + 1, parentStacks)
123
+ return { output: layoutOutput, stacks: parentStacks }
124
+ }
125
+
126
+ return { output: result.output, stacks: parentStacks }
127
+ }
128
+
129
+ private mergeStacks(target: Record<string, string[]>, source: Record<string, string[]>): void {
130
+ for (const [key, values] of Object.entries(source)) {
131
+ if (!target[key]) {
132
+ target[key] = []
133
+ }
134
+ target[key].push(...values)
135
+ }
136
+ }
137
+
76
138
  private async resolve(name: string): Promise<CacheEntry> {
77
139
  const cached = this.cache.get(name)
78
140
 
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()
package/src/tokenizer.ts CHANGED
@@ -30,6 +30,9 @@ const DIRECTIVES = new Set([
30
30
  'csrf',
31
31
  'class',
32
32
  'style',
33
+ 'push',
34
+ 'prepend',
35
+ 'stack',
33
36
  ])
34
37
 
35
38
  export function tokenize(source: string): Token[] {