@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 +49 -0
- package/src/asset_versioner.ts +92 -0
- package/src/cache.ts +47 -0
- package/src/client/islands.ts +84 -0
- package/src/client/router.ts +272 -0
- package/src/compiler.ts +293 -0
- package/src/engine.ts +162 -0
- package/src/escape.ts +14 -0
- package/src/index.ts +17 -0
- package/src/islands/island_builder.ts +437 -0
- package/src/islands/vue_plugin.ts +136 -0
- package/src/providers/view_provider.ts +43 -0
- package/src/route_types.ts +33 -0
- package/src/spa_routes.ts +25 -0
- package/src/tokenizer.ts +186 -0
- package/tsconfig.json +5 -0
|
@@ -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
|
+
}
|