@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,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
|
+
}
|