@stravigor/core 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/README.md +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +50 -0
- package/src/view/compiler.ts +185 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { resolve, join } from 'node:path'
|
|
2
|
+
import { inject } from '../core/inject.ts'
|
|
3
|
+
import Configuration from '../config/configuration.ts'
|
|
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 '../exceptions/errors.ts'
|
|
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', '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
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
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 } from './islands/island_builder.ts'
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { resolve, join, basename } from 'node:path'
|
|
2
|
+
import { readdirSync, watch as fsWatch, type FSWatcher } from 'node:fs'
|
|
3
|
+
import { vueSfcPlugin } from './vue_plugin.ts'
|
|
4
|
+
import type { BunPlugin } from 'bun'
|
|
5
|
+
|
|
6
|
+
export interface IslandBuilderOptions {
|
|
7
|
+
/** Directory containing .vue SFC files. Default: './islands' */
|
|
8
|
+
islandsDir?: string
|
|
9
|
+
/** Public directory where the bundle is output. Default: './public' */
|
|
10
|
+
outDir?: string
|
|
11
|
+
/** Output filename. Default: 'islands.js' */
|
|
12
|
+
outFile?: string
|
|
13
|
+
/** Enable minification. Default: true in production */
|
|
14
|
+
minify?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class IslandBuilder {
|
|
18
|
+
private islandsDir: string
|
|
19
|
+
private outDir: string
|
|
20
|
+
private outFile: string
|
|
21
|
+
private minify: boolean
|
|
22
|
+
private watcher: FSWatcher | null = null
|
|
23
|
+
|
|
24
|
+
constructor(options: IslandBuilderOptions = {}) {
|
|
25
|
+
this.islandsDir = resolve(options.islandsDir ?? './islands')
|
|
26
|
+
this.outDir = resolve(options.outDir ?? './public')
|
|
27
|
+
this.outFile = options.outFile ?? 'islands.js'
|
|
28
|
+
this.minify = options.minify ?? (Bun.env.NODE_ENV === 'production')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Discover all .vue files in the islands directory. */
|
|
32
|
+
private discoverIslands(): { name: string; path: string }[] {
|
|
33
|
+
let entries: string[]
|
|
34
|
+
try {
|
|
35
|
+
entries = readdirSync(this.islandsDir)
|
|
36
|
+
} catch {
|
|
37
|
+
return []
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return entries
|
|
41
|
+
.filter(f => f.endsWith('.vue'))
|
|
42
|
+
.sort()
|
|
43
|
+
.map(f => ({
|
|
44
|
+
name: basename(f, '.vue'),
|
|
45
|
+
path: join(this.islandsDir, f),
|
|
46
|
+
}))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Generate the virtual entry point that imports all islands + mount logic. */
|
|
50
|
+
private generateEntry(islands: { name: string; path: string }[]): string {
|
|
51
|
+
const lines: string[] = []
|
|
52
|
+
|
|
53
|
+
lines.push(`import { createApp } from 'vue';`)
|
|
54
|
+
lines.push('')
|
|
55
|
+
|
|
56
|
+
// Import each island component
|
|
57
|
+
for (let i = 0; i < islands.length; i++) {
|
|
58
|
+
lines.push(`import __c${i} from '${islands[i]!.path}';`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
lines.push('')
|
|
62
|
+
lines.push('const components = {')
|
|
63
|
+
for (let i = 0; i < islands.length; i++) {
|
|
64
|
+
lines.push(` '${islands[i]!.name}': __c${i},`)
|
|
65
|
+
}
|
|
66
|
+
lines.push('};')
|
|
67
|
+
|
|
68
|
+
lines.push('')
|
|
69
|
+
lines.push('function mountIslands() {')
|
|
70
|
+
lines.push(" document.querySelectorAll('[data-vue]').forEach(function(el) {")
|
|
71
|
+
lines.push(' var name = el.dataset.vue;')
|
|
72
|
+
lines.push(' if (!name) return;')
|
|
73
|
+
lines.push(' var Component = components[name];')
|
|
74
|
+
lines.push(' if (!Component) {')
|
|
75
|
+
lines.push(" console.warn('[islands] Unknown component: ' + name);")
|
|
76
|
+
lines.push(' return;')
|
|
77
|
+
lines.push(' }')
|
|
78
|
+
lines.push(" var props = JSON.parse(el.dataset.props || '{}');")
|
|
79
|
+
lines.push(' createApp(Component, props).mount(el);')
|
|
80
|
+
lines.push(' });')
|
|
81
|
+
lines.push('}')
|
|
82
|
+
lines.push('')
|
|
83
|
+
lines.push("if (document.readyState === 'loading') {")
|
|
84
|
+
lines.push(" document.addEventListener('DOMContentLoaded', mountIslands);")
|
|
85
|
+
lines.push('} else {')
|
|
86
|
+
lines.push(' mountIslands();')
|
|
87
|
+
lines.push('}')
|
|
88
|
+
|
|
89
|
+
return lines.join('\n')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Build the islands bundle. Returns true if islands were found and built. */
|
|
93
|
+
async build(): Promise<boolean> {
|
|
94
|
+
const islands = this.discoverIslands()
|
|
95
|
+
|
|
96
|
+
if (islands.length === 0) {
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const entrySource = this.generateEntry(islands)
|
|
101
|
+
|
|
102
|
+
// Virtual entry plugin — resolves the synthetic entry from memory
|
|
103
|
+
const virtualEntryPlugin: BunPlugin = {
|
|
104
|
+
name: 'virtual-entry',
|
|
105
|
+
setup(build) {
|
|
106
|
+
build.onResolve({ filter: /^virtual:islands-entry$/ }, () => ({
|
|
107
|
+
path: 'virtual:islands-entry',
|
|
108
|
+
namespace: 'island-entry',
|
|
109
|
+
}))
|
|
110
|
+
|
|
111
|
+
build.onLoad({ filter: /.*/, namespace: 'island-entry' }, () => ({
|
|
112
|
+
contents: entrySource,
|
|
113
|
+
loader: 'js',
|
|
114
|
+
}))
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const result = await Bun.build({
|
|
119
|
+
entrypoints: ['virtual:islands-entry'],
|
|
120
|
+
outdir: this.outDir,
|
|
121
|
+
naming: this.outFile,
|
|
122
|
+
minify: this.minify,
|
|
123
|
+
target: 'browser',
|
|
124
|
+
plugins: [virtualEntryPlugin, vueSfcPlugin()],
|
|
125
|
+
define: {
|
|
126
|
+
'__VUE_OPTIONS_API__': 'true',
|
|
127
|
+
'__VUE_PROD_DEVTOOLS__': 'false',
|
|
128
|
+
'__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false',
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
if (!result.success) {
|
|
133
|
+
const messages = result.logs.map(l => l.message ?? String(l)).join('\n')
|
|
134
|
+
throw new Error(`Island build failed:\n${messages}`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log(`[islands] Built ${islands.length} component(s) → ${this.outFile}`)
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Watch the islands directory and rebuild on changes. */
|
|
142
|
+
watch(): void {
|
|
143
|
+
if (this.watcher) return
|
|
144
|
+
|
|
145
|
+
this.build().catch(err => console.error('[islands] Build error:', err))
|
|
146
|
+
|
|
147
|
+
this.watcher = fsWatch(this.islandsDir, { recursive: true }, (_event, filename) => {
|
|
148
|
+
if (filename && !filename.endsWith('.vue')) return
|
|
149
|
+
console.log('[islands] Change detected, rebuilding...')
|
|
150
|
+
this.build().catch(err => console.error('[islands] Rebuild error:', err))
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
console.log(`[islands] Watching ${this.islandsDir}`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Stop watching. */
|
|
157
|
+
unwatch(): void {
|
|
158
|
+
this.watcher?.close()
|
|
159
|
+
this.watcher = null
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
scoped: true,
|
|
40
|
+
id,
|
|
41
|
+
compilerOptions: { scopeId },
|
|
42
|
+
} : undefined,
|
|
43
|
+
})
|
|
44
|
+
scriptCode = result.content
|
|
45
|
+
bindings = result.bindings
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Template (Options API only — script setup uses inlineTemplate) ─
|
|
49
|
+
let templateCode = ''
|
|
50
|
+
|
|
51
|
+
if (descriptor.template && !descriptor.scriptSetup) {
|
|
52
|
+
const result = compileTemplate({
|
|
53
|
+
source: descriptor.template.content,
|
|
54
|
+
filename: args.path,
|
|
55
|
+
id,
|
|
56
|
+
scoped,
|
|
57
|
+
compilerOptions: {
|
|
58
|
+
bindingMetadata: bindings,
|
|
59
|
+
scopeId: scoped ? scopeId : undefined,
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
if (result.errors.length > 0) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Vue template error in ${args.path}:\n${result.errors.map(e => typeof e === 'string' ? e : e.message).join('\n')}`
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
templateCode = result.code
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Styles ────────────────────────────────────────────────────────
|
|
73
|
+
const styles: string[] = []
|
|
74
|
+
|
|
75
|
+
for (const styleBlock of descriptor.styles) {
|
|
76
|
+
const result = compileStyle({
|
|
77
|
+
source: styleBlock.content,
|
|
78
|
+
filename: args.path,
|
|
79
|
+
id: scopeId,
|
|
80
|
+
scoped: !!styleBlock.scoped,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
if (result.errors.length > 0) {
|
|
84
|
+
console.warn(`[vue-sfc] Style warning in ${args.path}:`, result.errors)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
styles.push(result.code)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Assemble ──────────────────────────────────────────────────────
|
|
91
|
+
let output = ''
|
|
92
|
+
|
|
93
|
+
// Inject styles at module load time
|
|
94
|
+
if (styles.length > 0) {
|
|
95
|
+
const css = JSON.stringify(styles.join('\n'))
|
|
96
|
+
output += `(function(){var s=document.createElement('style');s.textContent=${css};document.head.appendChild(s)})();\n`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (descriptor.scriptSetup) {
|
|
100
|
+
// <script setup> with inlineTemplate — scriptCode is a complete module
|
|
101
|
+
// Rewrite the default export to capture the component and set __scopeId
|
|
102
|
+
if (scoped) {
|
|
103
|
+
output += scriptCode.replace(
|
|
104
|
+
/export\s+default\s+/,
|
|
105
|
+
'const __sfc__ = '
|
|
106
|
+
) + '\n'
|
|
107
|
+
output += `__sfc__.__scopeId = ${JSON.stringify(scopeId)};\n`
|
|
108
|
+
output += 'export default __sfc__;\n'
|
|
109
|
+
} else {
|
|
110
|
+
output += scriptCode + '\n'
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
// Options API — stitch script + template render function
|
|
114
|
+
if (scriptCode) {
|
|
115
|
+
output += scriptCode.replace(
|
|
116
|
+
/export\s+default\s*\{/,
|
|
117
|
+
'const __component__ = {'
|
|
118
|
+
) + '\n'
|
|
119
|
+
} else {
|
|
120
|
+
output += 'const __component__ = {};\n'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (templateCode) {
|
|
124
|
+
output += templateCode + '\n'
|
|
125
|
+
output += '__component__.render = render;\n'
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (scoped) {
|
|
129
|
+
output += `__component__.__scopeId = ${JSON.stringify(scopeId)};\n`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
output += 'export default __component__;\n'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const isTs = descriptor.script?.lang === 'ts' || descriptor.scriptSetup?.lang === 'ts'
|
|
136
|
+
return { contents: output, loader: isTs ? 'ts' : 'js' }
|
|
137
|
+
})
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { resolve, normalize } from 'node:path'
|
|
2
|
+
import type { Middleware } from '../../http/middleware.ts'
|
|
3
|
+
|
|
4
|
+
export function staticFiles(root = 'public'): Middleware {
|
|
5
|
+
const resolvedRoot = resolve(root)
|
|
6
|
+
|
|
7
|
+
return async (ctx, next) => {
|
|
8
|
+
// Only serve GET/HEAD requests
|
|
9
|
+
if (ctx.method !== 'GET' && ctx.method !== 'HEAD') {
|
|
10
|
+
return next()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Skip hidden files (segments starting with .)
|
|
14
|
+
const segments = ctx.path.split('/')
|
|
15
|
+
if (segments.some(s => s.startsWith('.') && s.length > 1)) {
|
|
16
|
+
return next()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const filePath = normalize(resolve(resolvedRoot + ctx.path))
|
|
20
|
+
|
|
21
|
+
// Directory traversal protection
|
|
22
|
+
if (!filePath.startsWith(resolvedRoot)) {
|
|
23
|
+
return next()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const file = Bun.file(filePath)
|
|
27
|
+
const exists = await file.exists()
|
|
28
|
+
|
|
29
|
+
if (!exists) {
|
|
30
|
+
return next()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return new Response(file)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { TemplateError } from '../exceptions/errors.ts'
|
|
2
|
+
export type TokenType = 'text' | 'escaped' | 'raw' | 'comment' | 'directive' | 'vue_island'
|
|
3
|
+
|
|
4
|
+
export interface VueAttr {
|
|
5
|
+
value: string
|
|
6
|
+
bound: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Token {
|
|
10
|
+
type: TokenType
|
|
11
|
+
value: string
|
|
12
|
+
directive?: string
|
|
13
|
+
args?: string
|
|
14
|
+
tag?: string
|
|
15
|
+
attrs?: Record<string, VueAttr>
|
|
16
|
+
line: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DIRECTIVES = new Set(['if', 'elseif', 'else', 'end', 'each', 'layout', 'block', 'include', 'islands'])
|
|
20
|
+
|
|
21
|
+
export function tokenize(source: string): Token[] {
|
|
22
|
+
const tokens: Token[] = []
|
|
23
|
+
let pos = 0
|
|
24
|
+
let line = 1
|
|
25
|
+
let textStart = 0
|
|
26
|
+
|
|
27
|
+
function countLines(str: string): number {
|
|
28
|
+
let count = 0
|
|
29
|
+
for (let i = 0; i < str.length; i++) {
|
|
30
|
+
if (str[i] === '\n') count++
|
|
31
|
+
}
|
|
32
|
+
return count
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function flushText(): void {
|
|
36
|
+
if (pos > textStart) {
|
|
37
|
+
const value = source.slice(textStart, pos)
|
|
38
|
+
if (value.length > 0) {
|
|
39
|
+
tokens.push({ type: 'text', value, line: line - countLines(value) })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function remaining(): string {
|
|
45
|
+
return source.slice(pos)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
while (pos < source.length) {
|
|
49
|
+
const rest = remaining()
|
|
50
|
+
|
|
51
|
+
// 1. Comments: {{-- ... --}}
|
|
52
|
+
if (rest.startsWith('{{--')) {
|
|
53
|
+
flushText()
|
|
54
|
+
const endIdx = source.indexOf('--}}', pos + 4)
|
|
55
|
+
if (endIdx === -1) {
|
|
56
|
+
throw new TemplateError(`Unclosed comment at line ${line}`)
|
|
57
|
+
}
|
|
58
|
+
const content = source.slice(pos + 4, endIdx)
|
|
59
|
+
tokens.push({ type: 'comment', value: content.trim(), line })
|
|
60
|
+
line += countLines(source.slice(pos, endIdx + 4))
|
|
61
|
+
pos = endIdx + 4
|
|
62
|
+
textStart = pos
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. Raw output: {!! ... !!}
|
|
67
|
+
if (rest.startsWith('{!!')) {
|
|
68
|
+
flushText()
|
|
69
|
+
const endIdx = source.indexOf('!!}', pos + 3)
|
|
70
|
+
if (endIdx === -1) {
|
|
71
|
+
throw new TemplateError(`Unclosed raw expression at line ${line}`)
|
|
72
|
+
}
|
|
73
|
+
const expr = source.slice(pos + 3, endIdx).trim()
|
|
74
|
+
tokens.push({ type: 'raw', value: expr, line })
|
|
75
|
+
line += countLines(source.slice(pos, endIdx + 3))
|
|
76
|
+
pos = endIdx + 3
|
|
77
|
+
textStart = pos
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 3. Escaped output: {{ ... }}
|
|
82
|
+
if (rest.startsWith('{{')) {
|
|
83
|
+
flushText()
|
|
84
|
+
const endIdx = source.indexOf('}}', pos + 2)
|
|
85
|
+
if (endIdx === -1) {
|
|
86
|
+
throw new TemplateError(`Unclosed expression at line ${line}`)
|
|
87
|
+
}
|
|
88
|
+
const expr = source.slice(pos + 2, endIdx).trim()
|
|
89
|
+
tokens.push({ type: 'escaped', value: expr, line })
|
|
90
|
+
line += countLines(source.slice(pos, endIdx + 2))
|
|
91
|
+
pos = endIdx + 2
|
|
92
|
+
textStart = pos
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 4. Vue islands: <vue:name ... />
|
|
97
|
+
const vueMatch = rest.match(/^<vue:([\w-]+)((?:\s+[\s\S]*?)?)\/>/)
|
|
98
|
+
if (vueMatch) {
|
|
99
|
+
flushText()
|
|
100
|
+
const tag = vueMatch[1]!
|
|
101
|
+
const attrsRaw = vueMatch[2]!.trim()
|
|
102
|
+
const attrs = parseVueAttrs(attrsRaw)
|
|
103
|
+
const full = vueMatch[0]
|
|
104
|
+
tokens.push({ type: 'vue_island', value: full, tag, attrs, line })
|
|
105
|
+
line += countLines(full)
|
|
106
|
+
pos += full.length
|
|
107
|
+
textStart = pos
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 5. Directives: @keyword or @keyword(...)
|
|
112
|
+
const dirMatch = rest.match(/^@(\w+)/)
|
|
113
|
+
if (dirMatch && DIRECTIVES.has(dirMatch[1]!)) {
|
|
114
|
+
flushText()
|
|
115
|
+
const directive = dirMatch[1]!
|
|
116
|
+
pos += dirMatch[0].length
|
|
117
|
+
let args: string | undefined
|
|
118
|
+
|
|
119
|
+
// Parse arguments in parentheses (if present)
|
|
120
|
+
if (pos < source.length && source[pos] === '(') {
|
|
121
|
+
const argsStart = pos
|
|
122
|
+
let depth = 1
|
|
123
|
+
pos++ // skip opening (
|
|
124
|
+
while (pos < source.length && depth > 0) {
|
|
125
|
+
if (source[pos] === '(') depth++
|
|
126
|
+
else if (source[pos] === ')') depth--
|
|
127
|
+
if (depth > 0) pos++
|
|
128
|
+
}
|
|
129
|
+
if (depth !== 0) {
|
|
130
|
+
throw new TemplateError(`Unclosed directive arguments at line ${line}`)
|
|
131
|
+
}
|
|
132
|
+
args = source.slice(argsStart + 1, pos)
|
|
133
|
+
pos++ // skip closing )
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
tokens.push({ type: 'directive', value: directive, directive, args, line })
|
|
137
|
+
textStart = pos
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 6. Regular text
|
|
142
|
+
if (source[pos] === '\n') line++
|
|
143
|
+
pos++
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
flushText()
|
|
147
|
+
return tokens
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseVueAttrs(raw: string): Record<string, VueAttr> {
|
|
151
|
+
const attrs: Record<string, VueAttr> = {}
|
|
152
|
+
const attrPattern = /([:@]?[\w.-]+)\s*=\s*"([^"]*)"/g
|
|
153
|
+
let match: RegExpExecArray | null
|
|
154
|
+
|
|
155
|
+
while ((match = attrPattern.exec(raw)) !== null) {
|
|
156
|
+
const name = match[1]!
|
|
157
|
+
const value = match[2]!
|
|
158
|
+
|
|
159
|
+
if (name.startsWith(':')) {
|
|
160
|
+
// Bound attribute — extract expression from {{ }} if present
|
|
161
|
+
const exprMatch = value.match(/^\{\{\s*(.*?)\s*\}\}$/)
|
|
162
|
+
attrs[name.slice(1)] = {
|
|
163
|
+
value: exprMatch ? exprMatch[1]! : value,
|
|
164
|
+
bound: true,
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
attrs[name] = { value, bound: false }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return attrs
|
|
172
|
+
}
|
package/tsconfig.json
ADDED