@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,91 @@
1
+ import Storage from './storage.ts'
2
+ import { StravError } from '../exceptions/strav_error.ts'
3
+
4
+ export class FileTooLargeError extends StravError {}
5
+
6
+ export class InvalidFileTypeError extends StravError {}
7
+
8
+ export interface UploadResult {
9
+ path: string
10
+ url: string
11
+ }
12
+
13
+ const SIZE_UNITS: Record<string, number> = {
14
+ b: 1,
15
+ kb: 1024,
16
+ mb: 1024 * 1024,
17
+ gb: 1024 * 1024 * 1024,
18
+ }
19
+
20
+ /**
21
+ * Fluent file upload builder with validation.
22
+ *
23
+ * @example
24
+ * const { path, url } = await Upload.file(avatar)
25
+ * .maxSize('5mb')
26
+ * .types(['image/jpeg', 'image/png'])
27
+ * .store('avatars')
28
+ */
29
+ export class Upload {
30
+ private _maxSizeBytes?: number
31
+ private _allowedTypes?: string[]
32
+
33
+ private constructor(private _file: File) {}
34
+
35
+ /** Start an upload pipeline. */
36
+ static file(file: File): Upload {
37
+ return new Upload(file)
38
+ }
39
+
40
+ /** Set maximum file size (e.g. '5mb', '500kb', '1gb', or bytes as number). */
41
+ maxSize(size: string | number): this {
42
+ this._maxSizeBytes = typeof size === 'number' ? size : parseSize(size)
43
+ return this
44
+ }
45
+
46
+ /** Set allowed MIME types. */
47
+ types(types: string[]): this {
48
+ this._allowedTypes = types
49
+ return this
50
+ }
51
+
52
+ /** Validate and store the file. */
53
+ async store(directory: string, name?: string): Promise<UploadResult> {
54
+ this.validate()
55
+
56
+ const path = name
57
+ ? await Storage.putAs(directory, this._file, name)
58
+ : await Storage.put(directory, this._file)
59
+
60
+ const url = Storage.url(path)
61
+
62
+ return { path, url }
63
+ }
64
+
65
+ private validate(): void {
66
+ if (this._maxSizeBytes !== undefined && this._file.size > this._maxSizeBytes) {
67
+ throw new FileTooLargeError(
68
+ `File size ${formatBytes(this._file.size)} exceeds maximum ${formatBytes(this._maxSizeBytes)}`
69
+ )
70
+ }
71
+
72
+ if (this._allowedTypes && !this._allowedTypes.includes(this._file.type)) {
73
+ throw new InvalidFileTypeError(
74
+ `File type "${this._file.type}" not allowed. Allowed: ${this._allowedTypes.join(', ')}`
75
+ )
76
+ }
77
+ }
78
+ }
79
+
80
+ function parseSize(size: string): number {
81
+ const match = size.toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)$/)
82
+ if (!match) throw new Error(`Invalid size format: "${size}". Use e.g. '5mb', '500kb', '1gb'`)
83
+ return parseFloat(match[1]!) * SIZE_UNITS[match[2]!]!
84
+ }
85
+
86
+ function formatBytes(bytes: number): string {
87
+ if (bytes < 1024) return `${bytes}B`
88
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
89
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
90
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`
91
+ }
@@ -0,0 +1,18 @@
1
+ export { validate } from './validate.ts'
2
+ export {
3
+ required,
4
+ string,
5
+ integer,
6
+ number,
7
+ boolean,
8
+ min,
9
+ max,
10
+ email,
11
+ url,
12
+ regex,
13
+ enumOf,
14
+ oneOf,
15
+ array,
16
+ } from './rules.ts'
17
+ export type { Rule } from './rules.ts'
18
+ export type { RuleSet, ValidationResult } from './validate.ts'
@@ -0,0 +1,170 @@
1
+ import { t } from '../i18n/helpers.ts'
2
+
3
+ export interface Rule {
4
+ name: string
5
+ validate(value: unknown): string | null
6
+ }
7
+
8
+ export function required(): Rule {
9
+ return {
10
+ name: 'required',
11
+ validate(value) {
12
+ if (value === undefined || value === null || value === '') {
13
+ return t('validation.required')
14
+ }
15
+ return null
16
+ },
17
+ }
18
+ }
19
+
20
+ export function string(): Rule {
21
+ return {
22
+ name: 'string',
23
+ validate(value) {
24
+ if (value === undefined || value === null) return null
25
+ if (typeof value !== 'string') return t('validation.string')
26
+ return null
27
+ },
28
+ }
29
+ }
30
+
31
+ export function integer(): Rule {
32
+ return {
33
+ name: 'integer',
34
+ validate(value) {
35
+ if (value === undefined || value === null) return null
36
+ if (typeof value !== 'number' || !Number.isInteger(value)) return t('validation.integer')
37
+ return null
38
+ },
39
+ }
40
+ }
41
+
42
+ export function number(): Rule {
43
+ return {
44
+ name: 'number',
45
+ validate(value) {
46
+ if (value === undefined || value === null) return null
47
+ if (typeof value !== 'number' || isNaN(value)) return t('validation.number')
48
+ return null
49
+ },
50
+ }
51
+ }
52
+
53
+ export function boolean(): Rule {
54
+ return {
55
+ name: 'boolean',
56
+ validate(value) {
57
+ if (value === undefined || value === null) return null
58
+ if (typeof value !== 'boolean') return t('validation.boolean')
59
+ return null
60
+ },
61
+ }
62
+ }
63
+
64
+ export function min(n: number): Rule {
65
+ return {
66
+ name: 'min',
67
+ validate(value) {
68
+ if (value === undefined || value === null) return null
69
+ if (typeof value === 'number') {
70
+ if (value < n) return t('validation.min.number', { min: n })
71
+ } else if (typeof value === 'string') {
72
+ if (value.length < n) return t('validation.min.string', { min: n })
73
+ }
74
+ return null
75
+ },
76
+ }
77
+ }
78
+
79
+ export function max(n: number): Rule {
80
+ return {
81
+ name: 'max',
82
+ validate(value) {
83
+ if (value === undefined || value === null) return null
84
+ if (typeof value === 'number') {
85
+ if (value > n) return t('validation.max.number', { max: n })
86
+ } else if (typeof value === 'string') {
87
+ if (value.length > n) return t('validation.max.string', { max: n })
88
+ }
89
+ return null
90
+ },
91
+ }
92
+ }
93
+
94
+ export function email(): Rule {
95
+ return {
96
+ name: 'email',
97
+ validate(value) {
98
+ if (value === undefined || value === null) return null
99
+ if (typeof value !== 'string') return t('validation.string')
100
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return t('validation.email')
101
+ return null
102
+ },
103
+ }
104
+ }
105
+
106
+ export function url(): Rule {
107
+ return {
108
+ name: 'url',
109
+ validate(value) {
110
+ if (value === undefined || value === null) return null
111
+ if (typeof value !== 'string') return t('validation.string')
112
+ try {
113
+ new URL(value)
114
+ return null
115
+ } catch {
116
+ return t('validation.url')
117
+ }
118
+ },
119
+ }
120
+ }
121
+
122
+ export function regex(pattern: RegExp): Rule {
123
+ return {
124
+ name: 'regex',
125
+ validate(value) {
126
+ if (value === undefined || value === null) return null
127
+ if (typeof value !== 'string') return t('validation.string')
128
+ if (!pattern.test(value)) return t('validation.regex')
129
+ return null
130
+ },
131
+ }
132
+ }
133
+
134
+ export function enumOf(enumObj: Record<string, string | number>): Rule {
135
+ const values = Object.values(enumObj)
136
+ return {
137
+ name: 'enumOf',
138
+ validate(value) {
139
+ if (value === undefined || value === null) return null
140
+ if (!values.includes(value as any)) {
141
+ return t('validation.enum', { values: values.join(', ') })
142
+ }
143
+ return null
144
+ },
145
+ }
146
+ }
147
+
148
+ export function oneOf(values: readonly (string | number | boolean)[]): Rule {
149
+ return {
150
+ name: 'oneOf',
151
+ validate(value) {
152
+ if (value === undefined || value === null) return null
153
+ if (!values.includes(value as any)) {
154
+ return t('validation.enum', { values: values.join(', ') })
155
+ }
156
+ return null
157
+ },
158
+ }
159
+ }
160
+
161
+ export function array(): Rule {
162
+ return {
163
+ name: 'array',
164
+ validate(value) {
165
+ if (value === undefined || value === null) return null
166
+ if (!Array.isArray(value)) return t('validation.array')
167
+ return null
168
+ },
169
+ }
170
+ }
@@ -0,0 +1,41 @@
1
+ import type { Rule } from './rules.ts'
2
+
3
+ export type RuleSet = Record<string, Rule[]>
4
+
5
+ export interface ValidationResult<T = Record<string, unknown>> {
6
+ data: T
7
+ errors: Record<string, string[]> | null
8
+ }
9
+
10
+ export function validate<T = Record<string, unknown>>(
11
+ input: unknown,
12
+ rules: RuleSet
13
+ ): ValidationResult<T> {
14
+ const record = (typeof input === 'object' && input !== null ? input : {}) as Record<
15
+ string,
16
+ unknown
17
+ >
18
+ const data: Record<string, unknown> = {}
19
+ const errors: Record<string, string[]> = {}
20
+ let hasErrors = false
21
+
22
+ for (const [field, fieldRules] of Object.entries(rules)) {
23
+ const value = record[field]
24
+ if (value !== undefined) data[field] = value
25
+
26
+ for (const rule of fieldRules) {
27
+ const error = rule.validate(value)
28
+ if (error) {
29
+ if (!errors[field]) errors[field] = []
30
+ errors[field]!.push(error)
31
+ hasErrors = true
32
+ break // stop at first error per field
33
+ }
34
+ }
35
+ }
36
+
37
+ return {
38
+ data: data as T,
39
+ errors: hasErrors ? errors : null,
40
+ }
41
+ }
@@ -0,0 +1,47 @@
1
+ export interface RenderResult {
2
+ output: string
3
+ blocks: Record<string, string>
4
+ }
5
+
6
+ export type RenderFunction = (
7
+ data: Record<string, unknown>,
8
+ includeFn: IncludeFn
9
+ ) => Promise<RenderResult>
10
+
11
+ export type IncludeFn = (name: string, data: Record<string, unknown>) => Promise<string>
12
+
13
+ export interface CacheEntry {
14
+ fn: RenderFunction
15
+ layout?: string
16
+ mtime: number
17
+ filePath: string
18
+ }
19
+
20
+ export default class TemplateCache {
21
+ private entries = new Map<string, CacheEntry>()
22
+
23
+ get(name: string): CacheEntry | undefined {
24
+ return this.entries.get(name)
25
+ }
26
+
27
+ set(name: string, entry: CacheEntry): void {
28
+ this.entries.set(name, entry)
29
+ }
30
+
31
+ async isStale(name: string): Promise<boolean> {
32
+ const entry = this.entries.get(name)
33
+ if (!entry) return true
34
+ const file = Bun.file(entry.filePath)
35
+ const exists = await file.exists()
36
+ if (!exists) return true
37
+ return file.lastModified > entry.mtime
38
+ }
39
+
40
+ delete(name: string): void {
41
+ this.entries.delete(name)
42
+ }
43
+
44
+ clear(): void {
45
+ this.entries.clear()
46
+ }
47
+ }
@@ -0,0 +1,50 @@
1
+ // @ts-nocheck — Client-side script; requires DOM types provided by the app's bundler config.
2
+ /**
3
+ * Vue Islands Bootstrap
4
+ *
5
+ * Auto-discovers elements with [data-vue] attributes and mounts
6
+ * Vue components on them. Register your components on the window
7
+ * before this script runs:
8
+ *
9
+ * import Counter from './components/Counter.vue'
10
+ * window.__vue_components = { counter: Counter }
11
+ *
12
+ * Then in your .strav templates:
13
+ * <vue:counter :initial="{{ count }}" label="Click me" />
14
+ */
15
+
16
+ import { createApp } from 'vue'
17
+
18
+ declare global {
19
+ interface Window {
20
+ __vue_components?: Record<string, any>
21
+ }
22
+ }
23
+
24
+ function toPascalCase(str: string): string {
25
+ return str.replace(/(^|-)(\w)/g, (_match, _sep, char) => char.toUpperCase())
26
+ }
27
+
28
+ function mountIslands(): void {
29
+ const components = window.__vue_components ?? {}
30
+
31
+ document.querySelectorAll<HTMLElement>('[data-vue]').forEach(el => {
32
+ const name = el.dataset.vue
33
+ if (!name) return
34
+
35
+ const Component = components[name] ?? components[toPascalCase(name)]
36
+ if (!Component) {
37
+ console.warn(`[islands] Unknown component: ${name}`)
38
+ return
39
+ }
40
+
41
+ const props = JSON.parse(el.dataset.props ?? '{}')
42
+ createApp(Component, props).mount(el)
43
+ })
44
+ }
45
+
46
+ if (document.readyState === 'loading') {
47
+ document.addEventListener('DOMContentLoaded', mountIslands)
48
+ } else {
49
+ mountIslands()
50
+ }
@@ -0,0 +1,185 @@
1
+ import { TemplateError } from '../exceptions/errors.ts'
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
+ }
13
+
14
+ function escapeJs(str: string): string {
15
+ return str
16
+ .replace(/\\/g, '\\\\')
17
+ .replace(/"/g, '\\"')
18
+ .replace(/\n/g, '\\n')
19
+ .replace(/\r/g, '\\r')
20
+ .replace(/\t/g, '\\t')
21
+ }
22
+
23
+ export function compile(tokens: Token[]): CompilationResult {
24
+ const lines: string[] = []
25
+ const stack: StackEntry[] = []
26
+ let layout: string | undefined
27
+
28
+ lines.push('let __out = "";')
29
+ lines.push('const __blocks = {};')
30
+
31
+ for (const token of tokens) {
32
+ switch (token.type) {
33
+ case 'text':
34
+ lines.push(`__out += "${escapeJs(token.value)}";`)
35
+ break
36
+
37
+ case 'escaped':
38
+ lines.push(`__out += __escape(${token.value});`)
39
+ break
40
+
41
+ case 'raw':
42
+ lines.push(`__out += (${token.value});`)
43
+ break
44
+
45
+ case 'comment':
46
+ // Stripped from output
47
+ break
48
+
49
+ case 'vue_island': {
50
+ const attrs = token.attrs ?? {}
51
+ const propParts: string[] = []
52
+ for (const [name, attr] of Object.entries(attrs)) {
53
+ if (attr.bound) {
54
+ propParts.push(`${JSON.stringify(name)}: (${attr.value})`)
55
+ } else {
56
+ propParts.push(`${JSON.stringify(name)}: ${JSON.stringify(attr.value)}`)
57
+ }
58
+ }
59
+ const propsExpr = `{${propParts.join(', ')}}`
60
+ const tag = escapeJs(token.tag!)
61
+ lines.push('__out += \'<div data-vue="' + tag + '"\'')
62
+ lines.push(' + " data-props=\'" + JSON.stringify(' + propsExpr + ') + "\'"')
63
+ lines.push(" + '></div>';")
64
+
65
+ break
66
+ }
67
+
68
+ case 'directive':
69
+ compileDirective(token, lines, stack, l => {
70
+ layout = l
71
+ })
72
+ break
73
+ }
74
+ }
75
+
76
+ if (stack.length > 0) {
77
+ const unclosed = stack[stack.length - 1]!
78
+ throw new TemplateError(`Unclosed @${unclosed.type} block (opened at line ${unclosed.line})`)
79
+ }
80
+
81
+ lines.push('return { output: __out, blocks: __blocks };')
82
+
83
+ return { code: lines.join('\n'), layout }
84
+ }
85
+
86
+ function compileDirective(
87
+ token: Token,
88
+ lines: string[],
89
+ stack: StackEntry[],
90
+ setLayout: (name: string) => void
91
+ ): void {
92
+ switch (token.directive) {
93
+ case 'if':
94
+ if (!token.args) throw new TemplateError(`@if requires a condition at line ${token.line}`)
95
+ lines.push(`if (${token.args}) {`)
96
+ stack.push({ type: 'if', line: token.line })
97
+ break
98
+
99
+ case 'elseif':
100
+ if (!token.args) throw new TemplateError(`@elseif requires a condition at line ${token.line}`)
101
+ if (!stack.length || stack[stack.length - 1]!.type !== 'if') {
102
+ throw new TemplateError(`@elseif without matching @if at line ${token.line}`)
103
+ }
104
+ lines.push(`} else if (${token.args}) {`)
105
+ break
106
+
107
+ case 'else':
108
+ if (!stack.length || stack[stack.length - 1]!.type !== 'if') {
109
+ throw new TemplateError(`@else without matching @if at line ${token.line}`)
110
+ }
111
+ lines.push(`} else {`)
112
+ break
113
+
114
+ case 'each': {
115
+ if (!token.args) throw new TemplateError(`@each requires arguments at line ${token.line}`)
116
+ const match = token.args.match(/^\s*(\w+)\s+in\s+(.+)$/)
117
+ if (!match) {
118
+ throw new TemplateError(`@each syntax error at line ${token.line}: expected "item in list"`)
119
+ }
120
+ const itemName = match[1]!
121
+ const listExpr = match[2]!.trim()
122
+ lines.push(`{`)
123
+ lines.push(` const __list = (${listExpr});`)
124
+ lines.push(` for (let $index = 0; $index < __list.length; $index++) {`)
125
+ lines.push(` const ${itemName} = __list[$index];`)
126
+ lines.push(` const $first = $index === 0;`)
127
+ lines.push(` const $last = $index === __list.length - 1;`)
128
+ stack.push({ type: 'each', line: token.line })
129
+ break
130
+ }
131
+
132
+ case 'layout': {
133
+ if (!token.args) throw new TemplateError(`@layout requires a name at line ${token.line}`)
134
+ const name = token.args.replace(/^['"]|['"]$/g, '').trim()
135
+ setLayout(name)
136
+ break
137
+ }
138
+
139
+ case 'block': {
140
+ if (!token.args) throw new TemplateError(`@block requires a name at line ${token.line}`)
141
+ const name = token.args.replace(/^['"]|['"]$/g, '').trim()
142
+ lines.push(`__blocks[${JSON.stringify(name)}] = (function() { let __out = "";`)
143
+ stack.push({ type: 'block', line: token.line })
144
+ break
145
+ }
146
+
147
+ case 'include': {
148
+ if (!token.args) throw new TemplateError(`@include requires arguments at line ${token.line}`)
149
+ const match = token.args.match(/^\s*['"]([^'"]+)['"]\s*(?:,\s*(.+))?\s*$/)
150
+ if (!match) {
151
+ throw new TemplateError(
152
+ `@include syntax error at line ${token.line}: expected "'name'" or "'name', data"`
153
+ )
154
+ }
155
+ const name = match[1]!
156
+ const dataExpr = match[2] ? match[2].trim() : '{}'
157
+ lines.push(`__out += await __include(${JSON.stringify(name)}, ${dataExpr});`)
158
+ break
159
+ }
160
+
161
+ case 'islands': {
162
+ const src = token.args
163
+ ? token.args.replace(/^['"]|['"]$/g, '').trim()
164
+ : '/islands.js'
165
+ lines.push(`__out += '<script src="${escapeJs(src)}"><\\/script>';`)
166
+ break
167
+ }
168
+
169
+ case 'end': {
170
+ if (!stack.length) {
171
+ throw new TemplateError(`Unexpected @end at line ${token.line} — no open block`)
172
+ }
173
+ const top = stack.pop()!
174
+ if (top.type === 'block') {
175
+ lines.push(`return __out; })();`)
176
+ } else if (top.type === 'each') {
177
+ lines.push(` }`) // close for loop
178
+ lines.push(`}`) // close block scope
179
+ } else {
180
+ lines.push(`}`)
181
+ }
182
+ break
183
+ }
184
+ }
185
+ }