@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/tokenizer.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { TemplateError } from '@stravigor/kernel/exceptions/errors'
|
|
2
|
+
export type TokenType = 'text' | 'escaped' | 'raw' | 'comment' | 'directive' | 'vue_island'
|
|
3
|
+
|
|
4
|
+
export interface VueAttr {
|
|
5
|
+
value: string
|
|
6
|
+
bound: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Token {
|
|
10
|
+
type: TokenType
|
|
11
|
+
value: string
|
|
12
|
+
directive?: string
|
|
13
|
+
args?: string
|
|
14
|
+
tag?: string
|
|
15
|
+
attrs?: Record<string, VueAttr>
|
|
16
|
+
line: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DIRECTIVES = new Set([
|
|
20
|
+
'if',
|
|
21
|
+
'elseif',
|
|
22
|
+
'else',
|
|
23
|
+
'end',
|
|
24
|
+
'each',
|
|
25
|
+
'layout',
|
|
26
|
+
'section',
|
|
27
|
+
'show',
|
|
28
|
+
'include',
|
|
29
|
+
'islands',
|
|
30
|
+
'csrf',
|
|
31
|
+
'class',
|
|
32
|
+
'style',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
export function tokenize(source: string): Token[] {
|
|
36
|
+
const tokens: Token[] = []
|
|
37
|
+
let pos = 0
|
|
38
|
+
let line = 1
|
|
39
|
+
let textStart = 0
|
|
40
|
+
|
|
41
|
+
function countLines(str: string): number {
|
|
42
|
+
let count = 0
|
|
43
|
+
for (let i = 0; i < str.length; i++) {
|
|
44
|
+
if (str[i] === '\n') count++
|
|
45
|
+
}
|
|
46
|
+
return count
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function flushText(): void {
|
|
50
|
+
if (pos > textStart) {
|
|
51
|
+
const value = source.slice(textStart, pos)
|
|
52
|
+
if (value.length > 0) {
|
|
53
|
+
tokens.push({ type: 'text', value, line: line - countLines(value) })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function remaining(): string {
|
|
59
|
+
return source.slice(pos)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
while (pos < source.length) {
|
|
63
|
+
const rest = remaining()
|
|
64
|
+
|
|
65
|
+
// 1. Comments: {{-- ... --}}
|
|
66
|
+
if (rest.startsWith('{{--')) {
|
|
67
|
+
flushText()
|
|
68
|
+
const endIdx = source.indexOf('--}}', pos + 4)
|
|
69
|
+
if (endIdx === -1) {
|
|
70
|
+
throw new TemplateError(`Unclosed comment at line ${line}`)
|
|
71
|
+
}
|
|
72
|
+
const content = source.slice(pos + 4, endIdx)
|
|
73
|
+
tokens.push({ type: 'comment', value: content.trim(), line })
|
|
74
|
+
line += countLines(source.slice(pos, endIdx + 4))
|
|
75
|
+
pos = endIdx + 4
|
|
76
|
+
textStart = pos
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Raw output: {!! ... !!}
|
|
81
|
+
if (rest.startsWith('{!!')) {
|
|
82
|
+
flushText()
|
|
83
|
+
const endIdx = source.indexOf('!!}', pos + 3)
|
|
84
|
+
if (endIdx === -1) {
|
|
85
|
+
throw new TemplateError(`Unclosed raw expression at line ${line}`)
|
|
86
|
+
}
|
|
87
|
+
const expr = source.slice(pos + 3, endIdx).trim()
|
|
88
|
+
tokens.push({ type: 'raw', value: expr, line })
|
|
89
|
+
line += countLines(source.slice(pos, endIdx + 3))
|
|
90
|
+
pos = endIdx + 3
|
|
91
|
+
textStart = pos
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. Escaped output: {{ ... }}
|
|
96
|
+
if (rest.startsWith('{{')) {
|
|
97
|
+
flushText()
|
|
98
|
+
const endIdx = source.indexOf('}}', pos + 2)
|
|
99
|
+
if (endIdx === -1) {
|
|
100
|
+
throw new TemplateError(`Unclosed expression at line ${line}`)
|
|
101
|
+
}
|
|
102
|
+
const expr = source.slice(pos + 2, endIdx).trim()
|
|
103
|
+
tokens.push({ type: 'escaped', value: expr, line })
|
|
104
|
+
line += countLines(source.slice(pos, endIdx + 2))
|
|
105
|
+
pos = endIdx + 2
|
|
106
|
+
textStart = pos
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 4. Vue islands: <vue:name ... /> (supports subpaths like <vue:forms/contact-form />)
|
|
111
|
+
const vueMatch = rest.match(/^<vue:([\w/-]+)((?:\s+[\s\S]*?)?)\/>/)
|
|
112
|
+
if (vueMatch) {
|
|
113
|
+
flushText()
|
|
114
|
+
const tag = vueMatch[1]!
|
|
115
|
+
const attrsRaw = vueMatch[2]!.trim()
|
|
116
|
+
const attrs = parseVueAttrs(attrsRaw)
|
|
117
|
+
const full = vueMatch[0]
|
|
118
|
+
tokens.push({ type: 'vue_island', value: full, tag, attrs, line })
|
|
119
|
+
line += countLines(full)
|
|
120
|
+
pos += full.length
|
|
121
|
+
textStart = pos
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 5. Directives: @keyword or @keyword(...)
|
|
126
|
+
const dirMatch = rest.match(/^@(\w+)/)
|
|
127
|
+
if (dirMatch && DIRECTIVES.has(dirMatch[1]!)) {
|
|
128
|
+
flushText()
|
|
129
|
+
const directive = dirMatch[1]!
|
|
130
|
+
pos += dirMatch[0].length
|
|
131
|
+
let args: string | undefined
|
|
132
|
+
|
|
133
|
+
// Parse arguments in parentheses (if present)
|
|
134
|
+
if (pos < source.length && source[pos] === '(') {
|
|
135
|
+
const argsStart = pos
|
|
136
|
+
let depth = 1
|
|
137
|
+
pos++ // skip opening (
|
|
138
|
+
while (pos < source.length && depth > 0) {
|
|
139
|
+
if (source[pos] === '(') depth++
|
|
140
|
+
else if (source[pos] === ')') depth--
|
|
141
|
+
if (depth > 0) pos++
|
|
142
|
+
}
|
|
143
|
+
if (depth !== 0) {
|
|
144
|
+
throw new TemplateError(`Unclosed directive arguments at line ${line}`)
|
|
145
|
+
}
|
|
146
|
+
args = source.slice(argsStart + 1, pos)
|
|
147
|
+
pos++ // skip closing )
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
tokens.push({ type: 'directive', value: directive, directive, args, line })
|
|
151
|
+
textStart = pos
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 6. Regular text
|
|
156
|
+
if (source[pos] === '\n') line++
|
|
157
|
+
pos++
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
flushText()
|
|
161
|
+
return tokens
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseVueAttrs(raw: string): Record<string, VueAttr> {
|
|
165
|
+
const attrs: Record<string, VueAttr> = {}
|
|
166
|
+
const attrPattern = /([:@]?[\w.-]+)\s*=\s*"([^"]*)"/g
|
|
167
|
+
let match: RegExpExecArray | null
|
|
168
|
+
|
|
169
|
+
while ((match = attrPattern.exec(raw)) !== null) {
|
|
170
|
+
const name = match[1]!
|
|
171
|
+
const value = match[2]!
|
|
172
|
+
|
|
173
|
+
if (name.startsWith(':')) {
|
|
174
|
+
// Bound attribute — extract expression from {{ }} if present
|
|
175
|
+
const exprMatch = value.match(/^\{\{\s*(.*?)\s*\}\}$/)
|
|
176
|
+
attrs[name.slice(1)] = {
|
|
177
|
+
value: exprMatch ? exprMatch[1]! : value,
|
|
178
|
+
bound: true,
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
attrs[name] = { value, bound: false }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return attrs
|
|
186
|
+
}
|