@strav/view 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/package.json +49 -0
- package/src/asset_versioner.ts +92 -0
- package/src/cache.ts +47 -0
- package/src/client/islands.ts +84 -0
- package/src/client/router.ts +272 -0
- package/src/compiler.ts +293 -0
- package/src/engine.ts +162 -0
- package/src/escape.ts +14 -0
- package/src/index.ts +17 -0
- package/src/islands/island_builder.ts +437 -0
- package/src/islands/vue_plugin.ts +136 -0
- package/src/providers/view_provider.ts +43 -0
- package/src/route_types.ts +33 -0
- package/src/spa_routes.ts +25 -0
- package/src/tokenizer.ts +186 -0
- package/tsconfig.json +5 -0
package/src/compiler.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { TemplateError } from '@stravigor/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' | 'section'
|
|
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
|
+
/**
|
|
25
|
+
* Parse a conditional array like `['p-4', 'bold' => isActive, 'dim' => !isActive]`.
|
|
26
|
+
* Returns an array of JS expressions:
|
|
27
|
+
* - plain entries stay as-is: `'p-4'`
|
|
28
|
+
* - `'value' => condition` becomes `(condition) ? 'value' : ''`
|
|
29
|
+
*/
|
|
30
|
+
function parseConditionalArray(args: string): string[] {
|
|
31
|
+
// Strip outer whitespace and brackets: "[ ... ]" → "..."
|
|
32
|
+
let inner = args.trim()
|
|
33
|
+
if (inner.startsWith('[') && inner.endsWith(']')) {
|
|
34
|
+
inner = inner.slice(1, -1)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Split by top-level commas (respect quotes, parens, brackets)
|
|
38
|
+
const entries: string[] = []
|
|
39
|
+
let current = ''
|
|
40
|
+
let depth = 0 // ( )
|
|
41
|
+
let bracketDepth = 0 // [ ]
|
|
42
|
+
let inSingle = false
|
|
43
|
+
let inDouble = false
|
|
44
|
+
let inBacktick = false
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < inner.length; i++) {
|
|
47
|
+
const ch = inner[i]!
|
|
48
|
+
const prev = i > 0 ? inner[i - 1] : ''
|
|
49
|
+
|
|
50
|
+
if (prev !== '\\') {
|
|
51
|
+
if (ch === "'" && !inDouble && !inBacktick) inSingle = !inSingle
|
|
52
|
+
else if (ch === '"' && !inSingle && !inBacktick) inDouble = !inDouble
|
|
53
|
+
else if (ch === '`' && !inSingle && !inDouble) inBacktick = !inBacktick
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const inString = inSingle || inDouble || inBacktick
|
|
57
|
+
|
|
58
|
+
if (!inString) {
|
|
59
|
+
if (ch === '(') depth++
|
|
60
|
+
else if (ch === ')') depth--
|
|
61
|
+
else if (ch === '[') bracketDepth++
|
|
62
|
+
else if (ch === ']') bracketDepth--
|
|
63
|
+
else if (ch === ',' && depth === 0 && bracketDepth === 0) {
|
|
64
|
+
entries.push(current.trim())
|
|
65
|
+
current = ''
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
current += ch
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const last = current.trim()
|
|
74
|
+
if (last) entries.push(last)
|
|
75
|
+
|
|
76
|
+
// Transform each entry
|
|
77
|
+
return entries.map(entry => {
|
|
78
|
+
// Match: 'value' => condition or "value" => condition
|
|
79
|
+
const arrowMatch = entry.match(/^(['"])(.*?)\1\s*=>\s*(.+)$/)
|
|
80
|
+
if (arrowMatch) {
|
|
81
|
+
const value = arrowMatch[1]! + arrowMatch[2]! + arrowMatch[1]!
|
|
82
|
+
const condition = arrowMatch[3]!.trim()
|
|
83
|
+
return `(${condition}) ? ${value} : ''`
|
|
84
|
+
}
|
|
85
|
+
// Plain expression — pass through
|
|
86
|
+
return entry
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function compile(tokens: Token[]): CompilationResult {
|
|
91
|
+
const lines: string[] = []
|
|
92
|
+
const stack: StackEntry[] = []
|
|
93
|
+
let layout: string | undefined
|
|
94
|
+
|
|
95
|
+
lines.push('let __out = "";')
|
|
96
|
+
lines.push('const __blocks = {};')
|
|
97
|
+
|
|
98
|
+
for (const token of tokens) {
|
|
99
|
+
switch (token.type) {
|
|
100
|
+
case 'text':
|
|
101
|
+
lines.push(`__out += "${escapeJs(token.value)}";`)
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
case 'escaped':
|
|
105
|
+
lines.push(`__out += __escape(${token.value});`)
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
case 'raw':
|
|
109
|
+
lines.push(`__out += (${token.value});`)
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
case 'comment':
|
|
113
|
+
// Stripped from output
|
|
114
|
+
break
|
|
115
|
+
|
|
116
|
+
case 'vue_island': {
|
|
117
|
+
const attrs = token.attrs ?? {}
|
|
118
|
+
const propParts: string[] = []
|
|
119
|
+
for (const [name, attr] of Object.entries(attrs)) {
|
|
120
|
+
if (attr.bound) {
|
|
121
|
+
propParts.push(`${JSON.stringify(name)}: (${attr.value})`)
|
|
122
|
+
} else {
|
|
123
|
+
propParts.push(`${JSON.stringify(name)}: ${JSON.stringify(attr.value)}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const propsExpr = `{${propParts.join(', ')}}`
|
|
127
|
+
const tag = escapeJs(token.tag!)
|
|
128
|
+
lines.push('__out += \'<div data-vue="' + tag + '"\'')
|
|
129
|
+
lines.push(
|
|
130
|
+
' + " data-props=\'" + JSON.stringify(' + propsExpr + ").replace(/'/g, ''') + \"'\""
|
|
131
|
+
)
|
|
132
|
+
lines.push(" + '></div>';")
|
|
133
|
+
|
|
134
|
+
break
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case 'directive':
|
|
138
|
+
compileDirective(token, lines, stack, l => {
|
|
139
|
+
layout = l
|
|
140
|
+
})
|
|
141
|
+
break
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (stack.length > 0) {
|
|
146
|
+
const unclosed = stack[stack.length - 1]!
|
|
147
|
+
throw new TemplateError(`Unclosed @${unclosed.type} block (opened at line ${unclosed.line})`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
lines.push('return { output: __out, blocks: __blocks };')
|
|
151
|
+
|
|
152
|
+
return { code: lines.join('\n'), layout }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function compileDirective(
|
|
156
|
+
token: Token,
|
|
157
|
+
lines: string[],
|
|
158
|
+
stack: StackEntry[],
|
|
159
|
+
setLayout: (name: string) => void
|
|
160
|
+
): void {
|
|
161
|
+
switch (token.directive) {
|
|
162
|
+
case 'if':
|
|
163
|
+
if (!token.args) throw new TemplateError(`@if requires a condition at line ${token.line}`)
|
|
164
|
+
lines.push(`if (${token.args}) {`)
|
|
165
|
+
stack.push({ type: 'if', line: token.line })
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
case 'elseif':
|
|
169
|
+
if (!token.args) throw new TemplateError(`@elseif requires a condition at line ${token.line}`)
|
|
170
|
+
if (!stack.length || stack[stack.length - 1]!.type !== 'if') {
|
|
171
|
+
throw new TemplateError(`@elseif without matching @if at line ${token.line}`)
|
|
172
|
+
}
|
|
173
|
+
lines.push(`} else if (${token.args}) {`)
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
case 'else':
|
|
177
|
+
if (!stack.length || stack[stack.length - 1]!.type !== 'if') {
|
|
178
|
+
throw new TemplateError(`@else without matching @if at line ${token.line}`)
|
|
179
|
+
}
|
|
180
|
+
lines.push(`} else {`)
|
|
181
|
+
break
|
|
182
|
+
|
|
183
|
+
case 'each': {
|
|
184
|
+
if (!token.args) throw new TemplateError(`@each requires arguments at line ${token.line}`)
|
|
185
|
+
const match = token.args.match(/^\s*(\w+)\s+in\s+(.+)$/)
|
|
186
|
+
if (!match) {
|
|
187
|
+
throw new TemplateError(`@each syntax error at line ${token.line}: expected "item in list"`)
|
|
188
|
+
}
|
|
189
|
+
const itemName = match[1]!
|
|
190
|
+
const listExpr = match[2]!.trim()
|
|
191
|
+
lines.push(`{`)
|
|
192
|
+
lines.push(` const __list = (${listExpr});`)
|
|
193
|
+
lines.push(` for (let $index = 0; $index < __list.length; $index++) {`)
|
|
194
|
+
lines.push(` const ${itemName} = __list[$index];`)
|
|
195
|
+
lines.push(` const $first = $index === 0;`)
|
|
196
|
+
lines.push(` const $last = $index === __list.length - 1;`)
|
|
197
|
+
stack.push({ type: 'each', line: token.line })
|
|
198
|
+
break
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case 'layout': {
|
|
202
|
+
if (!token.args) throw new TemplateError(`@layout requires a name at line ${token.line}`)
|
|
203
|
+
const name = token.args.replace(/^['"]|['"]$/g, '').trim()
|
|
204
|
+
setLayout(name)
|
|
205
|
+
break
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
case 'section': {
|
|
209
|
+
if (!token.args) throw new TemplateError(`@section requires a name at line ${token.line}`)
|
|
210
|
+
const name = token.args.replace(/^['"]|['"]$/g, '').trim()
|
|
211
|
+
const nameStr = JSON.stringify(name)
|
|
212
|
+
// Capture content between @section and @end into __blocks.
|
|
213
|
+
// Does not output inline — content flows to parent layout via result.blocks.
|
|
214
|
+
lines.push(`__blocks[${nameStr}] = (function() { let __out = "";`)
|
|
215
|
+
stack.push({ type: 'section', line: token.line, blockName: name })
|
|
216
|
+
break
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
case 'show': {
|
|
220
|
+
if (!token.args) throw new TemplateError(`@show requires a name at line ${token.line}`)
|
|
221
|
+
const name = token.args.replace(/^['"]|['"]$/g, '').trim()
|
|
222
|
+
const nameStr = JSON.stringify(name)
|
|
223
|
+
// Output block content: prefer child-provided variable, fall back to __blocks.
|
|
224
|
+
lines.push(`if (typeof ${name} !== 'undefined' && ${name} !== null) {`)
|
|
225
|
+
lines.push(` __out += ${name};`)
|
|
226
|
+
lines.push(`} else {`)
|
|
227
|
+
lines.push(` __out += __blocks[${nameStr}] || '';`)
|
|
228
|
+
lines.push(`}`)
|
|
229
|
+
break
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
case 'include': {
|
|
233
|
+
if (!token.args) throw new TemplateError(`@include requires arguments at line ${token.line}`)
|
|
234
|
+
const match = token.args.match(/^\s*['"]([^'"]+)['"]\s*(?:,\s*(.+))?\s*$/)
|
|
235
|
+
if (!match) {
|
|
236
|
+
throw new TemplateError(
|
|
237
|
+
`@include syntax error at line ${token.line}: expected "'name'" or "'name', data"`
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
const name = match[1]!
|
|
241
|
+
const dataExpr = match[2] ? match[2].trim() : '{}'
|
|
242
|
+
lines.push(`__out += await __include(${JSON.stringify(name)}, ${dataExpr});`)
|
|
243
|
+
break
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case 'islands': {
|
|
247
|
+
const src = token.args ? token.args.replace(/^['"]|['"]$/g, '').trim() : '/islands.js'
|
|
248
|
+
// Use __islandsSrc (set by IslandBuilder via ViewEngine.setGlobal) for versioned URL, fallback to static src
|
|
249
|
+
lines.push(
|
|
250
|
+
`__out += '<script src="' + (typeof __islandsSrc !== 'undefined' ? __islandsSrc : '${escapeJs(src)}') + '"><\\/script>';`
|
|
251
|
+
)
|
|
252
|
+
break
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
case 'class': {
|
|
256
|
+
if (!token.args) throw new TemplateError(`@class requires arguments at line ${token.line}`)
|
|
257
|
+
const classEntries = parseConditionalArray(token.args)
|
|
258
|
+
const classExpr = `[${classEntries.join(', ')}].filter(Boolean).join(' ')`
|
|
259
|
+
lines.push(`__out += 'class="' + __escape(${classExpr}) + '"';`)
|
|
260
|
+
break
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
case 'style': {
|
|
264
|
+
if (!token.args) throw new TemplateError(`@style requires arguments at line ${token.line}`)
|
|
265
|
+
const styleEntries = parseConditionalArray(token.args)
|
|
266
|
+
const styleExpr = `[${styleEntries.join(', ')}].filter(Boolean).join('; ')`
|
|
267
|
+
lines.push(`__out += 'style="' + __escape(${styleExpr}) + '"';`)
|
|
268
|
+
break
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
case 'csrf':
|
|
272
|
+
lines.push(
|
|
273
|
+
`__out += '<input type="hidden" name="_token" value="' + __escape(csrfToken) + '">';`
|
|
274
|
+
)
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
case 'end': {
|
|
278
|
+
if (!stack.length) {
|
|
279
|
+
throw new TemplateError(`Unexpected @end at line ${token.line} — no open block`)
|
|
280
|
+
}
|
|
281
|
+
const top = stack.pop()!
|
|
282
|
+
if (top.type === 'section') {
|
|
283
|
+
lines.push(` return __out; })();`)
|
|
284
|
+
} else if (top.type === 'each') {
|
|
285
|
+
lines.push(` }`) // close for loop
|
|
286
|
+
lines.push(`}`) // close block scope
|
|
287
|
+
} else {
|
|
288
|
+
lines.push(`}`)
|
|
289
|
+
}
|
|
290
|
+
break
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { resolve, join } from 'node:path'
|
|
2
|
+
import { watch as fsWatch, type FSWatcher } from 'node:fs'
|
|
3
|
+
import { inject } from '@stravigor/kernel/core/inject'
|
|
4
|
+
import Configuration from '@stravigor/kernel/config/configuration'
|
|
5
|
+
import { escapeHtml } from './escape.ts'
|
|
6
|
+
import { tokenize } from './tokenizer.ts'
|
|
7
|
+
import { compile } from './compiler.ts'
|
|
8
|
+
import TemplateCache from './cache.ts'
|
|
9
|
+
import type { CacheEntry, RenderFunction, IncludeFn } from './cache.ts'
|
|
10
|
+
import { ConfigurationError, TemplateError } from '@stravigor/kernel/exceptions/errors'
|
|
11
|
+
|
|
12
|
+
const MAX_INCLUDE_DEPTH = 50
|
|
13
|
+
|
|
14
|
+
@inject
|
|
15
|
+
export default class ViewEngine {
|
|
16
|
+
private static _instance: ViewEngine | null = null
|
|
17
|
+
private static _globals: Record<string, unknown> = {}
|
|
18
|
+
|
|
19
|
+
private directory: string
|
|
20
|
+
private cacheEnabled: boolean
|
|
21
|
+
private cache: TemplateCache
|
|
22
|
+
private watcher: FSWatcher | null = null
|
|
23
|
+
|
|
24
|
+
constructor(config: Configuration) {
|
|
25
|
+
this.directory = resolve(config.get('view.directory', 'resources/views') as string)
|
|
26
|
+
this.cacheEnabled = config.get('view.cache', true) as boolean
|
|
27
|
+
this.cache = new TemplateCache()
|
|
28
|
+
ViewEngine._instance = this
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static get instance(): ViewEngine {
|
|
32
|
+
if (!ViewEngine._instance) {
|
|
33
|
+
throw new ConfigurationError('ViewEngine not configured. Register it in the container.')
|
|
34
|
+
}
|
|
35
|
+
return ViewEngine._instance
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Register a global variable available in all templates. */
|
|
39
|
+
static setGlobal(key: string, value: unknown): void {
|
|
40
|
+
ViewEngine._globals[key] = value
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async render(name: string, data: Record<string, unknown> = {}): Promise<string> {
|
|
44
|
+
const merged = { ...ViewEngine._globals, ...data }
|
|
45
|
+
return this.renderWithDepth(name, merged, 0)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async renderWithDepth(
|
|
49
|
+
name: string,
|
|
50
|
+
data: Record<string, unknown>,
|
|
51
|
+
depth: number
|
|
52
|
+
): Promise<string> {
|
|
53
|
+
if (depth > MAX_INCLUDE_DEPTH) {
|
|
54
|
+
throw new TemplateError(
|
|
55
|
+
`Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded — possible circular include`
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const entry = await this.resolve(name)
|
|
60
|
+
|
|
61
|
+
const includeFn: IncludeFn = (includeName, includeData) => {
|
|
62
|
+
return this.renderWithDepth(includeName, { ...data, ...includeData }, depth + 1)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = await entry.fn(data, includeFn)
|
|
66
|
+
|
|
67
|
+
// Layout inheritance: render child first, then render layout with blocks merged
|
|
68
|
+
if (entry.layout) {
|
|
69
|
+
const layoutData = { ...data, ...result.blocks }
|
|
70
|
+
return this.renderWithDepth(entry.layout, layoutData, depth + 1)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return result.output
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async resolve(name: string): Promise<CacheEntry> {
|
|
77
|
+
const cached = this.cache.get(name)
|
|
78
|
+
|
|
79
|
+
if (cached) {
|
|
80
|
+
if (this.cacheEnabled) return cached
|
|
81
|
+
const stale = await this.cache.isStale(name)
|
|
82
|
+
if (!stale) return cached
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return this.compileTemplate(name)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async compileTemplate(name: string): Promise<CacheEntry> {
|
|
89
|
+
const filePath = this.resolvePath(name)
|
|
90
|
+
const file = Bun.file(filePath)
|
|
91
|
+
|
|
92
|
+
const exists = await file.exists()
|
|
93
|
+
if (!exists) {
|
|
94
|
+
throw new TemplateError(`Template not found: ${name} (looked at ${filePath})`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const source = await file.text()
|
|
98
|
+
const tokens = tokenize(source)
|
|
99
|
+
const result = compile(tokens)
|
|
100
|
+
const fn = this.createRenderFunction(result.code)
|
|
101
|
+
|
|
102
|
+
const entry: CacheEntry = {
|
|
103
|
+
fn,
|
|
104
|
+
layout: result.layout,
|
|
105
|
+
mtime: file.lastModified,
|
|
106
|
+
filePath,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.cache.set(name, entry)
|
|
110
|
+
return entry
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Watch the views directory for `.strav` changes and clear the cache. */
|
|
114
|
+
watch(): void {
|
|
115
|
+
if (this.watcher) return
|
|
116
|
+
|
|
117
|
+
this.watcher = fsWatch(this.directory, { recursive: true }, (_event, filename) => {
|
|
118
|
+
if (!filename || !filename.endsWith('.strav')) return
|
|
119
|
+
this.cache.clear()
|
|
120
|
+
console.log(`[views] ${filename} changed, cache cleared`)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
console.log(`[views] Watching ${this.directory}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Stop watching for template changes. */
|
|
127
|
+
unwatch(): void {
|
|
128
|
+
if (this.watcher) {
|
|
129
|
+
this.watcher.close()
|
|
130
|
+
this.watcher = null
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private resolvePath(name: string): string {
|
|
135
|
+
const relativePath = name.replace(/\./g, '/') + '.strav'
|
|
136
|
+
return join(this.directory, relativePath)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private createRenderFunction(code: string): RenderFunction {
|
|
140
|
+
// Use async Function with `with` statement for scope injection.
|
|
141
|
+
// `new Function()` does not inherit strict mode, so `with` is available.
|
|
142
|
+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
|
|
143
|
+
|
|
144
|
+
const fn = new AsyncFunction('__data', '__escape', '__include', `with (__data) {\n${code}\n}`)
|
|
145
|
+
|
|
146
|
+
return (data: Record<string, unknown>, includeFn: IncludeFn) => {
|
|
147
|
+
return fn(data, escapeHtml, includeFn)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function view(
|
|
153
|
+
name: string,
|
|
154
|
+
data: Record<string, unknown> = {},
|
|
155
|
+
status = 200
|
|
156
|
+
): Promise<Response> {
|
|
157
|
+
const html = await ViewEngine.instance.render(name, data)
|
|
158
|
+
return new Response(html, {
|
|
159
|
+
status,
|
|
160
|
+
headers: { 'Content-Type': 'text/html' },
|
|
161
|
+
})
|
|
162
|
+
}
|
package/src/escape.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const replacements: Record<string, string> = {
|
|
2
|
+
'&': '&',
|
|
3
|
+
'<': '<',
|
|
4
|
+
'>': '>',
|
|
5
|
+
'"': '"',
|
|
6
|
+
"'": ''',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const pattern = /[&<>"']/g
|
|
10
|
+
|
|
11
|
+
export function escapeHtml(value: unknown): string {
|
|
12
|
+
const str = String(value ?? '')
|
|
13
|
+
return str.replace(pattern, ch => replacements[ch]!)
|
|
14
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
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 { AssetVersioner } from './asset_versioner.ts'
|
|
7
|
+
export { IslandBuilder } from './islands/island_builder.ts'
|
|
8
|
+
export { vueSfcPlugin } from './islands/vue_plugin.ts'
|
|
9
|
+
export { defineRoutes } from './route_types.ts'
|
|
10
|
+
export { spaRoutes } from './spa_routes.ts'
|
|
11
|
+
export { default as ViewProvider } from './providers/view_provider.ts'
|
|
12
|
+
|
|
13
|
+
export type { Token, TokenType, VueAttr } from './tokenizer.ts'
|
|
14
|
+
export type { CompilationResult } from './compiler.ts'
|
|
15
|
+
export type { CacheEntry, RenderFunction, IncludeFn, RenderResult } from './cache.ts'
|
|
16
|
+
export type { CssOptions, IslandBuilderOptions, IslandManifest } from './islands/island_builder.ts'
|
|
17
|
+
export type { SpaRouteDefinition } from './route_types.ts'
|