@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.
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"],
4
+ "exclude": ["node_modules", "tests"]
5
+ }