@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.
Files changed (165) hide show
  1. package/README.md +45 -0
  2. package/package.json +83 -0
  3. package/src/auth/access_token.ts +122 -0
  4. package/src/auth/auth.ts +86 -0
  5. package/src/auth/index.ts +7 -0
  6. package/src/auth/middleware/authenticate.ts +64 -0
  7. package/src/auth/middleware/csrf.ts +62 -0
  8. package/src/auth/middleware/guest.ts +46 -0
  9. package/src/broadcast/broadcast_manager.ts +411 -0
  10. package/src/broadcast/client.ts +302 -0
  11. package/src/broadcast/index.ts +58 -0
  12. package/src/cache/cache_manager.ts +56 -0
  13. package/src/cache/cache_store.ts +31 -0
  14. package/src/cache/helpers.ts +74 -0
  15. package/src/cache/http_cache.ts +109 -0
  16. package/src/cache/index.ts +6 -0
  17. package/src/cache/memory_store.ts +63 -0
  18. package/src/cli/bootstrap.ts +37 -0
  19. package/src/cli/commands/generate_api.ts +74 -0
  20. package/src/cli/commands/generate_key.ts +46 -0
  21. package/src/cli/commands/generate_models.ts +48 -0
  22. package/src/cli/commands/migration_compare.ts +152 -0
  23. package/src/cli/commands/migration_fresh.ts +123 -0
  24. package/src/cli/commands/migration_generate.ts +79 -0
  25. package/src/cli/commands/migration_rollback.ts +53 -0
  26. package/src/cli/commands/migration_run.ts +44 -0
  27. package/src/cli/commands/queue_flush.ts +35 -0
  28. package/src/cli/commands/queue_retry.ts +34 -0
  29. package/src/cli/commands/queue_work.ts +40 -0
  30. package/src/cli/commands/scheduler_work.ts +45 -0
  31. package/src/cli/strav.ts +33 -0
  32. package/src/config/configuration.ts +105 -0
  33. package/src/config/loaders/base_loader.ts +69 -0
  34. package/src/config/loaders/env_loader.ts +112 -0
  35. package/src/config/loaders/typescript_loader.ts +56 -0
  36. package/src/config/types.ts +8 -0
  37. package/src/core/application.ts +4 -0
  38. package/src/core/container.ts +117 -0
  39. package/src/core/index.ts +3 -0
  40. package/src/core/inject.ts +39 -0
  41. package/src/database/database.ts +54 -0
  42. package/src/database/index.ts +30 -0
  43. package/src/database/introspector.ts +446 -0
  44. package/src/database/migration/differ.ts +308 -0
  45. package/src/database/migration/file_generator.ts +125 -0
  46. package/src/database/migration/index.ts +18 -0
  47. package/src/database/migration/runner.ts +133 -0
  48. package/src/database/migration/sql_generator.ts +378 -0
  49. package/src/database/migration/tracker.ts +76 -0
  50. package/src/database/migration/types.ts +189 -0
  51. package/src/database/query_builder.ts +474 -0
  52. package/src/encryption/encryption_manager.ts +209 -0
  53. package/src/encryption/helpers.ts +158 -0
  54. package/src/encryption/index.ts +3 -0
  55. package/src/encryption/types.ts +6 -0
  56. package/src/events/emitter.ts +101 -0
  57. package/src/events/index.ts +2 -0
  58. package/src/exceptions/errors.ts +75 -0
  59. package/src/exceptions/exception_handler.ts +126 -0
  60. package/src/exceptions/helpers.ts +25 -0
  61. package/src/exceptions/http_exception.ts +129 -0
  62. package/src/exceptions/index.ts +23 -0
  63. package/src/exceptions/strav_error.ts +11 -0
  64. package/src/generators/api_generator.ts +972 -0
  65. package/src/generators/config.ts +87 -0
  66. package/src/generators/doc_generator.ts +974 -0
  67. package/src/generators/index.ts +11 -0
  68. package/src/generators/model_generator.ts +586 -0
  69. package/src/generators/route_generator.ts +188 -0
  70. package/src/generators/test_generator.ts +1666 -0
  71. package/src/helpers/crypto.ts +4 -0
  72. package/src/helpers/env.ts +50 -0
  73. package/src/helpers/identity.ts +12 -0
  74. package/src/helpers/index.ts +4 -0
  75. package/src/helpers/strings.ts +67 -0
  76. package/src/http/context.ts +215 -0
  77. package/src/http/cookie.ts +59 -0
  78. package/src/http/cors.ts +163 -0
  79. package/src/http/index.ts +16 -0
  80. package/src/http/middleware.ts +39 -0
  81. package/src/http/rate_limit.ts +173 -0
  82. package/src/http/router.ts +556 -0
  83. package/src/http/server.ts +79 -0
  84. package/src/i18n/defaults/en/validation.json +20 -0
  85. package/src/i18n/helpers.ts +72 -0
  86. package/src/i18n/i18n_manager.ts +155 -0
  87. package/src/i18n/index.ts +4 -0
  88. package/src/i18n/middleware.ts +90 -0
  89. package/src/i18n/translator.ts +96 -0
  90. package/src/i18n/types.ts +17 -0
  91. package/src/logger/index.ts +6 -0
  92. package/src/logger/logger.ts +100 -0
  93. package/src/logger/request_logger.ts +19 -0
  94. package/src/logger/sinks/console_sink.ts +24 -0
  95. package/src/logger/sinks/file_sink.ts +24 -0
  96. package/src/logger/sinks/sink.ts +36 -0
  97. package/src/mail/css_inliner.ts +79 -0
  98. package/src/mail/helpers.ts +212 -0
  99. package/src/mail/index.ts +19 -0
  100. package/src/mail/mail_manager.ts +92 -0
  101. package/src/mail/transports/log_transport.ts +69 -0
  102. package/src/mail/transports/resend_transport.ts +59 -0
  103. package/src/mail/transports/sendgrid_transport.ts +77 -0
  104. package/src/mail/transports/smtp_transport.ts +48 -0
  105. package/src/mail/types.ts +80 -0
  106. package/src/notification/base_notification.ts +67 -0
  107. package/src/notification/channels/database_channel.ts +30 -0
  108. package/src/notification/channels/discord_channel.ts +43 -0
  109. package/src/notification/channels/email_channel.ts +37 -0
  110. package/src/notification/channels/webhook_channel.ts +45 -0
  111. package/src/notification/helpers.ts +214 -0
  112. package/src/notification/index.ts +20 -0
  113. package/src/notification/notification_manager.ts +126 -0
  114. package/src/notification/types.ts +122 -0
  115. package/src/orm/base_model.ts +351 -0
  116. package/src/orm/decorators.ts +127 -0
  117. package/src/orm/index.ts +4 -0
  118. package/src/policy/authorize.ts +44 -0
  119. package/src/policy/index.ts +3 -0
  120. package/src/policy/policy_result.ts +13 -0
  121. package/src/queue/index.ts +11 -0
  122. package/src/queue/queue.ts +338 -0
  123. package/src/queue/worker.ts +197 -0
  124. package/src/scheduler/cron.ts +140 -0
  125. package/src/scheduler/index.ts +7 -0
  126. package/src/scheduler/runner.ts +116 -0
  127. package/src/scheduler/schedule.ts +183 -0
  128. package/src/scheduler/scheduler.ts +47 -0
  129. package/src/schema/database_representation.ts +122 -0
  130. package/src/schema/define_association.ts +60 -0
  131. package/src/schema/define_schema.ts +46 -0
  132. package/src/schema/field_builder.ts +155 -0
  133. package/src/schema/field_definition.ts +66 -0
  134. package/src/schema/index.ts +21 -0
  135. package/src/schema/naming.ts +19 -0
  136. package/src/schema/postgres.ts +109 -0
  137. package/src/schema/registry.ts +157 -0
  138. package/src/schema/representation_builder.ts +479 -0
  139. package/src/schema/type_builder.ts +107 -0
  140. package/src/schema/types.ts +35 -0
  141. package/src/session/index.ts +4 -0
  142. package/src/session/middleware.ts +46 -0
  143. package/src/session/session.ts +308 -0
  144. package/src/session/session_manager.ts +81 -0
  145. package/src/storage/index.ts +13 -0
  146. package/src/storage/local_driver.ts +46 -0
  147. package/src/storage/s3_driver.ts +51 -0
  148. package/src/storage/storage.ts +43 -0
  149. package/src/storage/storage_manager.ts +59 -0
  150. package/src/storage/types.ts +42 -0
  151. package/src/storage/upload.ts +91 -0
  152. package/src/validation/index.ts +18 -0
  153. package/src/validation/rules.ts +170 -0
  154. package/src/validation/validate.ts +41 -0
  155. package/src/view/cache.ts +47 -0
  156. package/src/view/client/islands.ts +50 -0
  157. package/src/view/compiler.ts +185 -0
  158. package/src/view/engine.ts +139 -0
  159. package/src/view/escape.ts +14 -0
  160. package/src/view/index.ts +13 -0
  161. package/src/view/islands/island_builder.ts +161 -0
  162. package/src/view/islands/vue_plugin.ts +140 -0
  163. package/src/view/middleware/static.ts +35 -0
  164. package/src/view/tokenizer.ts +172 -0
  165. 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
+ '&': '&amp;',
3
+ '<': '&lt;',
4
+ '>': '&gt;',
5
+ '"': '&quot;',
6
+ "'": '&#39;',
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
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"]
4
+ }