@mkdevofficial/liquid-compiler 0.0.0-stage → 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 +22 -4
- package/src/analyzer.js +93 -0
- package/src/codegen.js +581 -0
- package/src/css.js +151 -0
- package/src/errors.js +52 -0
- package/src/index.js +53 -0
- package/src/parser.js +428 -0
- package/src/sourcemap.js +90 -0
- package/src/tokenizer.js +509 -0
- package/README.md +0 -3
package/src/sourcemap.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure JavaScript Base64 VLQ Source Map Generator (v3)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
6
|
+
|
|
7
|
+
export function encodeVLQ(num) {
|
|
8
|
+
let result = '';
|
|
9
|
+
let vlq = num < 0 ? (-num << 1) | 1 : num << 1;
|
|
10
|
+
do {
|
|
11
|
+
let digit = vlq & 31;
|
|
12
|
+
vlq >>>= 5;
|
|
13
|
+
if (vlq > 0) {
|
|
14
|
+
digit |= 32;
|
|
15
|
+
}
|
|
16
|
+
result += BASE64_CHARS[digit];
|
|
17
|
+
} while (vlq > 0);
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class SourceMapGenerator {
|
|
22
|
+
constructor({ file = 'output.js', sourceRoot = '', sourceFile = 'input.liq' } = {}) {
|
|
23
|
+
this.file = file;
|
|
24
|
+
this.sourceRoot = sourceRoot;
|
|
25
|
+
this.sourceFile = sourceFile;
|
|
26
|
+
this.mappings = []; // list of mappings per generated line
|
|
27
|
+
this.sources = [sourceFile];
|
|
28
|
+
this.names = [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
addMapping({ generatedLine, generatedColumn, originalLine, originalColumn }) {
|
|
32
|
+
this.mappings.push({
|
|
33
|
+
genLine: generatedLine,
|
|
34
|
+
genCol: generatedColumn,
|
|
35
|
+
origLine: originalLine,
|
|
36
|
+
origCol: originalColumn
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
generate() {
|
|
41
|
+
// Sort mappings by generated line and column
|
|
42
|
+
this.mappings.sort((a, b) => a.genLine - b.genLine || a.genCol - b.genCol);
|
|
43
|
+
|
|
44
|
+
let prevGenLine = 1;
|
|
45
|
+
let prevGenCol = 0;
|
|
46
|
+
let prevOrigLine = 0;
|
|
47
|
+
let prevOrigCol = 0;
|
|
48
|
+
let prevSource = 0;
|
|
49
|
+
|
|
50
|
+
const lines = [];
|
|
51
|
+
let currentLineMappings = [];
|
|
52
|
+
|
|
53
|
+
for (const m of this.mappings) {
|
|
54
|
+
while (m.genLine > prevGenLine) {
|
|
55
|
+
lines.push(currentLineMappings.join(','));
|
|
56
|
+
currentLineMappings = [];
|
|
57
|
+
prevGenLine++;
|
|
58
|
+
prevGenCol = 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Encode segment
|
|
62
|
+
const genColDiff = m.genCol - prevGenCol;
|
|
63
|
+
const sourceDiff = 0 - prevSource;
|
|
64
|
+
const origLineDiff = (m.origLine - 1) - prevOrigLine;
|
|
65
|
+
const origColDiff = (m.origCol - 1) - prevOrigCol;
|
|
66
|
+
|
|
67
|
+
const segment =
|
|
68
|
+
encodeVLQ(genColDiff) +
|
|
69
|
+
encodeVLQ(sourceDiff) +
|
|
70
|
+
encodeVLQ(origLineDiff) +
|
|
71
|
+
encodeVLQ(origColDiff);
|
|
72
|
+
|
|
73
|
+
currentLineMappings.push(segment);
|
|
74
|
+
|
|
75
|
+
prevGenCol = m.genCol;
|
|
76
|
+
prevOrigLine = m.origLine - 1;
|
|
77
|
+
prevOrigCol = m.origCol - 1;
|
|
78
|
+
prevSource = 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
lines.push(currentLineMappings.join(','));
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
version: 3,
|
|
85
|
+
file: this.file,
|
|
86
|
+
sources: this.sources,
|
|
87
|
+
mappings: lines.join(';')
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/tokenizer.js
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { LiquidSyntaxError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export const TokenType = {
|
|
4
|
+
SECTION: 'SECTION',
|
|
5
|
+
INDENT: 'INDENT',
|
|
6
|
+
DEDENT: 'DEDENT',
|
|
7
|
+
TAG: 'TAG',
|
|
8
|
+
KEYWORD: 'KEYWORD',
|
|
9
|
+
DIRECTIVE_EVENT: 'DIRECTIVE_EVENT',
|
|
10
|
+
DIRECTIVE_BIND: 'DIRECTIVE_BIND',
|
|
11
|
+
ATTR: 'ATTR',
|
|
12
|
+
TEXT: 'TEXT',
|
|
13
|
+
INTERPOLATION: 'INTERPOLATION',
|
|
14
|
+
NEWLINE: 'NEWLINE',
|
|
15
|
+
EOF: 'EOF'
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const KEYWORDS = new Set(['if', 'elseif', 'else if', 'else', 'each', 'slot']);
|
|
19
|
+
|
|
20
|
+
const COMMON_BOOLEAN_ATTRS = new Set([
|
|
21
|
+
'disabled', 'checked', 'required', 'readonly', 'hidden', 'autofocus',
|
|
22
|
+
'selected', 'multiple', 'novalidate', 'async', 'defer', 'open', 'reversed',
|
|
23
|
+
'loop', 'autoplay', 'controls', 'muted', 'default', 'inert'
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Tokenize a .liq source string into an array of tokens.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} source
|
|
30
|
+
* @param {object} options
|
|
31
|
+
* @returns {Array<object>}
|
|
32
|
+
*/
|
|
33
|
+
export function tokenize(source, options = {}) {
|
|
34
|
+
const file = options.file || 'component.liq';
|
|
35
|
+
const tokens = [];
|
|
36
|
+
|
|
37
|
+
const rawLines = source.split(/\r?\n/);
|
|
38
|
+
const indentStack = [0];
|
|
39
|
+
let currentSection = null; // null means top-level, defaults to UI unless section header seen
|
|
40
|
+
let sectionContentLines = [];
|
|
41
|
+
let sectionStartLoc = { line: 1, column: 1 };
|
|
42
|
+
|
|
43
|
+
function flushSection() {
|
|
44
|
+
if (currentSection && (currentSection === 'logic' || currentSection === 'style')) {
|
|
45
|
+
tokens.push({
|
|
46
|
+
type: TokenType.SECTION,
|
|
47
|
+
name: currentSection,
|
|
48
|
+
content: sectionContentLines.join('\n'),
|
|
49
|
+
line: sectionStartLoc.line,
|
|
50
|
+
column: sectionStartLoc.column
|
|
51
|
+
});
|
|
52
|
+
sectionContentLines = [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (let lineIndex = 0; lineIndex < rawLines.length; lineIndex++) {
|
|
57
|
+
const lineNumber = lineIndex + 1;
|
|
58
|
+
const rawLine = rawLines[lineIndex];
|
|
59
|
+
|
|
60
|
+
// Check for section header: e.g. "--- logic", "--- js", "--- style", "--- css", "--- ui"
|
|
61
|
+
const sectionMatch = rawLine.match(/^---\s*([a-zA-Z0-9_-]*)\s*$/);
|
|
62
|
+
if (sectionMatch) {
|
|
63
|
+
flushSection();
|
|
64
|
+
|
|
65
|
+
// If switching out of UI section, close any open UI indents
|
|
66
|
+
if (currentSection === 'ui' || currentSection === null) {
|
|
67
|
+
while (indentStack.length > 1) {
|
|
68
|
+
indentStack.pop();
|
|
69
|
+
tokens.push({ type: TokenType.DEDENT, line: lineNumber, column: 1 });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const rawName = sectionMatch[1].toLowerCase();
|
|
74
|
+
let sectionName = 'ui';
|
|
75
|
+
if (rawName === 'logic' || rawName === 'js' || rawName === 'script') {
|
|
76
|
+
sectionName = 'logic';
|
|
77
|
+
} else if (rawName === 'style' || rawName === 'styles' || rawName === 'css') {
|
|
78
|
+
sectionName = 'style';
|
|
79
|
+
} else if (rawName === 'ui' || rawName === 'template' || rawName === '') {
|
|
80
|
+
sectionName = 'ui';
|
|
81
|
+
} else {
|
|
82
|
+
throw new LiquidSyntaxError(
|
|
83
|
+
`Unknown section header '--- ${rawName}'. Valid sections are '--- logic', '--- style', or '--- ui'.`,
|
|
84
|
+
{ file, line: lineNumber, column: 1, source }
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
currentSection = sectionName;
|
|
89
|
+
sectionStartLoc = { line: lineNumber + 1, column: 1 };
|
|
90
|
+
sectionContentLines = [];
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// If inside logic or style section, collect lines verbatim
|
|
95
|
+
if (currentSection === 'logic' || currentSection === 'style') {
|
|
96
|
+
sectionContentLines.push(rawLine);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Now we are in UI section (either explicit "--- ui" or default top-level UI)
|
|
101
|
+
currentSection = 'ui';
|
|
102
|
+
|
|
103
|
+
// Blank line or comment-only line?
|
|
104
|
+
const trimmed = rawLine.trim();
|
|
105
|
+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*')) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check indentation of the current line
|
|
110
|
+
const leadingWhitespaceMatch = rawLine.match(/^[ \t]*/);
|
|
111
|
+
const leadingWhitespace = leadingWhitespaceMatch ? leadingWhitespaceMatch[0] : '';
|
|
112
|
+
const hasTabs = leadingWhitespace.includes('\t');
|
|
113
|
+
const hasSpaces = leadingWhitespace.includes(' ');
|
|
114
|
+
|
|
115
|
+
if (hasTabs && hasSpaces) {
|
|
116
|
+
const firstTab = leadingWhitespace.indexOf('\t') + 1;
|
|
117
|
+
throw new LiquidSyntaxError(
|
|
118
|
+
'Mixed tabs and spaces in indentation are not allowed. Please use spaces only (2 spaces per indent level).',
|
|
119
|
+
{ file, line: lineNumber, column: firstTab, source }
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (hasTabs) {
|
|
124
|
+
throw new LiquidSyntaxError(
|
|
125
|
+
'Tab characters in indentation are not allowed. Please use 2 spaces per indentation level.',
|
|
126
|
+
{ file, line: lineNumber, column: 1, source }
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const indentWidth = leadingWhitespace.length;
|
|
131
|
+
const currentIndent = indentStack[indentStack.length - 1];
|
|
132
|
+
|
|
133
|
+
if (indentWidth > currentIndent) {
|
|
134
|
+
if (indentWidth !== currentIndent + 2) {
|
|
135
|
+
throw new LiquidSyntaxError(
|
|
136
|
+
`Invalid indentation (${indentWidth} spaces). Expected ${currentIndent + 2} spaces (2 spaces per level).`,
|
|
137
|
+
{ file, line: lineNumber, column: currentIndent + 1, source }
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
indentStack.push(indentWidth);
|
|
141
|
+
tokens.push({
|
|
142
|
+
type: TokenType.INDENT,
|
|
143
|
+
level: indentWidth,
|
|
144
|
+
line: lineNumber,
|
|
145
|
+
column: 1
|
|
146
|
+
});
|
|
147
|
+
} else if (indentWidth < currentIndent) {
|
|
148
|
+
while (indentStack.length > 1 && indentStack[indentStack.length - 1] > indentWidth) {
|
|
149
|
+
indentStack.pop();
|
|
150
|
+
tokens.push({
|
|
151
|
+
type: TokenType.DEDENT,
|
|
152
|
+
level: indentStack[indentStack.length - 1],
|
|
153
|
+
line: lineNumber,
|
|
154
|
+
column: 1
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (indentStack[indentStack.length - 1] !== indentWidth) {
|
|
158
|
+
throw new LiquidSyntaxError(
|
|
159
|
+
`Indentation level of ${indentWidth} spaces does not match any outer indentation level.`,
|
|
160
|
+
{ file, line: lineNumber, column: 1, source }
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Tokenize the line content starting at column `indentWidth + 1`
|
|
166
|
+
const lineContent = rawLine.slice(indentWidth);
|
|
167
|
+
const colOffset = indentWidth + 1;
|
|
168
|
+
tokenizeUILine(lineContent, lineNumber, colOffset, tokens, file, source);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Flush any trailing section
|
|
172
|
+
flushSection();
|
|
173
|
+
|
|
174
|
+
// If in UI, emit dedents for remaining open indents
|
|
175
|
+
while (indentStack.length > 1) {
|
|
176
|
+
indentStack.pop();
|
|
177
|
+
tokens.push({
|
|
178
|
+
type: TokenType.DEDENT,
|
|
179
|
+
level: indentStack[indentStack.length - 1],
|
|
180
|
+
line: rawLines.length,
|
|
181
|
+
column: 1
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
tokens.push({
|
|
186
|
+
type: TokenType.EOF,
|
|
187
|
+
line: rawLines.length,
|
|
188
|
+
column: 1
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return tokens;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Tokenize a single non-empty UI line.
|
|
196
|
+
*/
|
|
197
|
+
function tokenizeUILine(lineContent, line, colOffset, tokens, file, source) {
|
|
198
|
+
// 1. Explicit text line: | text
|
|
199
|
+
if (lineContent.startsWith('| ') || lineContent === '|') {
|
|
200
|
+
const text = lineContent.startsWith('| ') ? lineContent.slice(2) : '';
|
|
201
|
+
tokens.push({
|
|
202
|
+
type: TokenType.TEXT,
|
|
203
|
+
value: text,
|
|
204
|
+
isExplicitText: true,
|
|
205
|
+
line,
|
|
206
|
+
column: colOffset
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Check if line starts with a keyword: if, elseif, else if, else, each, slot
|
|
212
|
+
for (const kw of ['else if', 'elseif', 'else', 'if', 'each', 'slot']) {
|
|
213
|
+
if (lineContent === kw || lineContent.startsWith(kw + ' ') || lineContent.startsWith(kw + '(')) {
|
|
214
|
+
const rest = lineContent.slice(kw.length).trim();
|
|
215
|
+
tokens.push({
|
|
216
|
+
type: TokenType.KEYWORD,
|
|
217
|
+
keyword: kw === 'else if' ? 'elseif' : kw,
|
|
218
|
+
args: rest,
|
|
219
|
+
line,
|
|
220
|
+
column: colOffset
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Check for HTML comments
|
|
227
|
+
if (lineContent.startsWith('<!--')) {
|
|
228
|
+
return; // ignore comments
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Otherwise, it's an element, component, or child text line
|
|
232
|
+
// Let's scan the first word (tag + shorthands)
|
|
233
|
+
let pos = 0;
|
|
234
|
+
const len = lineContent.length;
|
|
235
|
+
|
|
236
|
+
function skipSpaces() {
|
|
237
|
+
while (pos < len && (lineContent[pos] === ' ' || lineContent[pos] === '\t')) {
|
|
238
|
+
pos++;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
skipSpaces();
|
|
243
|
+
const tagStart = pos;
|
|
244
|
+
|
|
245
|
+
// Read tag name (supports letters, numbers, hyphens, and unicode characters for RTL/Arabic tags)
|
|
246
|
+
while (pos < len && !/[\s.#$@(=]/.test(lineContent[pos])) {
|
|
247
|
+
pos++;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const rawTagName = lineContent.slice(tagStart, pos);
|
|
251
|
+
|
|
252
|
+
// If no tag name and line doesn't start with '#' or '.', it's a plain text child line!
|
|
253
|
+
if (!rawTagName && lineContent[pos] !== '#' && lineContent[pos] !== '.') {
|
|
254
|
+
tokens.push({
|
|
255
|
+
type: TokenType.TEXT,
|
|
256
|
+
value: lineContent,
|
|
257
|
+
isExplicitText: false,
|
|
258
|
+
line,
|
|
259
|
+
column: colOffset
|
|
260
|
+
});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const tagName = rawTagName || 'div'; // #id or .class defaults to div
|
|
265
|
+
let id = null;
|
|
266
|
+
const classes = [];
|
|
267
|
+
|
|
268
|
+
// Parse shorthand #id and .class
|
|
269
|
+
while (pos < len && (lineContent[pos] === '#' || lineContent[pos] === '.')) {
|
|
270
|
+
const prefix = lineContent[pos];
|
|
271
|
+
pos++;
|
|
272
|
+
const segStart = pos;
|
|
273
|
+
while (pos < len && !/[\s.#$@(=]/.test(lineContent[pos])) {
|
|
274
|
+
pos++;
|
|
275
|
+
}
|
|
276
|
+
const seg = lineContent.slice(segStart, pos);
|
|
277
|
+
if (!seg) {
|
|
278
|
+
throw new LiquidSyntaxError(
|
|
279
|
+
`Expected identifier after '${prefix}' in shorthand.`,
|
|
280
|
+
{ file, line, column: colOffset + segStart, source }
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
if (prefix === '#') {
|
|
284
|
+
id = seg;
|
|
285
|
+
} else {
|
|
286
|
+
classes.push(seg);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
tokens.push({
|
|
291
|
+
type: TokenType.TAG,
|
|
292
|
+
name: tagName,
|
|
293
|
+
id,
|
|
294
|
+
classes,
|
|
295
|
+
isComponent: /^[A-Z]/.test(tagName),
|
|
296
|
+
line,
|
|
297
|
+
column: colOffset + tagStart
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
skipSpaces();
|
|
301
|
+
|
|
302
|
+
// Now parse attributes, directives, and optional trailing inline text
|
|
303
|
+
while (pos < len) {
|
|
304
|
+
skipSpaces();
|
|
305
|
+
if (pos >= len) break;
|
|
306
|
+
|
|
307
|
+
const char = lineContent[pos];
|
|
308
|
+
|
|
309
|
+
// Event directive: @event or @event.modifier=handler
|
|
310
|
+
if (char === '@') {
|
|
311
|
+
pos++;
|
|
312
|
+
const evStart = pos;
|
|
313
|
+
while (pos < len && !/[\s=]/.test(lineContent[pos])) {
|
|
314
|
+
pos++;
|
|
315
|
+
}
|
|
316
|
+
const rawEvent = lineContent.slice(evStart, pos);
|
|
317
|
+
skipSpaces();
|
|
318
|
+
let handler = '';
|
|
319
|
+
if (pos < len && lineContent[pos] === '=') {
|
|
320
|
+
pos++;
|
|
321
|
+
skipSpaces();
|
|
322
|
+
const hObj = readAttrValue();
|
|
323
|
+
handler = hObj.value;
|
|
324
|
+
}
|
|
325
|
+
const [name, ...modifiers] = rawEvent.split('.');
|
|
326
|
+
tokens.push({
|
|
327
|
+
type: TokenType.DIRECTIVE_EVENT,
|
|
328
|
+
name,
|
|
329
|
+
modifiers,
|
|
330
|
+
handler,
|
|
331
|
+
line,
|
|
332
|
+
column: colOffset + evStart - 1
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Two-way binding: $prop={sig} or $={sig}
|
|
338
|
+
if (char === '$') {
|
|
339
|
+
pos++;
|
|
340
|
+
const bindStart = pos;
|
|
341
|
+
while (pos < len && !/[\s=]/.test(lineContent[pos])) {
|
|
342
|
+
pos++;
|
|
343
|
+
}
|
|
344
|
+
const prop = lineContent.slice(bindStart, pos) || 'value';
|
|
345
|
+
skipSpaces();
|
|
346
|
+
let target = '';
|
|
347
|
+
if (pos < len && lineContent[pos] === '=') {
|
|
348
|
+
pos++;
|
|
349
|
+
skipSpaces();
|
|
350
|
+
const tObj = readAttrValue();
|
|
351
|
+
target = tObj.value;
|
|
352
|
+
}
|
|
353
|
+
tokens.push({
|
|
354
|
+
type: TokenType.DIRECTIVE_BIND,
|
|
355
|
+
prop,
|
|
356
|
+
target,
|
|
357
|
+
line,
|
|
358
|
+
column: colOffset + bindStart - 1
|
|
359
|
+
});
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Check if this looks like an attribute: name="value", name={expr}, or boolean attr
|
|
364
|
+
// An attribute name matches [a-zA-Z0-9_-:]
|
|
365
|
+
const attrMatch = lineContent.slice(pos).match(/^([a-zA-Z0-9_\-:]+)(?:=|\s|$)/);
|
|
366
|
+
if (attrMatch) {
|
|
367
|
+
const attrName = attrMatch[1];
|
|
368
|
+
const afterNamePos = pos + attrName.length;
|
|
369
|
+
let nextChar = lineContent[afterNamePos];
|
|
370
|
+
|
|
371
|
+
// If followed immediately or after spaces by '='
|
|
372
|
+
let lookAheadPos = afterNamePos;
|
|
373
|
+
while (lookAheadPos < len && (lineContent[lookAheadPos] === ' ' || lineContent[lookAheadPos] === '\t')) {
|
|
374
|
+
lookAheadPos++;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (lineContent[lookAheadPos] === '=') {
|
|
378
|
+
// Definitely an attribute with value
|
|
379
|
+
pos = lookAheadPos + 1;
|
|
380
|
+
skipSpaces();
|
|
381
|
+
const attrVal = readAttrValue();
|
|
382
|
+
tokens.push({
|
|
383
|
+
type: TokenType.ATTR,
|
|
384
|
+
name: attrName,
|
|
385
|
+
value: attrVal.value,
|
|
386
|
+
isDynamic: attrVal.isDynamic,
|
|
387
|
+
line,
|
|
388
|
+
column: colOffset + (pos - attrName.length)
|
|
389
|
+
});
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Check if it's a known boolean attribute
|
|
394
|
+
if (COMMON_BOOLEAN_ATTRS.has(attrName.toLowerCase())) {
|
|
395
|
+
pos = afterNamePos;
|
|
396
|
+
tokens.push({
|
|
397
|
+
type: TokenType.ATTR,
|
|
398
|
+
name: attrName,
|
|
399
|
+
value: 'true',
|
|
400
|
+
isDynamic: false,
|
|
401
|
+
isBoolean: true,
|
|
402
|
+
line,
|
|
403
|
+
column: colOffset + pos
|
|
404
|
+
});
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// If it does not match an attribute or directive, the rest of the line is INLINE TEXT!
|
|
410
|
+
const inlineText = lineContent.slice(pos);
|
|
411
|
+
tokens.push({
|
|
412
|
+
type: TokenType.TEXT,
|
|
413
|
+
value: inlineText,
|
|
414
|
+
isInline: true,
|
|
415
|
+
line,
|
|
416
|
+
column: colOffset + pos
|
|
417
|
+
});
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function readAttrValue() {
|
|
422
|
+
skipSpaces();
|
|
423
|
+
if (pos >= len) return { value: '', isDynamic: false };
|
|
424
|
+
|
|
425
|
+
const firstChar = lineContent[pos];
|
|
426
|
+
|
|
427
|
+
// Expression: {expr}
|
|
428
|
+
if (firstChar === '{') {
|
|
429
|
+
let depth = 1;
|
|
430
|
+
pos++;
|
|
431
|
+
const exprStart = pos;
|
|
432
|
+
while (pos < len && depth > 0) {
|
|
433
|
+
if (lineContent[pos] === '{') depth++;
|
|
434
|
+
else if (lineContent[pos] === '}') depth--;
|
|
435
|
+
if (depth > 0) pos++;
|
|
436
|
+
}
|
|
437
|
+
const expr = lineContent.slice(exprStart, pos);
|
|
438
|
+
if (lineContent[pos] === '}') pos++;
|
|
439
|
+
return { value: expr.trim(), isDynamic: true };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Quoted string: "..." or '...'
|
|
443
|
+
if (firstChar === '"' || firstChar === "'") {
|
|
444
|
+
const quote = firstChar;
|
|
445
|
+
pos++;
|
|
446
|
+
const strStart = pos;
|
|
447
|
+
let strVal = '';
|
|
448
|
+
let escaped = false;
|
|
449
|
+
while (pos < len) {
|
|
450
|
+
const c = lineContent[pos];
|
|
451
|
+
if (escaped) {
|
|
452
|
+
strVal += c;
|
|
453
|
+
escaped = false;
|
|
454
|
+
} else if (c === '\\') {
|
|
455
|
+
escaped = true;
|
|
456
|
+
} else if (c === quote) {
|
|
457
|
+
pos++;
|
|
458
|
+
break;
|
|
459
|
+
} else {
|
|
460
|
+
strVal += c;
|
|
461
|
+
}
|
|
462
|
+
pos++;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Check for interpolation inside quotes: e.g. "{expr}" or "text {expr}"
|
|
466
|
+
if (strVal.includes('{')) {
|
|
467
|
+
if (strVal.startsWith('{') && strVal.endsWith('}') && strVal.indexOf('{', 1) === -1) {
|
|
468
|
+
return { value: strVal.slice(1, -1).trim(), isDynamic: true };
|
|
469
|
+
}
|
|
470
|
+
const parts = [];
|
|
471
|
+
let cur = 0;
|
|
472
|
+
let inExpr = false;
|
|
473
|
+
let buf = '';
|
|
474
|
+
while (cur < strVal.length) {
|
|
475
|
+
if (!inExpr && strVal[cur] === '{') {
|
|
476
|
+
if (buf) { parts.push(JSON.stringify(buf)); buf = ''; }
|
|
477
|
+
inExpr = true;
|
|
478
|
+
cur++;
|
|
479
|
+
} else if (inExpr && strVal[cur] === '}') {
|
|
480
|
+
if (buf) { parts.push(`(${buf.trim()})`); buf = ''; }
|
|
481
|
+
inExpr = false;
|
|
482
|
+
cur++;
|
|
483
|
+
} else {
|
|
484
|
+
buf += strVal[cur++];
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (buf) parts.push(JSON.stringify(buf));
|
|
488
|
+
return { value: parts.join(' + '), isDynamic: true };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return { value: strVal, isDynamic: false };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Unquoted expression or identifier, tracking balanced parentheses/brackets
|
|
495
|
+
const valStart = pos;
|
|
496
|
+
let parenDepth = 0;
|
|
497
|
+
while (pos < len) {
|
|
498
|
+
const c = lineContent[pos];
|
|
499
|
+
if (parenDepth === 0 && (c === ' ' || c === '\t')) {
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
if (c === '(' || c === '[' || c === '{') parenDepth++;
|
|
503
|
+
else if (c === ')' || c === ']' || c === '}') parenDepth--;
|
|
504
|
+
pos++;
|
|
505
|
+
}
|
|
506
|
+
const unquoted = lineContent.slice(valStart, pos);
|
|
507
|
+
return { value: unquoted, isDynamic: false };
|
|
508
|
+
}
|
|
509
|
+
}
|
package/README.md
DELETED