@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.
@@ -0,0 +1,437 @@
1
+ import { resolve, join, dirname, basename } from 'node:path'
2
+ import {
3
+ readdirSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ unlinkSync,
7
+ watch as fsWatch,
8
+ type FSWatcher,
9
+ } from 'node:fs'
10
+ import { brotliCompressSync, constants as zlibConstants } from 'node:zlib'
11
+ import { vueSfcPlugin } from './vue_plugin.ts'
12
+ import ViewEngine from '../engine.ts'
13
+ import type { BunPlugin } from 'bun'
14
+
15
+ export interface CssOptions {
16
+ /** Sass entry file path. e.g. 'resources/css/app.scss' */
17
+ entry: string
18
+ /** Output filename. Default: derived from entry (app.scss → app.css) */
19
+ outFile?: string
20
+ /** Output directory. Default: './public/css' */
21
+ outDir?: string
22
+ /** Base URL path for the CSS file. Default: '/css/' */
23
+ basePath?: string
24
+ }
25
+
26
+ export interface IslandBuilderOptions {
27
+ /** Directory containing .vue SFC files. Default: './resources/islands' */
28
+ islandsDir?: string
29
+ /** Directory where the bundle is output. Default: './public/builds' */
30
+ outDir?: string
31
+ /** Output filename. Default: 'islands.js' */
32
+ outFile?: string
33
+ /** Enable minification. Default: true in production */
34
+ minify?: boolean
35
+ /** Enable pre-compression (gzip + brotli). Default: true */
36
+ compress?: boolean
37
+ /** Base URL path for the islands script. Default: '/builds/' */
38
+ basePath?: string
39
+ /** Sass CSS compilation options. Requires `sass` package as a peer dependency. */
40
+ css?: CssOptions
41
+ }
42
+
43
+ export interface IslandManifest {
44
+ file: string
45
+ version: string
46
+ src: string
47
+ size: number
48
+ gzip?: number
49
+ brotli?: number
50
+ }
51
+
52
+ export class IslandBuilder {
53
+ private islandsDir: string
54
+ private outDir: string
55
+ private outFile: string
56
+ private minify: boolean
57
+ private compress: boolean
58
+ private basePath: string
59
+ private watcher: FSWatcher | null = null
60
+ private cssWatcher: FSWatcher | null = null
61
+ private _version: string | null = null
62
+ private _manifest: IslandManifest | null = null
63
+ private cssOpts: { entry: string; outFile: string; outDir: string; basePath: string } | null = null
64
+ private _cssVersion: string | null = null
65
+
66
+ constructor(options: IslandBuilderOptions = {}) {
67
+ this.islandsDir = resolve(options.islandsDir ?? './resources/islands')
68
+ this.outDir = resolve(options.outDir ?? './public/builds')
69
+ this.outFile = options.outFile ?? 'islands.js'
70
+ this.minify = options.minify ?? Bun.env.NODE_ENV === 'production'
71
+ this.compress = options.compress ?? true
72
+ this.basePath = options.basePath ?? '/builds/'
73
+
74
+ if (options.css) {
75
+ this.cssOpts = {
76
+ entry: resolve(options.css.entry),
77
+ outFile: options.css.outFile ?? basename(options.css.entry).replace(/\.scss$/, '.css'),
78
+ outDir: resolve(options.css.outDir ?? './public/css'),
79
+ basePath: options.css.basePath ?? '/css/',
80
+ }
81
+ }
82
+ }
83
+
84
+ /** The content hash of the last build, or null if not yet built. */
85
+ get version(): string | null {
86
+ return this._version
87
+ }
88
+
89
+ /** The versioned script src (e.g. '/islands.js?v=abc12345'), or the plain path if not yet built. */
90
+ get src(): string {
91
+ const base = this.basePath + this.outFile
92
+ return this._version ? `${base}?v=${this._version}` : base
93
+ }
94
+
95
+ /** The build manifest with file info and sizes, or null if not yet built. */
96
+ get manifest(): IslandManifest | null {
97
+ return this._manifest
98
+ }
99
+
100
+ /** The versioned CSS src (e.g. '/css/app.css?v=abc12345'), or null if CSS is not configured. */
101
+ get cssSrc(): string | null {
102
+ if (!this.cssOpts) return null
103
+ const base = this.cssOpts.basePath + this.cssOpts.outFile
104
+ return this._cssVersion ? `${base}?v=${this._cssVersion}` : base
105
+ }
106
+
107
+ /** Discover all .vue files in the islands directory (recursively). */
108
+ private discoverIslands(): { name: string; path: string }[] {
109
+ let entries: string[]
110
+ try {
111
+ entries = readdirSync(this.islandsDir, { recursive: true }) as string[]
112
+ } catch {
113
+ return []
114
+ }
115
+
116
+ return entries
117
+ .filter(f => f.endsWith('.vue'))
118
+ .sort()
119
+ .map(f => ({
120
+ name: f.slice(0, -4).replace(/\\/g, '/'),
121
+ path: join(this.islandsDir, f),
122
+ }))
123
+ }
124
+
125
+ /** Check if a setup file exists in the islands directory. */
126
+ private hasSetupFile(): string | null {
127
+ for (const ext of ['ts', 'js']) {
128
+ const p = join(this.islandsDir, `setup.${ext}`)
129
+ if (existsSync(p)) return p
130
+ }
131
+ return null
132
+ }
133
+
134
+ /** Generate the virtual entry point that imports all islands + mount logic. */
135
+ private generateEntry(islands: { name: string; path: string }[]): string {
136
+ const setupPath = this.hasSetupFile()
137
+ const lines: string[] = []
138
+
139
+ lines.push(`import { createApp, defineComponent, h, Teleport } from 'vue';`)
140
+ lines.push('')
141
+
142
+ if (setupPath) {
143
+ lines.push(`import __setup from '${setupPath}';`)
144
+ lines.push('')
145
+ }
146
+
147
+ // Import each island component
148
+ for (let i = 0; i < islands.length; i++) {
149
+ lines.push(`import __c${i} from '${islands[i]!.path}';`)
150
+ }
151
+
152
+ lines.push('')
153
+ lines.push('var components = {')
154
+ for (let i = 0; i < islands.length; i++) {
155
+ lines.push(` '${islands[i]!.name}': __c${i},`)
156
+ }
157
+ lines.push('};')
158
+
159
+ lines.push('')
160
+ lines.push('function mountIslands() {')
161
+ lines.push(' var islands = [];')
162
+ lines.push(" document.querySelectorAll('[data-vue]').forEach(function(el) {")
163
+ lines.push(' var name = el.dataset.vue;')
164
+ lines.push(' if (!name) return;')
165
+ lines.push(' var Component = components[name];')
166
+ lines.push(' if (!Component) {')
167
+ lines.push(" console.warn('[islands] Unknown component: ' + name);")
168
+ lines.push(' return;')
169
+ lines.push(' }')
170
+ lines.push(" var props = JSON.parse(el.dataset.props || '{}');")
171
+ lines.push(' islands.push({ Component: Component, props: props, el: el });')
172
+ lines.push(' });')
173
+ lines.push('')
174
+ lines.push(' if (islands.length === 0) return;')
175
+ lines.push('')
176
+ lines.push(' var Root = defineComponent({')
177
+ lines.push(' render: function() {')
178
+ lines.push(' return islands.map(function(island) {')
179
+ lines.push(
180
+ ' return h(Teleport, { to: island.el }, [h(island.Component, island.props)]);'
181
+ )
182
+ lines.push(' });')
183
+ lines.push(' }')
184
+ lines.push(' });')
185
+ lines.push('')
186
+ lines.push(' var app = createApp(Root);')
187
+ if (setupPath) {
188
+ lines.push(' if (typeof __setup === "function") __setup(app);')
189
+ }
190
+ lines.push(' var root = document.createElement("div");')
191
+ lines.push(' root.style.display = "contents";')
192
+ lines.push(' document.body.appendChild(root);')
193
+ lines.push(' app.mount(root);')
194
+ lines.push('}')
195
+ lines.push('')
196
+ lines.push("if (document.readyState === 'loading') {")
197
+ lines.push(" document.addEventListener('DOMContentLoaded', mountIslands);")
198
+ lines.push('} else {')
199
+ lines.push(' mountIslands();')
200
+ lines.push('}')
201
+
202
+ return lines.join('\n')
203
+ }
204
+
205
+ /** Compute a short content hash for cache busting. */
206
+ private computeHash(content: Uint8Array): string {
207
+ const hasher = new Bun.CryptoHasher('md5')
208
+ hasher.update(content)
209
+ return hasher.digest('hex').slice(0, 8)
210
+ }
211
+
212
+ /** Generate pre-compressed versions of the bundle. */
213
+ private async generateCompressed(
214
+ outPath: string,
215
+ content: Uint8Array
216
+ ): Promise<{ gzip?: number; brotli?: number }> {
217
+ const sizes: { gzip?: number; brotli?: number } = {}
218
+
219
+ // Gzip
220
+ const gzipped = Bun.gzipSync(content as Uint8Array<ArrayBuffer>)
221
+ await Bun.write(outPath + '.gz', gzipped)
222
+ sizes.gzip = gzipped.length
223
+
224
+ // Brotli
225
+ try {
226
+ const brotli = brotliCompressSync(Buffer.from(content), {
227
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 },
228
+ })
229
+ await Bun.write(outPath + '.br', brotli)
230
+ sizes.brotli = brotli.length
231
+ } catch {
232
+ // Brotli may not be available in all environments
233
+ }
234
+
235
+ return sizes
236
+ }
237
+
238
+ /** Remove stale compressed files. */
239
+ private cleanCompressed(outPath: string): void {
240
+ for (const ext of ['.gz', '.br']) {
241
+ try {
242
+ unlinkSync(outPath + ext)
243
+ } catch {
244
+ // File may not exist
245
+ }
246
+ }
247
+ }
248
+
249
+ /** Update the ViewEngine global so @islands() picks up the versioned src. */
250
+ private syncViewEngine(): void {
251
+ try {
252
+ ViewEngine.setGlobal('__islandsSrc', this.src)
253
+ if (this.cssSrc) {
254
+ ViewEngine.setGlobal('__cssSrc', this.cssSrc)
255
+ }
256
+ } catch {
257
+ // ViewEngine may not be initialized yet
258
+ }
259
+ }
260
+
261
+ /** Compile Sass entry to CSS. */
262
+ async buildCss(): Promise<void> {
263
+ if (!this.cssOpts) return
264
+
265
+ const sass = await import('sass')
266
+ const result = sass.compile(this.cssOpts.entry, {
267
+ style: this.minify ? 'compressed' : 'expanded',
268
+ })
269
+
270
+ mkdirSync(this.cssOpts.outDir, { recursive: true })
271
+ const outPath = join(this.cssOpts.outDir, this.cssOpts.outFile)
272
+ await Bun.write(outPath, result.css)
273
+
274
+ const content = new Uint8Array(Buffer.from(result.css))
275
+ this._cssVersion = this.computeHash(content)
276
+
277
+ let compressedSizes: { gzip?: number; brotli?: number } = {}
278
+ if (this.compress) {
279
+ compressedSizes = await this.generateCompressed(outPath, content)
280
+ } else {
281
+ this.cleanCompressed(outPath)
282
+ }
283
+
284
+ this.syncViewEngine()
285
+
286
+ const entryName = basename(this.cssOpts.entry)
287
+ const sizeKB = (content.length / 1024).toFixed(1)
288
+ const gzKB = compressedSizes.gzip
289
+ ? ` | gzip: ${(compressedSizes.gzip / 1024).toFixed(1)}kB`
290
+ : ''
291
+ const brKB = compressedSizes.brotli
292
+ ? ` | br: ${(compressedSizes.brotli / 1024).toFixed(1)}kB`
293
+ : ''
294
+
295
+ console.log(
296
+ `[css] Built ${entryName} → ${this.cssOpts.outFile} (${sizeKB}kB${gzKB}${brKB}) v=${this._cssVersion}`
297
+ )
298
+ }
299
+
300
+ /** Build the islands bundle. Returns true if islands were found and built. */
301
+ async build(): Promise<boolean> {
302
+ const islands = this.discoverIslands()
303
+
304
+ if (islands.length === 0) {
305
+ // Still build CSS even if there are no islands
306
+ await this.buildCss()
307
+ return false
308
+ }
309
+
310
+ // Ensure output directory exists
311
+ mkdirSync(this.outDir, { recursive: true })
312
+
313
+ const entrySource = this.generateEntry(islands)
314
+
315
+ // Virtual entry plugin — resolves the synthetic entry from memory
316
+ const virtualEntryPlugin: BunPlugin = {
317
+ name: 'virtual-entry',
318
+ setup(build) {
319
+ build.onResolve({ filter: /^virtual:islands-entry$/ }, () => ({
320
+ path: 'virtual:islands-entry',
321
+ namespace: 'island-entry',
322
+ }))
323
+
324
+ build.onLoad({ filter: /.*/, namespace: 'island-entry' }, () => ({
325
+ contents: entrySource,
326
+ loader: 'js',
327
+ }))
328
+ },
329
+ }
330
+
331
+ const result = await Bun.build({
332
+ entrypoints: ['virtual:islands-entry'],
333
+ outdir: this.outDir,
334
+ naming: this.outFile,
335
+ format: 'iife',
336
+ minify: this.minify,
337
+ target: 'browser',
338
+ plugins: [virtualEntryPlugin, vueSfcPlugin()],
339
+ define: {
340
+ __VUE_OPTIONS_API__: 'true',
341
+ __VUE_PROD_DEVTOOLS__: 'false',
342
+ __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
343
+ },
344
+ })
345
+
346
+ if (!result.success) {
347
+ const messages = result.logs.map(l => l.message ?? String(l)).join('\n')
348
+ throw new Error(`Island build failed:\n${messages}`)
349
+ }
350
+
351
+ // Read the output, compute version hash, optionally compress
352
+ const outPath = join(this.outDir, this.outFile)
353
+ const content = new Uint8Array(await Bun.file(outPath).arrayBuffer())
354
+
355
+ this._version = this.computeHash(content)
356
+
357
+ let compressedSizes: { gzip?: number; brotli?: number } = {}
358
+ if (this.compress) {
359
+ compressedSizes = await this.generateCompressed(outPath, content)
360
+ } else {
361
+ this.cleanCompressed(outPath)
362
+ }
363
+
364
+ this._manifest = {
365
+ file: this.outFile,
366
+ version: this._version,
367
+ src: this.src,
368
+ size: content.length,
369
+ ...compressedSizes,
370
+ }
371
+
372
+ // Write manifest
373
+ await Bun.write(
374
+ join(this.outDir, this.outFile.replace(/\.js$/, '.manifest.json')),
375
+ JSON.stringify(this._manifest, null, 2)
376
+ )
377
+
378
+ // Sync version with ViewEngine
379
+ this.syncViewEngine()
380
+
381
+ const sizeKB = (content.length / 1024).toFixed(1)
382
+ const gzKB = compressedSizes.gzip
383
+ ? ` | gzip: ${(compressedSizes.gzip / 1024).toFixed(1)}kB`
384
+ : ''
385
+ const brKB = compressedSizes.brotli
386
+ ? ` | br: ${(compressedSizes.brotli / 1024).toFixed(1)}kB`
387
+ : ''
388
+
389
+ console.log(
390
+ `[islands] Built ${islands.length} component(s) → ${this.outFile} (${sizeKB}kB${gzKB}${brKB}) v=${this._version}`
391
+ )
392
+
393
+ // Build CSS if configured
394
+ await this.buildCss()
395
+
396
+ return true
397
+ }
398
+
399
+ /** Watch the islands directory and rebuild on changes. */
400
+ watch(): void {
401
+ if (!this.watcher) {
402
+ // Only build if not already built (avoids duplicate Bun.build() in same process)
403
+ if (!this._version) {
404
+ this.build().catch(err => console.error('[islands] Build error:', err))
405
+ }
406
+
407
+ this.watcher = fsWatch(this.islandsDir, { recursive: true }, (_event, filename) => {
408
+ if (filename && !filename.endsWith('.vue') && !filename.startsWith('setup.')) return
409
+ console.log('[islands] Change detected, rebuilding...')
410
+ this.build().catch(err => console.error('[islands] Rebuild error:', err))
411
+ })
412
+
413
+ console.log(`[islands] Watching ${this.islandsDir}`)
414
+ }
415
+
416
+ // Watch CSS source directory
417
+ if (this.cssOpts && !this.cssWatcher) {
418
+ const cssDir = dirname(this.cssOpts.entry)
419
+
420
+ this.cssWatcher = fsWatch(cssDir, { recursive: true }, (_event, filename) => {
421
+ if (filename && !filename.endsWith('.scss') && !filename.endsWith('.css')) return
422
+ console.log('[css] Change detected, recompiling...')
423
+ this.buildCss().catch(err => console.error('[css] Build error:', err))
424
+ })
425
+
426
+ console.log(`[css] Watching ${cssDir}`)
427
+ }
428
+ }
429
+
430
+ /** Stop watching. */
431
+ unwatch(): void {
432
+ this.watcher?.close()
433
+ this.watcher = null
434
+ this.cssWatcher?.close()
435
+ this.cssWatcher = null
436
+ }
437
+ }
@@ -0,0 +1,136 @@
1
+ import { parse, compileScript, compileTemplate, compileStyle } from '@vue/compiler-sfc'
2
+ import type { BunPlugin } from 'bun'
3
+
4
+ function hashId(path: string): string {
5
+ const hasher = new Bun.CryptoHasher('md5')
6
+ hasher.update(path)
7
+ return hasher.digest('hex').slice(0, 8)
8
+ }
9
+
10
+ export function vueSfcPlugin(): BunPlugin {
11
+ return {
12
+ name: 'vue-sfc',
13
+ setup(build) {
14
+ build.onLoad({ filter: /\.vue$/ }, async args => {
15
+ const source = await Bun.file(args.path).text()
16
+ const id = hashId(args.path)
17
+ const scopeId = `data-v-${id}`
18
+ const hasScoped = false // computed below
19
+ const { descriptor, errors } = parse(source, { filename: args.path })
20
+
21
+ if (errors.length > 0) {
22
+ throw new Error(
23
+ `Vue SFC parse error in ${args.path}:\n${errors.map(e => e.message).join('\n')}`
24
+ )
25
+ }
26
+
27
+ const scoped = descriptor.styles.some(s => s.scoped)
28
+
29
+ // ── Script ────────────────────────────────────────────────────────
30
+ let scriptCode = ''
31
+ let bindings: Record<string, any> | undefined
32
+
33
+ if (descriptor.script || descriptor.scriptSetup) {
34
+ const result = compileScript(descriptor, {
35
+ id,
36
+ inlineTemplate: !!descriptor.scriptSetup,
37
+ sourceMap: false,
38
+ templateOptions: scoped
39
+ ? {
40
+ scoped: true,
41
+ id,
42
+ compilerOptions: { scopeId },
43
+ }
44
+ : undefined,
45
+ })
46
+ scriptCode = result.content
47
+ bindings = result.bindings
48
+ }
49
+
50
+ // ── Template (Options API only — script setup uses inlineTemplate) ─
51
+ let templateCode = ''
52
+
53
+ if (descriptor.template && !descriptor.scriptSetup) {
54
+ const result = compileTemplate({
55
+ source: descriptor.template.content,
56
+ filename: args.path,
57
+ id,
58
+ scoped,
59
+ compilerOptions: {
60
+ bindingMetadata: bindings,
61
+ scopeId: scoped ? scopeId : undefined,
62
+ },
63
+ })
64
+
65
+ if (result.errors.length > 0) {
66
+ throw new Error(
67
+ `Vue template error in ${args.path}:\n${result.errors.map(e => (typeof e === 'string' ? e : e.message)).join('\n')}`
68
+ )
69
+ }
70
+
71
+ templateCode = result.code
72
+ }
73
+
74
+ // ── Styles ────────────────────────────────────────────────────────
75
+ const styles: string[] = []
76
+
77
+ for (const styleBlock of descriptor.styles) {
78
+ const result = compileStyle({
79
+ source: styleBlock.content,
80
+ filename: args.path,
81
+ id: scopeId,
82
+ scoped: !!styleBlock.scoped,
83
+ })
84
+
85
+ if (result.errors.length > 0) {
86
+ console.warn(`[vue-sfc] Style warning in ${args.path}:`, result.errors)
87
+ }
88
+
89
+ styles.push(result.code)
90
+ }
91
+
92
+ // ── Assemble ──────────────────────────────────────────────────────
93
+ let output = ''
94
+
95
+ // Inject styles at module load time
96
+ if (styles.length > 0) {
97
+ const css = JSON.stringify(styles.join('\n'))
98
+ output += `(function(){var s=document.createElement('style');s.textContent=${css};document.head.appendChild(s)})();\n`
99
+ }
100
+
101
+ if (descriptor.scriptSetup) {
102
+ // <script setup> with inlineTemplate — scriptCode is a complete module
103
+ // Rewrite the default export to capture the component and set __scopeId
104
+ if (scoped) {
105
+ output += scriptCode.replace(/export\s+default\s+/, 'const __sfc__ = ') + '\n'
106
+ output += `__sfc__.__scopeId = ${JSON.stringify(scopeId)};\n`
107
+ output += 'export default __sfc__;\n'
108
+ } else {
109
+ output += scriptCode + '\n'
110
+ }
111
+ } else {
112
+ // Options API — stitch script + template render function
113
+ if (scriptCode) {
114
+ output += scriptCode.replace(/export\s+default\s*\{/, 'const __component__ = {') + '\n'
115
+ } else {
116
+ output += 'const __component__ = {};\n'
117
+ }
118
+
119
+ if (templateCode) {
120
+ output += templateCode + '\n'
121
+ output += '__component__.render = render;\n'
122
+ }
123
+
124
+ if (scoped) {
125
+ output += `__component__.__scopeId = ${JSON.stringify(scopeId)};\n`
126
+ }
127
+
128
+ output += 'export default __component__;\n'
129
+ }
130
+
131
+ const isTs = descriptor.script?.lang === 'ts' || descriptor.scriptSetup?.lang === 'ts'
132
+ return { contents: output, loader: isTs ? 'ts' : 'js' }
133
+ })
134
+ },
135
+ }
136
+ }
@@ -0,0 +1,43 @@
1
+ import ServiceProvider from '@stravigor/kernel/core/service_provider'
2
+ import type Application from '@stravigor/kernel/core/application'
3
+ import Configuration from '@stravigor/kernel/config/configuration'
4
+ import Context from '@stravigor/http/http/context'
5
+ import ViewEngine from '../engine.ts'
6
+ import { AssetVersioner } from '../asset_versioner.ts'
7
+
8
+ export default class ViewProvider extends ServiceProvider {
9
+ readonly name = 'view'
10
+ override readonly dependencies = ['config']
11
+
12
+ private assets: AssetVersioner | null = null
13
+
14
+ override register(app: Application): void {
15
+ app.singleton(ViewEngine)
16
+ }
17
+
18
+ override async boot(app: Application): Promise<void> {
19
+ const engine = app.resolve(ViewEngine)
20
+ Context.setViewEngine(engine)
21
+
22
+ const config = app.resolve(Configuration)
23
+ const assetPaths = config.get('view.assets', []) as string[]
24
+ if (!assetPaths.length) return
25
+
26
+ const publicDir = config.get('http.public', './public') as string
27
+ this.assets = new AssetVersioner(publicDir)
28
+
29
+ await Promise.all(assetPaths.map(path => this.assets!.add(path)))
30
+ ViewEngine.setGlobal('asset', (path: string) => this.assets!.resolve(path))
31
+
32
+ if (Bun.env.NODE_ENV !== 'production') {
33
+ for (const path of assetPaths) {
34
+ this.assets.watch(path)
35
+ }
36
+ }
37
+ }
38
+
39
+ override shutdown(): void {
40
+ this.assets?.close()
41
+ this.assets = null
42
+ }
43
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared SPA route definition types.
3
+ *
4
+ * Zero dependencies — safe to import in both server and client bundles.
5
+ */
6
+
7
+ /**
8
+ * A single SPA route definition.
9
+ *
10
+ * Shared between server (to register catch-all GET handlers)
11
+ * and client (to match paths and render components).
12
+ */
13
+ export interface SpaRouteDefinition {
14
+ /** URL pattern with named params, e.g. '/projects/:id/chat' */
15
+ path: string
16
+ /** Unique route name for programmatic navigation */
17
+ name: string
18
+ /** View component key — maps to a component in the views registry */
19
+ view: string
20
+ /**
21
+ * Map raw URL params (all strings) to component props.
22
+ * When omitted, no props are passed to the view.
23
+ */
24
+ props?: (params: Record<string, string>) => Record<string, unknown>
25
+ }
26
+
27
+ /**
28
+ * Identity function that provides type inference for route definitions.
29
+ * Zero dependencies — safe to import in any environment.
30
+ */
31
+ export function defineRoutes(routes: SpaRouteDefinition[]): SpaRouteDefinition[] {
32
+ return routes
33
+ }
@@ -0,0 +1,25 @@
1
+ import type Router from '@stravigor/http/http/router'
2
+ import type { HandlerInput } from '@stravigor/http/http/router'
3
+ import type { SpaRouteDefinition } from './route_types.ts'
4
+
5
+ /**
6
+ * Register SPA route definitions as GET handlers on the server router.
7
+ *
8
+ * Each route path gets the same handler (typically the controller that
9
+ * renders the SPA shell template). This eliminates the need to manually
10
+ * list every client-side route on the server.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * spaRoutes(r, routeDefs, [AppController, 'index'])
15
+ * ```
16
+ */
17
+ export function spaRoutes(
18
+ router: Router,
19
+ routes: readonly SpaRouteDefinition[],
20
+ handler: HandlerInput,
21
+ ): void {
22
+ for (const route of routes) {
23
+ router.get(route.path, handler)
24
+ }
25
+ }