@strav/http 0.2.4 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/auth/middleware/guest.ts +1 -1
- package/src/client/index.ts +41 -0
- package/src/client/route_helper.ts +181 -0
- package/src/http/router.ts +11 -0
- package/src/index.ts +0 -1
- package/src/view/cache.ts +0 -47
- package/src/view/client/islands.ts +0 -84
- package/src/view/compiler.ts +0 -199
- package/src/view/engine.ts +0 -139
- package/src/view/escape.ts +0 -14
- package/src/view/index.ts +0 -13
- package/src/view/islands/island_builder.ts +0 -338
- package/src/view/islands/vue_plugin.ts +0 -136
- package/src/view/middleware/static.ts +0 -69
- package/src/view/tokenizer.ts +0 -182
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/http",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "HTTP layer for the Strav framework — router, server, middleware, authentication, sessions, validation, and views",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
],
|
|
20
20
|
"exports": {
|
|
21
21
|
".": "./src/index.ts",
|
|
22
|
+
"./client": "./src/client/index.ts",
|
|
22
23
|
"./http": "./src/http/index.ts",
|
|
23
24
|
"./http/*": "./src/http/*.ts",
|
|
24
25
|
"./view": "./src/view/index.ts",
|
|
@@ -35,8 +36,8 @@
|
|
|
35
36
|
"./providers/*": "./src/providers/*.ts"
|
|
36
37
|
},
|
|
37
38
|
"peerDependencies": {
|
|
38
|
-
"@strav/kernel": "0.
|
|
39
|
-
"@strav/database": "0.
|
|
39
|
+
"@strav/kernel": "0.2.6",
|
|
40
|
+
"@strav/database": "0.2.6"
|
|
40
41
|
},
|
|
41
42
|
"dependencies": {
|
|
42
43
|
"@vue/compiler-sfc": "^3.5.28",
|
|
@@ -27,7 +27,7 @@ export function guest(redirectTo?: string): Middleware {
|
|
|
27
27
|
|
|
28
28
|
if (guardName === 'session') {
|
|
29
29
|
const session = ctx.get<Session>('session')
|
|
30
|
-
isAuthenticated = session
|
|
30
|
+
isAuthenticated = session && session.isAuthenticated && !session.isExpired()
|
|
31
31
|
} else if (guardName === 'token') {
|
|
32
32
|
const header = ctx.header('authorization')
|
|
33
33
|
if (header?.startsWith('Bearer ')) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe HTTP utilities from @strav/http
|
|
3
|
+
*
|
|
4
|
+
* This module exports only browser-compatible functionality,
|
|
5
|
+
* avoiding Node.js dependencies that cause bundling issues.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { route, routeUrl } from '@strav/http/client'
|
|
10
|
+
*
|
|
11
|
+
* // Use route helpers on the client-side
|
|
12
|
+
* const response = await route('users.show', { params: { id: 123 } })
|
|
13
|
+
* const profileUrl = routeUrl('users.profile', { id: 456 })
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Re-export browser-safe route helpers (no Node.js dependencies)
|
|
18
|
+
export { route, routeUrl, registerRoutes } from './route_helper.ts'
|
|
19
|
+
export type { RouteOptions } from './route_helper.ts'
|
|
20
|
+
|
|
21
|
+
// Re-export browser-safe types
|
|
22
|
+
export type {
|
|
23
|
+
Handler,
|
|
24
|
+
Middleware,
|
|
25
|
+
Next
|
|
26
|
+
} from '../http/middleware.ts'
|
|
27
|
+
|
|
28
|
+
export type {
|
|
29
|
+
CorsOptions
|
|
30
|
+
} from '../http/cors.ts'
|
|
31
|
+
|
|
32
|
+
export type {
|
|
33
|
+
CookieOptions
|
|
34
|
+
} from '../http/cookie.ts'
|
|
35
|
+
|
|
36
|
+
export type {
|
|
37
|
+
RouteDefinition,
|
|
38
|
+
GroupOptions,
|
|
39
|
+
WebSocketHandlers,
|
|
40
|
+
WebSocketData
|
|
41
|
+
} from '../http/router.ts'
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe route helpers that work without the server-side router instance.
|
|
3
|
+
*
|
|
4
|
+
* These functions maintain the same API as the server-side route helpers but
|
|
5
|
+
* require route definitions to be provided at runtime or fallback to URL construction.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface RouteOptions extends Omit<RequestInit, 'body'> {
|
|
9
|
+
params?: Record<string, any>
|
|
10
|
+
body?: any
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Global registry for client-side route definitions
|
|
14
|
+
const clientRoutes = new Map<string, { method: string; pattern: string }>()
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register route definitions for client-side use.
|
|
18
|
+
* This should be called during app initialization with route data from the server.
|
|
19
|
+
*/
|
|
20
|
+
export function registerRoutes(routes: Record<string, { method: string; pattern: string }>) {
|
|
21
|
+
Object.entries(routes).forEach(([name, def]) => {
|
|
22
|
+
clientRoutes.set(name, def)
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate a URL for a named route with optional parameters.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* const profileUrl = routeUrl('users.profile', { id: 456 })
|
|
31
|
+
* // Returns '/users/456'
|
|
32
|
+
*/
|
|
33
|
+
export function routeUrl(name: string, params?: Record<string, any>): string {
|
|
34
|
+
const routeDef = clientRoutes.get(name)
|
|
35
|
+
if (!routeDef) {
|
|
36
|
+
throw new Error(`Route '${name}' not found. Make sure to call registerRoutes() with route definitions.`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return generateUrl(routeDef.pattern, params)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Invoke a named route with automatic method detection and smart defaults.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* // Simple POST with JSON body
|
|
47
|
+
* await route('auth.register', {
|
|
48
|
+
* name: 'John',
|
|
49
|
+
* email: 'john@example.com',
|
|
50
|
+
* password: 'secret'
|
|
51
|
+
* })
|
|
52
|
+
*
|
|
53
|
+
* // GET with URL parameters
|
|
54
|
+
* await route('users.show', { params: { id: 123 } })
|
|
55
|
+
*/
|
|
56
|
+
export async function route(
|
|
57
|
+
name: string,
|
|
58
|
+
data?: any,
|
|
59
|
+
options?: RouteOptions
|
|
60
|
+
): Promise<Response> {
|
|
61
|
+
const routeDef = clientRoutes.get(name)
|
|
62
|
+
if (!routeDef) {
|
|
63
|
+
throw new Error(`Route '${name}' not found. Make sure to call registerRoutes() with route definitions.`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Determine if data is the body or options
|
|
67
|
+
let body: any
|
|
68
|
+
let opts: RouteOptions = {}
|
|
69
|
+
|
|
70
|
+
if (data !== undefined) {
|
|
71
|
+
// If data has params, body, or any RequestInit properties, treat it as options
|
|
72
|
+
if (
|
|
73
|
+
typeof data === 'object' &&
|
|
74
|
+
!Array.isArray(data) &&
|
|
75
|
+
!(data instanceof FormData) &&
|
|
76
|
+
!(data instanceof Blob) &&
|
|
77
|
+
!(data instanceof ArrayBuffer) &&
|
|
78
|
+
!(data instanceof URLSearchParams) &&
|
|
79
|
+
('params' in data || 'body' in data || 'headers' in data || 'cache' in data ||
|
|
80
|
+
'credentials' in data || 'mode' in data || 'redirect' in data || 'referrer' in data)
|
|
81
|
+
) {
|
|
82
|
+
opts = data
|
|
83
|
+
body = opts.body
|
|
84
|
+
} else {
|
|
85
|
+
// Otherwise, treat data as the body
|
|
86
|
+
body = data
|
|
87
|
+
opts = options || {}
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
opts = options || {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Generate URL with parameters
|
|
94
|
+
const generatedUrl = generateUrl(routeDef.pattern, opts.params)
|
|
95
|
+
|
|
96
|
+
// Determine method from route definition
|
|
97
|
+
const method = opts.method || routeDef.method
|
|
98
|
+
|
|
99
|
+
// Build headers with smart defaults
|
|
100
|
+
const headers = new Headers(opts.headers)
|
|
101
|
+
|
|
102
|
+
// Set default Accept header if not provided
|
|
103
|
+
if (!headers.has('Accept')) {
|
|
104
|
+
headers.set('Accept', 'application/json')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Handle body and Content-Type
|
|
108
|
+
let requestBody: string | FormData | Blob | ArrayBuffer | URLSearchParams | undefined
|
|
109
|
+
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
|
110
|
+
if (body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer || body instanceof URLSearchParams) {
|
|
111
|
+
// Let fetch set the Content-Type for FormData, or use the existing type for Blob/ArrayBuffer
|
|
112
|
+
requestBody = body
|
|
113
|
+
} else if (typeof body === 'object') {
|
|
114
|
+
// JSON body
|
|
115
|
+
if (!headers.has('Content-Type')) {
|
|
116
|
+
headers.set('Content-Type', 'application/json')
|
|
117
|
+
}
|
|
118
|
+
requestBody = JSON.stringify(body)
|
|
119
|
+
} else {
|
|
120
|
+
// String or other primitive
|
|
121
|
+
requestBody = String(body)
|
|
122
|
+
if (!headers.has('Content-Type')) {
|
|
123
|
+
headers.set('Content-Type', 'text/plain')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Set default credentials if not provided
|
|
129
|
+
const credentials = opts.credentials || 'same-origin'
|
|
130
|
+
|
|
131
|
+
// Build final fetch options
|
|
132
|
+
const fetchOptions: RequestInit = {
|
|
133
|
+
...opts,
|
|
134
|
+
method,
|
|
135
|
+
headers,
|
|
136
|
+
credentials,
|
|
137
|
+
...(requestBody !== undefined && { body: requestBody })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Remove our custom properties
|
|
141
|
+
delete (fetchOptions as any).params
|
|
142
|
+
|
|
143
|
+
return fetch(generatedUrl, fetchOptions)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Generate URL from pattern and parameters (browser-safe version)
|
|
148
|
+
*/
|
|
149
|
+
function generateUrl(pattern: string, params?: Record<string, any>): string {
|
|
150
|
+
let url = pattern
|
|
151
|
+
const queryParams: Record<string, string> = {}
|
|
152
|
+
|
|
153
|
+
if (params) {
|
|
154
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
155
|
+
const paramPattern = `:${key}`
|
|
156
|
+
const wildcardPattern = `*${key}`
|
|
157
|
+
|
|
158
|
+
if (url.includes(paramPattern)) {
|
|
159
|
+
// Replace route parameter
|
|
160
|
+
url = url.replace(paramPattern, encodeURIComponent(String(value)))
|
|
161
|
+
} else if (url.includes(wildcardPattern)) {
|
|
162
|
+
// Replace wildcard parameter
|
|
163
|
+
url = url.replace(`/${wildcardPattern}`, `/${encodeURIComponent(String(value))}`)
|
|
164
|
+
} else {
|
|
165
|
+
// Add as query parameter
|
|
166
|
+
queryParams[key] = String(value)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Append query parameters
|
|
172
|
+
const queryString = Object.keys(queryParams)
|
|
173
|
+
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key] ?? '')}`)
|
|
174
|
+
.join('&')
|
|
175
|
+
|
|
176
|
+
if (queryString) {
|
|
177
|
+
url += (url.includes('?') ? '&' : '?') + queryString
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return url
|
|
181
|
+
}
|
package/src/http/router.ts
CHANGED
|
@@ -446,6 +446,17 @@ export default class Router {
|
|
|
446
446
|
return this.routes.find(route => route.name === name)
|
|
447
447
|
}
|
|
448
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Get all registered routes.
|
|
451
|
+
*
|
|
452
|
+
* @example
|
|
453
|
+
* const routes = router.getAllRoutes()
|
|
454
|
+
* // Returns array of all route definitions
|
|
455
|
+
*/
|
|
456
|
+
getAllRoutes(): readonly RouteDefinition[] {
|
|
457
|
+
return this.routes
|
|
458
|
+
}
|
|
459
|
+
|
|
449
460
|
/**
|
|
450
461
|
* Generate a URL for a named route with optional parameters.
|
|
451
462
|
*
|
package/src/index.ts
CHANGED
package/src/view/cache.ts
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,84 +0,0 @@
|
|
|
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 via a single shared Vue app instance.
|
|
7
|
-
* All islands share the same app context (plugins, provide/inject,
|
|
8
|
-
* global components), connected to their marker elements via Teleport.
|
|
9
|
-
*
|
|
10
|
-
* Register your components on the window before this script runs:
|
|
11
|
-
*
|
|
12
|
-
* import Counter from './components/Counter.vue'
|
|
13
|
-
* window.__vue_components = { counter: Counter }
|
|
14
|
-
*
|
|
15
|
-
* Optionally provide a setup function to install plugins:
|
|
16
|
-
*
|
|
17
|
-
* window.__vue_setup = (app) => {
|
|
18
|
-
* app.use(somePlugin)
|
|
19
|
-
* app.provide('key', value)
|
|
20
|
-
* }
|
|
21
|
-
*
|
|
22
|
-
* Then in your .strav templates:
|
|
23
|
-
* <vue:counter :initial="{{ count }}" label="Click me" />
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { createApp, defineComponent, h, Teleport } from 'vue'
|
|
27
|
-
|
|
28
|
-
declare global {
|
|
29
|
-
interface Window {
|
|
30
|
-
__vue_components?: Record<string, any>
|
|
31
|
-
__vue_setup?: (app: any) => void
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function toPascalCase(str: string): string {
|
|
36
|
-
return str.replace(/(^|-)(\w)/g, (_match, _sep, char) => char.toUpperCase())
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function mountIslands(): void {
|
|
40
|
-
const components = window.__vue_components ?? {}
|
|
41
|
-
|
|
42
|
-
const islands: { Component: any; props: Record<string, any>; el: HTMLElement }[] = []
|
|
43
|
-
|
|
44
|
-
document.querySelectorAll<HTMLElement>('[data-vue]').forEach(el => {
|
|
45
|
-
const name = el.dataset.vue
|
|
46
|
-
if (!name) return
|
|
47
|
-
|
|
48
|
-
const Component = components[name] ?? components[toPascalCase(name)]
|
|
49
|
-
if (!Component) {
|
|
50
|
-
console.warn(`[islands] Unknown component: ${name}`)
|
|
51
|
-
return
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const props = JSON.parse(el.dataset.props ?? '{}')
|
|
55
|
-
islands.push({ Component, props, el })
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
if (islands.length === 0) return
|
|
59
|
-
|
|
60
|
-
const Root = defineComponent({
|
|
61
|
-
render() {
|
|
62
|
-
return islands.map(island =>
|
|
63
|
-
h(Teleport, { to: island.el }, [h(island.Component, island.props)])
|
|
64
|
-
)
|
|
65
|
-
},
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
const app = createApp(Root)
|
|
69
|
-
|
|
70
|
-
if (typeof window.__vue_setup === 'function') {
|
|
71
|
-
window.__vue_setup(app)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const root = document.createElement('div')
|
|
75
|
-
root.style.display = 'contents'
|
|
76
|
-
document.body.appendChild(root)
|
|
77
|
-
app.mount(root)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (document.readyState === 'loading') {
|
|
81
|
-
document.addEventListener('DOMContentLoaded', mountIslands)
|
|
82
|
-
} else {
|
|
83
|
-
mountIslands()
|
|
84
|
-
}
|
package/src/view/compiler.ts
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import { TemplateError } from '@strav/kernel/exceptions/errors'
|
|
2
|
-
import type { Token } from './tokenizer.ts'
|
|
3
|
-
|
|
4
|
-
export interface CompilationResult {
|
|
5
|
-
code: string
|
|
6
|
-
layout?: string
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
interface StackEntry {
|
|
10
|
-
type: 'if' | 'each' | 'block'
|
|
11
|
-
line: number
|
|
12
|
-
blockName?: string
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function escapeJs(str: string): string {
|
|
16
|
-
return str
|
|
17
|
-
.replace(/\\/g, '\\\\')
|
|
18
|
-
.replace(/"/g, '\\"')
|
|
19
|
-
.replace(/\n/g, '\\n')
|
|
20
|
-
.replace(/\r/g, '\\r')
|
|
21
|
-
.replace(/\t/g, '\\t')
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function compile(tokens: Token[]): CompilationResult {
|
|
25
|
-
const lines: string[] = []
|
|
26
|
-
const stack: StackEntry[] = []
|
|
27
|
-
let layout: string | undefined
|
|
28
|
-
|
|
29
|
-
lines.push('let __out = "";')
|
|
30
|
-
lines.push('const __blocks = {};')
|
|
31
|
-
|
|
32
|
-
for (const token of tokens) {
|
|
33
|
-
switch (token.type) {
|
|
34
|
-
case 'text':
|
|
35
|
-
lines.push(`__out += "${escapeJs(token.value)}";`)
|
|
36
|
-
break
|
|
37
|
-
|
|
38
|
-
case 'escaped':
|
|
39
|
-
lines.push(`__out += __escape(${token.value});`)
|
|
40
|
-
break
|
|
41
|
-
|
|
42
|
-
case 'raw':
|
|
43
|
-
lines.push(`__out += (${token.value});`)
|
|
44
|
-
break
|
|
45
|
-
|
|
46
|
-
case 'comment':
|
|
47
|
-
// Stripped from output
|
|
48
|
-
break
|
|
49
|
-
|
|
50
|
-
case 'vue_island': {
|
|
51
|
-
const attrs = token.attrs ?? {}
|
|
52
|
-
const propParts: string[] = []
|
|
53
|
-
for (const [name, attr] of Object.entries(attrs)) {
|
|
54
|
-
if (attr.bound) {
|
|
55
|
-
propParts.push(`${JSON.stringify(name)}: (${attr.value})`)
|
|
56
|
-
} else {
|
|
57
|
-
propParts.push(`${JSON.stringify(name)}: ${JSON.stringify(attr.value)}`)
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
const propsExpr = `{${propParts.join(', ')}}`
|
|
61
|
-
const tag = escapeJs(token.tag!)
|
|
62
|
-
lines.push('__out += \'<div data-vue="' + tag + '"\'')
|
|
63
|
-
lines.push(
|
|
64
|
-
' + " data-props=\'" + JSON.stringify(' + propsExpr + ").replace(/'/g, ''') + \"'\""
|
|
65
|
-
)
|
|
66
|
-
lines.push(" + '></div>';")
|
|
67
|
-
|
|
68
|
-
break
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
case 'directive':
|
|
72
|
-
compileDirective(token, lines, stack, l => {
|
|
73
|
-
layout = l
|
|
74
|
-
})
|
|
75
|
-
break
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (stack.length > 0) {
|
|
80
|
-
const unclosed = stack[stack.length - 1]!
|
|
81
|
-
throw new TemplateError(`Unclosed @${unclosed.type} block (opened at line ${unclosed.line})`)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
lines.push('return { output: __out, blocks: __blocks };')
|
|
85
|
-
|
|
86
|
-
return { code: lines.join('\n'), layout }
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function compileDirective(
|
|
90
|
-
token: Token,
|
|
91
|
-
lines: string[],
|
|
92
|
-
stack: StackEntry[],
|
|
93
|
-
setLayout: (name: string) => void
|
|
94
|
-
): void {
|
|
95
|
-
switch (token.directive) {
|
|
96
|
-
case 'if':
|
|
97
|
-
if (!token.args) throw new TemplateError(`@if requires a condition at line ${token.line}`)
|
|
98
|
-
lines.push(`if (${token.args}) {`)
|
|
99
|
-
stack.push({ type: 'if', line: token.line })
|
|
100
|
-
break
|
|
101
|
-
|
|
102
|
-
case 'elseif':
|
|
103
|
-
if (!token.args) throw new TemplateError(`@elseif requires a condition at line ${token.line}`)
|
|
104
|
-
if (!stack.length || stack[stack.length - 1]!.type !== 'if') {
|
|
105
|
-
throw new TemplateError(`@elseif without matching @if at line ${token.line}`)
|
|
106
|
-
}
|
|
107
|
-
lines.push(`} else if (${token.args}) {`)
|
|
108
|
-
break
|
|
109
|
-
|
|
110
|
-
case 'else':
|
|
111
|
-
if (!stack.length || stack[stack.length - 1]!.type !== 'if') {
|
|
112
|
-
throw new TemplateError(`@else without matching @if at line ${token.line}`)
|
|
113
|
-
}
|
|
114
|
-
lines.push(`} else {`)
|
|
115
|
-
break
|
|
116
|
-
|
|
117
|
-
case 'each': {
|
|
118
|
-
if (!token.args) throw new TemplateError(`@each requires arguments at line ${token.line}`)
|
|
119
|
-
const match = token.args.match(/^\s*(\w+)\s+in\s+(.+)$/)
|
|
120
|
-
if (!match) {
|
|
121
|
-
throw new TemplateError(`@each syntax error at line ${token.line}: expected "item in list"`)
|
|
122
|
-
}
|
|
123
|
-
const itemName = match[1]!
|
|
124
|
-
const listExpr = match[2]!.trim()
|
|
125
|
-
lines.push(`{`)
|
|
126
|
-
lines.push(` const __list = (${listExpr});`)
|
|
127
|
-
lines.push(` for (let $index = 0; $index < __list.length; $index++) {`)
|
|
128
|
-
lines.push(` const ${itemName} = __list[$index];`)
|
|
129
|
-
lines.push(` const $first = $index === 0;`)
|
|
130
|
-
lines.push(` const $last = $index === __list.length - 1;`)
|
|
131
|
-
stack.push({ type: 'each', line: token.line })
|
|
132
|
-
break
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
case 'layout': {
|
|
136
|
-
if (!token.args) throw new TemplateError(`@layout requires a name at line ${token.line}`)
|
|
137
|
-
const name = token.args.replace(/^['"]|['"]$/g, '').trim()
|
|
138
|
-
setLayout(name)
|
|
139
|
-
break
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
case 'block': {
|
|
143
|
-
if (!token.args) throw new TemplateError(`@block requires a name at line ${token.line}`)
|
|
144
|
-
const name = token.args.replace(/^['"]|['"]$/g, '').trim()
|
|
145
|
-
const nameStr = JSON.stringify(name)
|
|
146
|
-
// If a child template already provided this block as data, yield it.
|
|
147
|
-
// Otherwise, render the default content between @block and @end.
|
|
148
|
-
lines.push(`if (typeof ${name} !== 'undefined' && ${name} !== null) {`)
|
|
149
|
-
lines.push(` __out += ${name};`)
|
|
150
|
-
lines.push(` __blocks[${nameStr}] = ${name};`)
|
|
151
|
-
lines.push(`} else {`)
|
|
152
|
-
lines.push(` __blocks[${nameStr}] = (function() { let __out = "";`)
|
|
153
|
-
stack.push({ type: 'block', line: token.line, blockName: name })
|
|
154
|
-
break
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
case 'include': {
|
|
158
|
-
if (!token.args) throw new TemplateError(`@include requires arguments at line ${token.line}`)
|
|
159
|
-
const match = token.args.match(/^\s*['"]([^'"]+)['"]\s*(?:,\s*(.+))?\s*$/)
|
|
160
|
-
if (!match) {
|
|
161
|
-
throw new TemplateError(
|
|
162
|
-
`@include syntax error at line ${token.line}: expected "'name'" or "'name', data"`
|
|
163
|
-
)
|
|
164
|
-
}
|
|
165
|
-
const name = match[1]!
|
|
166
|
-
const dataExpr = match[2] ? match[2].trim() : '{}'
|
|
167
|
-
lines.push(`__out += await __include(${JSON.stringify(name)}, ${dataExpr});`)
|
|
168
|
-
break
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
case 'islands': {
|
|
172
|
-
const src = token.args ? token.args.replace(/^['"]|['"]$/g, '').trim() : '/islands.js'
|
|
173
|
-
// Use __islandsSrc (set by IslandBuilder via ViewEngine.setGlobal) for versioned URL, fallback to static src
|
|
174
|
-
lines.push(
|
|
175
|
-
`__out += '<script src="' + (typeof __islandsSrc !== 'undefined' ? __islandsSrc : '${escapeJs(src)}') + '"><\\/script>';`
|
|
176
|
-
)
|
|
177
|
-
break
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
case 'end': {
|
|
181
|
-
if (!stack.length) {
|
|
182
|
-
throw new TemplateError(`Unexpected @end at line ${token.line} — no open block`)
|
|
183
|
-
}
|
|
184
|
-
const top = stack.pop()!
|
|
185
|
-
if (top.type === 'block') {
|
|
186
|
-
const nameStr = JSON.stringify(top.blockName!)
|
|
187
|
-
lines.push(` return __out; })();`)
|
|
188
|
-
lines.push(` __out += __blocks[${nameStr}];`)
|
|
189
|
-
lines.push(`}`)
|
|
190
|
-
} else if (top.type === 'each') {
|
|
191
|
-
lines.push(` }`) // close for loop
|
|
192
|
-
lines.push(`}`) // close block scope
|
|
193
|
-
} else {
|
|
194
|
-
lines.push(`}`)
|
|
195
|
-
}
|
|
196
|
-
break
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|