@strav/http 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 +4 -3
- package/src/auth/middleware/guest.ts +1 -1
- package/src/client/index.ts +41 -0
- package/src/client/route_helper.ts +181 -0
- package/src/http/context.ts +16 -0
- package/src/http/index.ts +1 -1
- package/src/http/route_helper.ts +60 -0
- package/src/http/router.ts +11 -0
- package/src/index.ts +1 -2
- package/src/view/cache.ts +0 -47
- package/src/view/client/islands.ts +0 -84
- package/src/view/compiler.ts +0 -199
- package/src/view/engine.ts +0 -139
- package/src/view/escape.ts +0 -14
- package/src/view/index.ts +0 -13
- package/src/view/islands/island_builder.ts +0 -338
- package/src/view/islands/vue_plugin.ts +0 -136
- package/src/view/middleware/static.ts +0 -69
- package/src/view/tokenizer.ts +0 -182
package/src/view/compiler.ts
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import { TemplateError } from '@strav/kernel/exceptions/errors'
|
|
2
|
-
import type { Token } from './tokenizer.ts'
|
|
3
|
-
|
|
4
|
-
export interface CompilationResult {
|
|
5
|
-
code: string
|
|
6
|
-
layout?: string
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
interface StackEntry {
|
|
10
|
-
type: 'if' | 'each' | 'block'
|
|
11
|
-
line: number
|
|
12
|
-
blockName?: string
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function escapeJs(str: string): string {
|
|
16
|
-
return str
|
|
17
|
-
.replace(/\\/g, '\\\\')
|
|
18
|
-
.replace(/"/g, '\\"')
|
|
19
|
-
.replace(/\n/g, '\\n')
|
|
20
|
-
.replace(/\r/g, '\\r')
|
|
21
|
-
.replace(/\t/g, '\\t')
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function compile(tokens: Token[]): CompilationResult {
|
|
25
|
-
const lines: string[] = []
|
|
26
|
-
const stack: StackEntry[] = []
|
|
27
|
-
let layout: string | undefined
|
|
28
|
-
|
|
29
|
-
lines.push('let __out = "";')
|
|
30
|
-
lines.push('const __blocks = {};')
|
|
31
|
-
|
|
32
|
-
for (const token of tokens) {
|
|
33
|
-
switch (token.type) {
|
|
34
|
-
case 'text':
|
|
35
|
-
lines.push(`__out += "${escapeJs(token.value)}";`)
|
|
36
|
-
break
|
|
37
|
-
|
|
38
|
-
case 'escaped':
|
|
39
|
-
lines.push(`__out += __escape(${token.value});`)
|
|
40
|
-
break
|
|
41
|
-
|
|
42
|
-
case 'raw':
|
|
43
|
-
lines.push(`__out += (${token.value});`)
|
|
44
|
-
break
|
|
45
|
-
|
|
46
|
-
case 'comment':
|
|
47
|
-
// Stripped from output
|
|
48
|
-
break
|
|
49
|
-
|
|
50
|
-
case 'vue_island': {
|
|
51
|
-
const attrs = token.attrs ?? {}
|
|
52
|
-
const propParts: string[] = []
|
|
53
|
-
for (const [name, attr] of Object.entries(attrs)) {
|
|
54
|
-
if (attr.bound) {
|
|
55
|
-
propParts.push(`${JSON.stringify(name)}: (${attr.value})`)
|
|
56
|
-
} else {
|
|
57
|
-
propParts.push(`${JSON.stringify(name)}: ${JSON.stringify(attr.value)}`)
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
const propsExpr = `{${propParts.join(', ')}}`
|
|
61
|
-
const tag = escapeJs(token.tag!)
|
|
62
|
-
lines.push('__out += \'<div data-vue="' + tag + '"\'')
|
|
63
|
-
lines.push(
|
|
64
|
-
' + " data-props=\'" + JSON.stringify(' + propsExpr + ").replace(/'/g, ''') + \"'\""
|
|
65
|
-
)
|
|
66
|
-
lines.push(" + '></div>';")
|
|
67
|
-
|
|
68
|
-
break
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
case 'directive':
|
|
72
|
-
compileDirective(token, lines, stack, l => {
|
|
73
|
-
layout = l
|
|
74
|
-
})
|
|
75
|
-
break
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (stack.length > 0) {
|
|
80
|
-
const unclosed = stack[stack.length - 1]!
|
|
81
|
-
throw new TemplateError(`Unclosed @${unclosed.type} block (opened at line ${unclosed.line})`)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
lines.push('return { output: __out, blocks: __blocks };')
|
|
85
|
-
|
|
86
|
-
return { code: lines.join('\n'), layout }
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function compileDirective(
|
|
90
|
-
token: Token,
|
|
91
|
-
lines: string[],
|
|
92
|
-
stack: StackEntry[],
|
|
93
|
-
setLayout: (name: string) => void
|
|
94
|
-
): void {
|
|
95
|
-
switch (token.directive) {
|
|
96
|
-
case 'if':
|
|
97
|
-
if (!token.args) throw new TemplateError(`@if requires a condition at line ${token.line}`)
|
|
98
|
-
lines.push(`if (${token.args}) {`)
|
|
99
|
-
stack.push({ type: 'if', line: token.line })
|
|
100
|
-
break
|
|
101
|
-
|
|
102
|
-
case 'elseif':
|
|
103
|
-
if (!token.args) throw new TemplateError(`@elseif requires a condition at line ${token.line}`)
|
|
104
|
-
if (!stack.length || stack[stack.length - 1]!.type !== 'if') {
|
|
105
|
-
throw new TemplateError(`@elseif without matching @if at line ${token.line}`)
|
|
106
|
-
}
|
|
107
|
-
lines.push(`} else if (${token.args}) {`)
|
|
108
|
-
break
|
|
109
|
-
|
|
110
|
-
case 'else':
|
|
111
|
-
if (!stack.length || stack[stack.length - 1]!.type !== 'if') {
|
|
112
|
-
throw new TemplateError(`@else without matching @if at line ${token.line}`)
|
|
113
|
-
}
|
|
114
|
-
lines.push(`} else {`)
|
|
115
|
-
break
|
|
116
|
-
|
|
117
|
-
case 'each': {
|
|
118
|
-
if (!token.args) throw new TemplateError(`@each requires arguments at line ${token.line}`)
|
|
119
|
-
const match = token.args.match(/^\s*(\w+)\s+in\s+(.+)$/)
|
|
120
|
-
if (!match) {
|
|
121
|
-
throw new TemplateError(`@each syntax error at line ${token.line}: expected "item in list"`)
|
|
122
|
-
}
|
|
123
|
-
const itemName = match[1]!
|
|
124
|
-
const listExpr = match[2]!.trim()
|
|
125
|
-
lines.push(`{`)
|
|
126
|
-
lines.push(` const __list = (${listExpr});`)
|
|
127
|
-
lines.push(` for (let $index = 0; $index < __list.length; $index++) {`)
|
|
128
|
-
lines.push(` const ${itemName} = __list[$index];`)
|
|
129
|
-
lines.push(` const $first = $index === 0;`)
|
|
130
|
-
lines.push(` const $last = $index === __list.length - 1;`)
|
|
131
|
-
stack.push({ type: 'each', line: token.line })
|
|
132
|
-
break
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
case 'layout': {
|
|
136
|
-
if (!token.args) throw new TemplateError(`@layout requires a name at line ${token.line}`)
|
|
137
|
-
const name = token.args.replace(/^['"]|['"]$/g, '').trim()
|
|
138
|
-
setLayout(name)
|
|
139
|
-
break
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
case 'block': {
|
|
143
|
-
if (!token.args) throw new TemplateError(`@block requires a name at line ${token.line}`)
|
|
144
|
-
const name = token.args.replace(/^['"]|['"]$/g, '').trim()
|
|
145
|
-
const nameStr = JSON.stringify(name)
|
|
146
|
-
// If a child template already provided this block as data, yield it.
|
|
147
|
-
// Otherwise, render the default content between @block and @end.
|
|
148
|
-
lines.push(`if (typeof ${name} !== 'undefined' && ${name} !== null) {`)
|
|
149
|
-
lines.push(` __out += ${name};`)
|
|
150
|
-
lines.push(` __blocks[${nameStr}] = ${name};`)
|
|
151
|
-
lines.push(`} else {`)
|
|
152
|
-
lines.push(` __blocks[${nameStr}] = (function() { let __out = "";`)
|
|
153
|
-
stack.push({ type: 'block', line: token.line, blockName: name })
|
|
154
|
-
break
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
case 'include': {
|
|
158
|
-
if (!token.args) throw new TemplateError(`@include requires arguments at line ${token.line}`)
|
|
159
|
-
const match = token.args.match(/^\s*['"]([^'"]+)['"]\s*(?:,\s*(.+))?\s*$/)
|
|
160
|
-
if (!match) {
|
|
161
|
-
throw new TemplateError(
|
|
162
|
-
`@include syntax error at line ${token.line}: expected "'name'" or "'name', data"`
|
|
163
|
-
)
|
|
164
|
-
}
|
|
165
|
-
const name = match[1]!
|
|
166
|
-
const dataExpr = match[2] ? match[2].trim() : '{}'
|
|
167
|
-
lines.push(`__out += await __include(${JSON.stringify(name)}, ${dataExpr});`)
|
|
168
|
-
break
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
case 'islands': {
|
|
172
|
-
const src = token.args ? token.args.replace(/^['"]|['"]$/g, '').trim() : '/islands.js'
|
|
173
|
-
// Use __islandsSrc (set by IslandBuilder via ViewEngine.setGlobal) for versioned URL, fallback to static src
|
|
174
|
-
lines.push(
|
|
175
|
-
`__out += '<script src="' + (typeof __islandsSrc !== 'undefined' ? __islandsSrc : '${escapeJs(src)}') + '"><\\/script>';`
|
|
176
|
-
)
|
|
177
|
-
break
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
case 'end': {
|
|
181
|
-
if (!stack.length) {
|
|
182
|
-
throw new TemplateError(`Unexpected @end at line ${token.line} — no open block`)
|
|
183
|
-
}
|
|
184
|
-
const top = stack.pop()!
|
|
185
|
-
if (top.type === 'block') {
|
|
186
|
-
const nameStr = JSON.stringify(top.blockName!)
|
|
187
|
-
lines.push(` return __out; })();`)
|
|
188
|
-
lines.push(` __out += __blocks[${nameStr}];`)
|
|
189
|
-
lines.push(`}`)
|
|
190
|
-
} else if (top.type === 'each') {
|
|
191
|
-
lines.push(` }`) // close for loop
|
|
192
|
-
lines.push(`}`) // close block scope
|
|
193
|
-
} else {
|
|
194
|
-
lines.push(`}`)
|
|
195
|
-
}
|
|
196
|
-
break
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
package/src/view/engine.ts
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { resolve, join } from 'node:path'
|
|
2
|
-
import { inject } from '@strav/kernel/core/inject'
|
|
3
|
-
import Configuration from '@strav/kernel/config/configuration'
|
|
4
|
-
import { escapeHtml } from './escape.ts'
|
|
5
|
-
import { tokenize } from './tokenizer.ts'
|
|
6
|
-
import { compile } from './compiler.ts'
|
|
7
|
-
import TemplateCache from './cache.ts'
|
|
8
|
-
import type { CacheEntry, RenderFunction, IncludeFn } from './cache.ts'
|
|
9
|
-
import { ConfigurationError, TemplateError } from '@strav/kernel/exceptions/errors'
|
|
10
|
-
|
|
11
|
-
const MAX_INCLUDE_DEPTH = 50
|
|
12
|
-
|
|
13
|
-
@inject
|
|
14
|
-
export default class ViewEngine {
|
|
15
|
-
private static _instance: ViewEngine | null = null
|
|
16
|
-
private static _globals: Record<string, unknown> = {}
|
|
17
|
-
|
|
18
|
-
private directory: string
|
|
19
|
-
private cacheEnabled: boolean
|
|
20
|
-
private cache: TemplateCache
|
|
21
|
-
|
|
22
|
-
constructor(config: Configuration) {
|
|
23
|
-
this.directory = resolve(config.get('view.directory', 'resources/views') as string)
|
|
24
|
-
this.cacheEnabled = config.get('view.cache', true) as boolean
|
|
25
|
-
this.cache = new TemplateCache()
|
|
26
|
-
ViewEngine._instance = this
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
static get instance(): ViewEngine {
|
|
30
|
-
if (!ViewEngine._instance) {
|
|
31
|
-
throw new ConfigurationError('ViewEngine not configured. Register it in the container.')
|
|
32
|
-
}
|
|
33
|
-
return ViewEngine._instance
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Register a global variable available in all templates. */
|
|
37
|
-
static setGlobal(key: string, value: unknown): void {
|
|
38
|
-
ViewEngine._globals[key] = value
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async render(name: string, data: Record<string, unknown> = {}): Promise<string> {
|
|
42
|
-
const merged = { ...ViewEngine._globals, ...data }
|
|
43
|
-
return this.renderWithDepth(name, merged, 0)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
private async renderWithDepth(
|
|
47
|
-
name: string,
|
|
48
|
-
data: Record<string, unknown>,
|
|
49
|
-
depth: number
|
|
50
|
-
): Promise<string> {
|
|
51
|
-
if (depth > MAX_INCLUDE_DEPTH) {
|
|
52
|
-
throw new TemplateError(
|
|
53
|
-
`Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded — possible circular include`
|
|
54
|
-
)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const entry = await this.resolve(name)
|
|
58
|
-
|
|
59
|
-
const includeFn: IncludeFn = (includeName, includeData) => {
|
|
60
|
-
return this.renderWithDepth(includeName, { ...data, ...includeData }, depth + 1)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const result = await entry.fn(data, includeFn)
|
|
64
|
-
|
|
65
|
-
// Layout inheritance: render child first, then render layout with blocks merged
|
|
66
|
-
if (entry.layout) {
|
|
67
|
-
const layoutData = { ...data, ...result.blocks }
|
|
68
|
-
return this.renderWithDepth(entry.layout, layoutData, depth + 1)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return result.output
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
private async resolve(name: string): Promise<CacheEntry> {
|
|
75
|
-
const cached = this.cache.get(name)
|
|
76
|
-
|
|
77
|
-
if (cached) {
|
|
78
|
-
if (this.cacheEnabled) return cached
|
|
79
|
-
const stale = await this.cache.isStale(name)
|
|
80
|
-
if (!stale) return cached
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return this.compileTemplate(name)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
private async compileTemplate(name: string): Promise<CacheEntry> {
|
|
87
|
-
const filePath = this.resolvePath(name)
|
|
88
|
-
const file = Bun.file(filePath)
|
|
89
|
-
|
|
90
|
-
const exists = await file.exists()
|
|
91
|
-
if (!exists) {
|
|
92
|
-
throw new TemplateError(`Template not found: ${name} (looked at ${filePath})`)
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const source = await file.text()
|
|
96
|
-
const tokens = tokenize(source)
|
|
97
|
-
const result = compile(tokens)
|
|
98
|
-
const fn = this.createRenderFunction(result.code)
|
|
99
|
-
|
|
100
|
-
const entry: CacheEntry = {
|
|
101
|
-
fn,
|
|
102
|
-
layout: result.layout,
|
|
103
|
-
mtime: file.lastModified,
|
|
104
|
-
filePath,
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
this.cache.set(name, entry)
|
|
108
|
-
return entry
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
private resolvePath(name: string): string {
|
|
112
|
-
const relativePath = name.replace(/\./g, '/') + '.strav'
|
|
113
|
-
return join(this.directory, relativePath)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
private createRenderFunction(code: string): RenderFunction {
|
|
117
|
-
// Use async Function with `with` statement for scope injection.
|
|
118
|
-
// `new Function()` does not inherit strict mode, so `with` is available.
|
|
119
|
-
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
|
120
|
-
|
|
121
|
-
const fn = new AsyncFunction('__data', '__escape', '__include', `with (__data) {\n${code}\n}`)
|
|
122
|
-
|
|
123
|
-
return (data: Record<string, unknown>, includeFn: IncludeFn) => {
|
|
124
|
-
return fn(data, escapeHtml, includeFn)
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export async function view(
|
|
130
|
-
name: string,
|
|
131
|
-
data: Record<string, unknown> = {},
|
|
132
|
-
status = 200
|
|
133
|
-
): Promise<Response> {
|
|
134
|
-
const html = await ViewEngine.instance.render(name, data)
|
|
135
|
-
return new Response(html, {
|
|
136
|
-
status,
|
|
137
|
-
headers: { 'Content-Type': 'text/html' },
|
|
138
|
-
})
|
|
139
|
-
}
|
package/src/view/escape.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
const replacements: Record<string, string> = {
|
|
2
|
-
'&': '&',
|
|
3
|
-
'<': '<',
|
|
4
|
-
'>': '>',
|
|
5
|
-
'"': '"',
|
|
6
|
-
"'": ''',
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const pattern = /[&<>"']/g
|
|
10
|
-
|
|
11
|
-
export function escapeHtml(value: unknown): string {
|
|
12
|
-
const str = String(value ?? '')
|
|
13
|
-
return str.replace(pattern, ch => replacements[ch]!)
|
|
14
|
-
}
|
package/src/view/index.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export { default as ViewEngine, view } from './engine.ts'
|
|
2
|
-
export { tokenize } from './tokenizer.ts'
|
|
3
|
-
export { compile } from './compiler.ts'
|
|
4
|
-
export { default as TemplateCache } from './cache.ts'
|
|
5
|
-
export { escapeHtml } from './escape.ts'
|
|
6
|
-
export { staticFiles } from './middleware/static.ts'
|
|
7
|
-
export { IslandBuilder } from './islands/island_builder.ts'
|
|
8
|
-
export { vueSfcPlugin } from './islands/vue_plugin.ts'
|
|
9
|
-
|
|
10
|
-
export type { Token, TokenType, VueAttr } from './tokenizer.ts'
|
|
11
|
-
export type { CompilationResult } from './compiler.ts'
|
|
12
|
-
export type { CacheEntry, RenderFunction, IncludeFn, RenderResult } from './cache.ts'
|
|
13
|
-
export type { IslandBuilderOptions, IslandManifest } from './islands/island_builder.ts'
|
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
import { resolve, join } 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 IslandBuilderOptions {
|
|
16
|
-
/** Directory containing .vue SFC files. Default: './resources/islands' */
|
|
17
|
-
islandsDir?: string
|
|
18
|
-
/** Directory where the bundle is output. Default: './public/builds' */
|
|
19
|
-
outDir?: string
|
|
20
|
-
/** Output filename. Default: 'islands.js' */
|
|
21
|
-
outFile?: string
|
|
22
|
-
/** Enable minification. Default: true in production */
|
|
23
|
-
minify?: boolean
|
|
24
|
-
/** Enable pre-compression (gzip + brotli). Default: true */
|
|
25
|
-
compress?: boolean
|
|
26
|
-
/** Base URL path for the islands script. Default: '/builds/' */
|
|
27
|
-
basePath?: string
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface IslandManifest {
|
|
31
|
-
file: string
|
|
32
|
-
version: string
|
|
33
|
-
src: string
|
|
34
|
-
size: number
|
|
35
|
-
gzip?: number
|
|
36
|
-
brotli?: number
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export class IslandBuilder {
|
|
40
|
-
private islandsDir: string
|
|
41
|
-
private outDir: string
|
|
42
|
-
private outFile: string
|
|
43
|
-
private minify: boolean
|
|
44
|
-
private compress: boolean
|
|
45
|
-
private basePath: string
|
|
46
|
-
private watcher: FSWatcher | null = null
|
|
47
|
-
private _version: string | null = null
|
|
48
|
-
private _manifest: IslandManifest | null = null
|
|
49
|
-
|
|
50
|
-
constructor(options: IslandBuilderOptions = {}) {
|
|
51
|
-
this.islandsDir = resolve(options.islandsDir ?? './resources/islands')
|
|
52
|
-
this.outDir = resolve(options.outDir ?? './public/builds')
|
|
53
|
-
this.outFile = options.outFile ?? 'islands.js'
|
|
54
|
-
this.minify = options.minify ?? Bun.env.NODE_ENV === 'production'
|
|
55
|
-
this.compress = options.compress ?? true
|
|
56
|
-
this.basePath = options.basePath ?? '/builds/'
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** The content hash of the last build, or null if not yet built. */
|
|
60
|
-
get version(): string | null {
|
|
61
|
-
return this._version
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** The versioned script src (e.g. '/islands.js?v=abc12345'), or the plain path if not yet built. */
|
|
65
|
-
get src(): string {
|
|
66
|
-
const base = this.basePath + this.outFile
|
|
67
|
-
return this._version ? `${base}?v=${this._version}` : base
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** The build manifest with file info and sizes, or null if not yet built. */
|
|
71
|
-
get manifest(): IslandManifest | null {
|
|
72
|
-
return this._manifest
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Discover all .vue files in the islands directory (recursively). */
|
|
76
|
-
private discoverIslands(): { name: string; path: string }[] {
|
|
77
|
-
let entries: string[]
|
|
78
|
-
try {
|
|
79
|
-
entries = readdirSync(this.islandsDir, { recursive: true }) as string[]
|
|
80
|
-
} catch {
|
|
81
|
-
return []
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return entries
|
|
85
|
-
.filter(f => f.endsWith('.vue'))
|
|
86
|
-
.sort()
|
|
87
|
-
.map(f => ({
|
|
88
|
-
name: f.slice(0, -4).replace(/\\/g, '/'),
|
|
89
|
-
path: join(this.islandsDir, f),
|
|
90
|
-
}))
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Check if a setup file exists in the islands directory. */
|
|
94
|
-
private hasSetupFile(): string | null {
|
|
95
|
-
for (const ext of ['ts', 'js']) {
|
|
96
|
-
const p = join(this.islandsDir, `setup.${ext}`)
|
|
97
|
-
if (existsSync(p)) return p
|
|
98
|
-
}
|
|
99
|
-
return null
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Generate the virtual entry point that imports all islands + mount logic. */
|
|
103
|
-
private generateEntry(islands: { name: string; path: string }[]): string {
|
|
104
|
-
const setupPath = this.hasSetupFile()
|
|
105
|
-
const lines: string[] = []
|
|
106
|
-
|
|
107
|
-
lines.push(`import { createApp, defineComponent, h, Teleport } from 'vue';`)
|
|
108
|
-
lines.push('')
|
|
109
|
-
|
|
110
|
-
if (setupPath) {
|
|
111
|
-
lines.push(`import __setup from '${setupPath}';`)
|
|
112
|
-
lines.push('')
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Import each island component
|
|
116
|
-
for (let i = 0; i < islands.length; i++) {
|
|
117
|
-
lines.push(`import __c${i} from '${islands[i]!.path}';`)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
lines.push('')
|
|
121
|
-
lines.push('var components = {')
|
|
122
|
-
for (let i = 0; i < islands.length; i++) {
|
|
123
|
-
lines.push(` '${islands[i]!.name}': __c${i},`)
|
|
124
|
-
}
|
|
125
|
-
lines.push('};')
|
|
126
|
-
|
|
127
|
-
lines.push('')
|
|
128
|
-
lines.push('function mountIslands() {')
|
|
129
|
-
lines.push(' var islands = [];')
|
|
130
|
-
lines.push(" document.querySelectorAll('[data-vue]').forEach(function(el) {")
|
|
131
|
-
lines.push(' var name = el.dataset.vue;')
|
|
132
|
-
lines.push(' if (!name) return;')
|
|
133
|
-
lines.push(' var Component = components[name];')
|
|
134
|
-
lines.push(' if (!Component) {')
|
|
135
|
-
lines.push(" console.warn('[islands] Unknown component: ' + name);")
|
|
136
|
-
lines.push(' return;')
|
|
137
|
-
lines.push(' }')
|
|
138
|
-
lines.push(" var props = JSON.parse(el.dataset.props || '{}');")
|
|
139
|
-
lines.push(' islands.push({ Component: Component, props: props, el: el });')
|
|
140
|
-
lines.push(' });')
|
|
141
|
-
lines.push('')
|
|
142
|
-
lines.push(' if (islands.length === 0) return;')
|
|
143
|
-
lines.push('')
|
|
144
|
-
lines.push(' var Root = defineComponent({')
|
|
145
|
-
lines.push(' render: function() {')
|
|
146
|
-
lines.push(' return islands.map(function(island) {')
|
|
147
|
-
lines.push(
|
|
148
|
-
' return h(Teleport, { to: island.el }, [h(island.Component, island.props)]);'
|
|
149
|
-
)
|
|
150
|
-
lines.push(' });')
|
|
151
|
-
lines.push(' }')
|
|
152
|
-
lines.push(' });')
|
|
153
|
-
lines.push('')
|
|
154
|
-
lines.push(' var app = createApp(Root);')
|
|
155
|
-
if (setupPath) {
|
|
156
|
-
lines.push(' if (typeof __setup === "function") __setup(app);')
|
|
157
|
-
}
|
|
158
|
-
lines.push(' var root = document.createElement("div");')
|
|
159
|
-
lines.push(' root.style.display = "contents";')
|
|
160
|
-
lines.push(' document.body.appendChild(root);')
|
|
161
|
-
lines.push(' app.mount(root);')
|
|
162
|
-
lines.push('}')
|
|
163
|
-
lines.push('')
|
|
164
|
-
lines.push("if (document.readyState === 'loading') {")
|
|
165
|
-
lines.push(" document.addEventListener('DOMContentLoaded', mountIslands);")
|
|
166
|
-
lines.push('} else {')
|
|
167
|
-
lines.push(' mountIslands();')
|
|
168
|
-
lines.push('}')
|
|
169
|
-
|
|
170
|
-
return lines.join('\n')
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** Compute a short content hash for cache busting. */
|
|
174
|
-
private computeHash(content: Uint8Array): string {
|
|
175
|
-
const hasher = new Bun.CryptoHasher('md5')
|
|
176
|
-
hasher.update(content)
|
|
177
|
-
return hasher.digest('hex').slice(0, 8)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Generate pre-compressed versions of the bundle. */
|
|
181
|
-
private async generateCompressed(
|
|
182
|
-
outPath: string,
|
|
183
|
-
content: Uint8Array
|
|
184
|
-
): Promise<{ gzip?: number; brotli?: number }> {
|
|
185
|
-
const sizes: { gzip?: number; brotli?: number } = {}
|
|
186
|
-
|
|
187
|
-
// Gzip
|
|
188
|
-
const gzipped = Bun.gzipSync(content as Uint8Array<ArrayBuffer>)
|
|
189
|
-
await Bun.write(outPath + '.gz', gzipped)
|
|
190
|
-
sizes.gzip = gzipped.length
|
|
191
|
-
|
|
192
|
-
// Brotli
|
|
193
|
-
try {
|
|
194
|
-
const brotli = brotliCompressSync(Buffer.from(content), {
|
|
195
|
-
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 },
|
|
196
|
-
})
|
|
197
|
-
await Bun.write(outPath + '.br', brotli)
|
|
198
|
-
sizes.brotli = brotli.length
|
|
199
|
-
} catch {
|
|
200
|
-
// Brotli may not be available in all environments
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return sizes
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** Remove stale compressed files. */
|
|
207
|
-
private cleanCompressed(outPath: string): void {
|
|
208
|
-
for (const ext of ['.gz', '.br']) {
|
|
209
|
-
try {
|
|
210
|
-
unlinkSync(outPath + ext)
|
|
211
|
-
} catch {
|
|
212
|
-
// File may not exist
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** Update the ViewEngine global so @islands() picks up the versioned src. */
|
|
218
|
-
private syncViewEngine(): void {
|
|
219
|
-
try {
|
|
220
|
-
ViewEngine.setGlobal('__islandsSrc', this.src)
|
|
221
|
-
} catch {
|
|
222
|
-
// ViewEngine may not be initialized yet
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** Build the islands bundle. Returns true if islands were found and built. */
|
|
227
|
-
async build(): Promise<boolean> {
|
|
228
|
-
const islands = this.discoverIslands()
|
|
229
|
-
|
|
230
|
-
if (islands.length === 0) {
|
|
231
|
-
return false
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Ensure output directory exists
|
|
235
|
-
mkdirSync(this.outDir, { recursive: true })
|
|
236
|
-
|
|
237
|
-
const entrySource = this.generateEntry(islands)
|
|
238
|
-
|
|
239
|
-
// Virtual entry plugin — resolves the synthetic entry from memory
|
|
240
|
-
const virtualEntryPlugin: BunPlugin = {
|
|
241
|
-
name: 'virtual-entry',
|
|
242
|
-
setup(build) {
|
|
243
|
-
build.onResolve({ filter: /^virtual:islands-entry$/ }, () => ({
|
|
244
|
-
path: 'virtual:islands-entry',
|
|
245
|
-
namespace: 'island-entry',
|
|
246
|
-
}))
|
|
247
|
-
|
|
248
|
-
build.onLoad({ filter: /.*/, namespace: 'island-entry' }, () => ({
|
|
249
|
-
contents: entrySource,
|
|
250
|
-
loader: 'js',
|
|
251
|
-
}))
|
|
252
|
-
},
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const result = await Bun.build({
|
|
256
|
-
entrypoints: ['virtual:islands-entry'],
|
|
257
|
-
outdir: this.outDir,
|
|
258
|
-
naming: this.outFile,
|
|
259
|
-
minify: this.minify,
|
|
260
|
-
target: 'browser',
|
|
261
|
-
plugins: [virtualEntryPlugin, vueSfcPlugin()],
|
|
262
|
-
define: {
|
|
263
|
-
__VUE_OPTIONS_API__: 'true',
|
|
264
|
-
__VUE_PROD_DEVTOOLS__: 'false',
|
|
265
|
-
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
|
|
266
|
-
},
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
if (!result.success) {
|
|
270
|
-
const messages = result.logs.map(l => l.message ?? String(l)).join('\n')
|
|
271
|
-
throw new Error(`Island build failed:\n${messages}`)
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Read the output, compute version hash, optionally compress
|
|
275
|
-
const outPath = join(this.outDir, this.outFile)
|
|
276
|
-
const content = new Uint8Array(await Bun.file(outPath).arrayBuffer())
|
|
277
|
-
|
|
278
|
-
this._version = this.computeHash(content)
|
|
279
|
-
|
|
280
|
-
let compressedSizes: { gzip?: number; brotli?: number } = {}
|
|
281
|
-
if (this.compress) {
|
|
282
|
-
compressedSizes = await this.generateCompressed(outPath, content)
|
|
283
|
-
} else {
|
|
284
|
-
this.cleanCompressed(outPath)
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
this._manifest = {
|
|
288
|
-
file: this.outFile,
|
|
289
|
-
version: this._version,
|
|
290
|
-
src: this.src,
|
|
291
|
-
size: content.length,
|
|
292
|
-
...compressedSizes,
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Write manifest
|
|
296
|
-
await Bun.write(
|
|
297
|
-
join(this.outDir, this.outFile.replace(/\.js$/, '.manifest.json')),
|
|
298
|
-
JSON.stringify(this._manifest, null, 2)
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
// Sync version with ViewEngine
|
|
302
|
-
this.syncViewEngine()
|
|
303
|
-
|
|
304
|
-
const sizeKB = (content.length / 1024).toFixed(1)
|
|
305
|
-
const gzKB = compressedSizes.gzip
|
|
306
|
-
? ` | gzip: ${(compressedSizes.gzip / 1024).toFixed(1)}kB`
|
|
307
|
-
: ''
|
|
308
|
-
const brKB = compressedSizes.brotli
|
|
309
|
-
? ` | br: ${(compressedSizes.brotli / 1024).toFixed(1)}kB`
|
|
310
|
-
: ''
|
|
311
|
-
|
|
312
|
-
console.log(
|
|
313
|
-
`[islands] Built ${islands.length} component(s) → ${this.outFile} (${sizeKB}kB${gzKB}${brKB}) v=${this._version}`
|
|
314
|
-
)
|
|
315
|
-
return true
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/** Watch the islands directory and rebuild on changes. */
|
|
319
|
-
watch(): void {
|
|
320
|
-
if (this.watcher) return
|
|
321
|
-
|
|
322
|
-
this.build().catch(err => console.error('[islands] Build error:', err))
|
|
323
|
-
|
|
324
|
-
this.watcher = fsWatch(this.islandsDir, { recursive: true }, (_event, filename) => {
|
|
325
|
-
if (filename && !filename.endsWith('.vue') && !filename.startsWith('setup.')) return
|
|
326
|
-
console.log('[islands] Change detected, rebuilding...')
|
|
327
|
-
this.build().catch(err => console.error('[islands] Rebuild error:', err))
|
|
328
|
-
})
|
|
329
|
-
|
|
330
|
-
console.log(`[islands] Watching ${this.islandsDir}`)
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/** Stop watching. */
|
|
334
|
-
unwatch(): void {
|
|
335
|
-
this.watcher?.close()
|
|
336
|
-
this.watcher = null
|
|
337
|
-
}
|
|
338
|
-
}
|