lui-templates 0.0.6
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/.vscode/settings.json +10 -0
- package/README.md +103 -0
- package/package.json +32 -0
- package/schema/intermediary.json +147 -0
- package/src/cli.js +59 -0
- package/src/constants.js +8 -0
- package/src/generator.js +276 -0
- package/src/main.js +74 -0
- package/src/parser.js +57 -0
- package/src/parsers/json.js +9 -0
- package/src/parsers/liquid.js +1194 -0
- package/test/basic.js +9 -0
- package/test/templates/article.liquid +9 -0
- package/test/templates/button.liquid +3 -0
- package/test/templates/complex-nested.liquid +11 -0
- package/test/templates/complex.liquid +4 -0
- package/test/templates/conditional-bool-attr.liquid +4 -0
- package/test/templates/conditional-nonbool-attr.liquid +4 -0
- package/test/templates/conditional-unless-attr.liquid +4 -0
- package/test/templates/conditional-unless-nonbool-attr.liquid +4 -0
- package/test/templates/conditional.liquid +6 -0
- package/test/templates/dynamic-class.liquid +1 -0
- package/test/templates/greeting.json +74 -0
- package/test/templates/image.liquid +5 -0
- package/test/templates/link.json +31 -0
- package/test/templates/mixed.liquid +5 -0
- package/test/templates/nested-attr-conditionals.liquid +1 -0
- package/test/templates/nested-comprehensive.liquid +51 -0
- package/test/templates/nested-conditionals.liquid +9 -0
- package/test/templates/nested-if-flatten.liquid +6 -0
- package/test/templates/root-text.liquid +2 -0
- package/test/templates/test-attr-cond.liquid +1 -0
- package/test/templates/text-nodes.liquid +5 -0
- package/test/templates/unless.liquid +5 -0
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NODE_TYPE_ELEMENT,
|
|
3
|
+
NODE_TYPE_IF,
|
|
4
|
+
VALUE_TYPE_FIELD,
|
|
5
|
+
VALUE_TYPE_STATIC,
|
|
6
|
+
VALUE_TYPE_STRING_CONCAT,
|
|
7
|
+
} from '../constants.js';
|
|
8
|
+
import {
|
|
9
|
+
html_attr_to_dom,
|
|
10
|
+
html_is_self_closing,
|
|
11
|
+
html_is_whitespace,
|
|
12
|
+
} from '../parser.js';
|
|
13
|
+
|
|
14
|
+
const TOKEN_HTML_START = 0; // html start tag
|
|
15
|
+
const TOKEN_HTML_END = 1; // html end tag
|
|
16
|
+
const TOKEN_TEXT = 2; // static text
|
|
17
|
+
const TOKEN_ATTRIBUTE = 3; // html attribute
|
|
18
|
+
const TOKEN_EXPRESSION = 4; // (inline transformed) variable
|
|
19
|
+
const TOKEN_LIQUID = 5; // generic liquid tag with one of the following commands
|
|
20
|
+
|
|
21
|
+
const COMMAND_NOP = 0;
|
|
22
|
+
const COMMAND_IF = 1;
|
|
23
|
+
const COMMAND_UNLESS = 2;
|
|
24
|
+
const COMMAND_FOR = 3;
|
|
25
|
+
const COMMAND_ENDIF = 4;
|
|
26
|
+
const COMMAND_ENDUNLESS = 5;
|
|
27
|
+
const COMMAND_ENDFOR = 6;
|
|
28
|
+
|
|
29
|
+
const command_map = new Map([
|
|
30
|
+
['if', COMMAND_IF],
|
|
31
|
+
['unless', COMMAND_UNLESS],
|
|
32
|
+
['for', COMMAND_FOR],
|
|
33
|
+
['endif', COMMAND_ENDIF],
|
|
34
|
+
['endunless', COMMAND_ENDUNLESS],
|
|
35
|
+
['endfor', COMMAND_ENDFOR],
|
|
36
|
+
]);
|
|
37
|
+
const command_map_reverse = new Map(
|
|
38
|
+
Array.from(command_map.entries())
|
|
39
|
+
.map(([key, value]) => [value, key])
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export default async function parse_liquid(src, path) {
|
|
43
|
+
const tokenizer = new Tokenizer(src, path);
|
|
44
|
+
const tokens = tokenizer.parse_nodes();
|
|
45
|
+
const nodes = build_nodes(tokens, 0, tokens.length);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
inputs: (
|
|
49
|
+
Array.from(tokenizer.variables)
|
|
50
|
+
// variables that are not assigned anywhere
|
|
51
|
+
.filter(([, value]) => value === null)
|
|
52
|
+
.map(([name]) => ({ name }))
|
|
53
|
+
),
|
|
54
|
+
|
|
55
|
+
// what variables are derived from other variables via transformations?
|
|
56
|
+
transformations: [],// TODO
|
|
57
|
+
// not used for now
|
|
58
|
+
effects: [],
|
|
59
|
+
nodes,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
To parse liquid, we first need to "tokenize" the input string into meaningful chunks.
|
|
65
|
+
Reason for this is that we can have computed values, if and unless at almost any place.
|
|
66
|
+
Our internal tree should consist of html start tags, html end tags, static text, html attributes, (inline transformed) variables, if nodes, loop nodes, and other liquid nodes.
|
|
67
|
+
Then, we can traverse this tree to optimize it and generate the final output.
|
|
68
|
+
Certain html elements are forbidden: script, style, link, noscript, etc.
|
|
69
|
+
For clarity, no RegExp are used in this file and stuff related to html spec belongs to parser.js.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
Handles errors during of after tokenization.
|
|
74
|
+
@param {string} message
|
|
75
|
+
@param {Object} obj either the Tokenizer or a specific token
|
|
76
|
+
*/
|
|
77
|
+
function error(message, obj) {
|
|
78
|
+
console.error(`Error in ${obj.path}:${obj.line}:${obj.column}: ${message}`);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
class Tokenizer {
|
|
83
|
+
constructor(src, path) {
|
|
84
|
+
this.src = src;
|
|
85
|
+
this.path = path;
|
|
86
|
+
this.line = 1;
|
|
87
|
+
this.column = 0;
|
|
88
|
+
this.index = 0;
|
|
89
|
+
this.variables = new Map;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
Returns the current character being processed.
|
|
94
|
+
*/
|
|
95
|
+
char_current() {
|
|
96
|
+
return this.src.charAt(this.index);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
Checks if the next characters match the given string.
|
|
101
|
+
@param {string} chars
|
|
102
|
+
@returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
chars_match(chars) {
|
|
105
|
+
return this.src.slice(this.index, this.index + chars.length) === chars;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
Steps to the next character.
|
|
110
|
+
*/
|
|
111
|
+
char_step() {
|
|
112
|
+
const char = this.char_current();
|
|
113
|
+
if (char == null) error('Unexpected end of input', this);
|
|
114
|
+
this.index++;
|
|
115
|
+
if (char === '\n') {
|
|
116
|
+
this.line++;
|
|
117
|
+
this.column = 0;
|
|
118
|
+
}
|
|
119
|
+
else this.column++;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
Steps over n characters.
|
|
124
|
+
@param {number} n
|
|
125
|
+
*/
|
|
126
|
+
chars_step(n) {
|
|
127
|
+
for (let i = 0; i < n; i++) {
|
|
128
|
+
this.char_step();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
Consumes the given characters from the input.
|
|
134
|
+
@param {string} chars
|
|
135
|
+
*/
|
|
136
|
+
chars_consume(chars) {
|
|
137
|
+
if (!this.chars_match(chars)) error(`Expected "${chars}"`, this);
|
|
138
|
+
this.chars_step(chars.length);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
Consumes characters until the given limit is reached.
|
|
143
|
+
@param {string} limit - The end delimiter
|
|
144
|
+
@param {string} desc - Description of the context
|
|
145
|
+
*/
|
|
146
|
+
chars_consume_until(limit, desc) {
|
|
147
|
+
const index_end = this.src.indexOf(limit, this.index);
|
|
148
|
+
if (index_end === -1) error(`Unclosed ${desc}`, this);
|
|
149
|
+
const value = this.src.slice(this.index, index_end);
|
|
150
|
+
this.chars_step(index_end + limit.length - this.index);
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
Skips whitespace characters.
|
|
156
|
+
*/
|
|
157
|
+
chars_skip_whitespace() {
|
|
158
|
+
while (html_is_whitespace(this.char_current())) {
|
|
159
|
+
this.char_step();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
Creates position info for a token.
|
|
165
|
+
@returns {Object} Position info with path, line, column
|
|
166
|
+
*/
|
|
167
|
+
position_get() {
|
|
168
|
+
return {
|
|
169
|
+
path: this.path,
|
|
170
|
+
line: this.line,
|
|
171
|
+
column: this.column,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
Expects to be in either top level or inside a block.
|
|
177
|
+
@returns {Array} Array of tokens
|
|
178
|
+
*/
|
|
179
|
+
parse_nodes() {
|
|
180
|
+
const tokens = [];
|
|
181
|
+
|
|
182
|
+
while (this.index < this.src.length) {
|
|
183
|
+
let token = null;
|
|
184
|
+
|
|
185
|
+
const char = this.char_current();
|
|
186
|
+
if (html_is_whitespace(char)) {
|
|
187
|
+
this.char_step();
|
|
188
|
+
}
|
|
189
|
+
else if (char === '<') {
|
|
190
|
+
if (this.chars_match('<!--')) this.chars_consume_until('-->', 'HTML comment');
|
|
191
|
+
else if (this.chars_match('</')) token = this.parse_html_end();
|
|
192
|
+
else token = this.parse_html_start();
|
|
193
|
+
}
|
|
194
|
+
else if (char === '{') {
|
|
195
|
+
token = this.parse_liquid();
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
token = this.parse_text();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (token !== null) tokens.push(token);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return tokens;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
Parses a html start tag.
|
|
209
|
+
@returns {Object} Token
|
|
210
|
+
*/
|
|
211
|
+
parse_html_start() {
|
|
212
|
+
const position = this.position_get();
|
|
213
|
+
this.chars_consume('<');
|
|
214
|
+
const tag_name = this.parse_tag_name();
|
|
215
|
+
const attributes = this.parse_attributes();
|
|
216
|
+
this.chars_consume('>');
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
type: TOKEN_HTML_START,
|
|
220
|
+
...position,
|
|
221
|
+
trim_before: false,
|
|
222
|
+
trim_after: false,
|
|
223
|
+
tag_name,
|
|
224
|
+
attributes,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
Parses an html end tag.
|
|
230
|
+
@returns {Object} Token
|
|
231
|
+
*/
|
|
232
|
+
parse_html_end() {
|
|
233
|
+
const position = this.position_get();
|
|
234
|
+
this.chars_consume('</');
|
|
235
|
+
const tag_name = this.parse_tag_name();
|
|
236
|
+
this.chars_consume('>');
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
type: TOKEN_HTML_END,
|
|
240
|
+
...position,
|
|
241
|
+
trim_before: false,
|
|
242
|
+
trim_after: false,
|
|
243
|
+
tag_name,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
Parses a liquid block.
|
|
249
|
+
@returns {Object} Token
|
|
250
|
+
*/
|
|
251
|
+
parse_liquid() {
|
|
252
|
+
const position = this.position_get();
|
|
253
|
+
const is_expression = this.chars_match('{{');
|
|
254
|
+
|
|
255
|
+
if (
|
|
256
|
+
!is_expression &&
|
|
257
|
+
!this.chars_match('{%')
|
|
258
|
+
) {
|
|
259
|
+
// this is not a liquid tag!
|
|
260
|
+
this.char_step();
|
|
261
|
+
return {
|
|
262
|
+
type: TOKEN_TEXT,
|
|
263
|
+
...position,
|
|
264
|
+
trim_before: false,
|
|
265
|
+
trim_after: false,
|
|
266
|
+
value: '{',
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
this.chars_consume('{');
|
|
271
|
+
this.char_step(); // skip second { or %
|
|
272
|
+
|
|
273
|
+
let value = (
|
|
274
|
+
is_expression
|
|
275
|
+
? this.chars_consume_until('}}', 'liquid expression')
|
|
276
|
+
: this.chars_consume_until('%}', 'liquid tag')
|
|
277
|
+
);
|
|
278
|
+
const trim_before = value.startsWith('-');
|
|
279
|
+
const trim_after = value.endsWith('-');
|
|
280
|
+
value = value.slice(
|
|
281
|
+
trim_before ? 1 : 0,
|
|
282
|
+
trim_after ? -1 : undefined
|
|
283
|
+
).trim();
|
|
284
|
+
|
|
285
|
+
if (is_expression) {
|
|
286
|
+
// TODO: see if it is really just a variable or includes pipes
|
|
287
|
+
this.variables.set(value, null);
|
|
288
|
+
return {
|
|
289
|
+
type: TOKEN_EXPRESSION,
|
|
290
|
+
...position,
|
|
291
|
+
trim_before,
|
|
292
|
+
trim_after,
|
|
293
|
+
value,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// liquid tag
|
|
298
|
+
const [command_str, ...args] = value.split(' ');
|
|
299
|
+
if (!command_str) error('Empty liquid tag command', position);
|
|
300
|
+
value = args.join(' ').trimStart();
|
|
301
|
+
|
|
302
|
+
switch (command_str) {
|
|
303
|
+
case 'comment':
|
|
304
|
+
this.chars_consume_until('endcomment', 'liquid comment');
|
|
305
|
+
return {
|
|
306
|
+
type: TOKEN_LIQUID,
|
|
307
|
+
...position,
|
|
308
|
+
trim_before,
|
|
309
|
+
trim_after: this.chars_consume_until('%}', 'liquid tag').endsWith('-'),
|
|
310
|
+
command: COMMAND_NOP,
|
|
311
|
+
value: '',
|
|
312
|
+
};
|
|
313
|
+
case 'echo':
|
|
314
|
+
// TODO: see if it is really just a variable or includes pipes
|
|
315
|
+
this.variables.set(value, null);
|
|
316
|
+
return {
|
|
317
|
+
type: TOKEN_EXPRESSION,
|
|
318
|
+
...position,
|
|
319
|
+
trim_before,
|
|
320
|
+
trim_after,
|
|
321
|
+
value,
|
|
322
|
+
};
|
|
323
|
+
case 'raw': {
|
|
324
|
+
const position = this.position_get();
|
|
325
|
+
let value = this.chars_consume_until('endraw', 'liquid raw');
|
|
326
|
+
if (trim_after) value = value.trimStart();
|
|
327
|
+
const index_end_braces = value.lastIndexOf('{%');
|
|
328
|
+
if (index_end_braces === -1) error(`Unclosed liquid raw`, position);
|
|
329
|
+
const trim_after_2 = value.charAt(index_end_braces + 2) === '-';
|
|
330
|
+
value = value.slice(0, index_end_braces);
|
|
331
|
+
if (trim_after_2) value = value.trimEnd();
|
|
332
|
+
return {
|
|
333
|
+
type: TOKEN_TEXT,
|
|
334
|
+
...position,
|
|
335
|
+
trim_before,
|
|
336
|
+
trim_after: this.chars_consume_until('%}', 'liquid tag').endsWith('-'),
|
|
337
|
+
value,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const command = command_map.get(command_str);
|
|
343
|
+
if (command == null) error(`Unsupported liquid command: ${command_str}`, position);
|
|
344
|
+
|
|
345
|
+
if (
|
|
346
|
+
command === COMMAND_IF ||
|
|
347
|
+
command === COMMAND_UNLESS
|
|
348
|
+
) {
|
|
349
|
+
// TODO: see if it is really just a variable or includes pipes
|
|
350
|
+
this.variables.set(value, null);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
type: TOKEN_LIQUID,
|
|
355
|
+
...position,
|
|
356
|
+
trim_before,
|
|
357
|
+
trim_after,
|
|
358
|
+
command,
|
|
359
|
+
value,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
Parses static text value.
|
|
365
|
+
@returns {Object} Token
|
|
366
|
+
*/
|
|
367
|
+
parse_text() {
|
|
368
|
+
const position = this.position_get();
|
|
369
|
+
let value = '';
|
|
370
|
+
while (this.index < this.src.length) {
|
|
371
|
+
const char = this.char_current();
|
|
372
|
+
if (char === '<' || char === '{') break;
|
|
373
|
+
value += char;
|
|
374
|
+
this.char_step();
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
type: TOKEN_TEXT,
|
|
378
|
+
...position,
|
|
379
|
+
trim_before: false,
|
|
380
|
+
trim_after: false,
|
|
381
|
+
value,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
Parses a tag name (can be static or dynamic).
|
|
387
|
+
@returns {string} Tag name
|
|
388
|
+
*/
|
|
389
|
+
parse_tag_name() {
|
|
390
|
+
let name = '';
|
|
391
|
+
while (this.index < this.src.length) {
|
|
392
|
+
const char = this.char_current();
|
|
393
|
+
if (html_is_whitespace(char) || char === '>' || char === '/') break;
|
|
394
|
+
name += char;
|
|
395
|
+
this.char_step();
|
|
396
|
+
}
|
|
397
|
+
return name;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
Parses HTML attributes.
|
|
402
|
+
@returns {Array} Array of attribute tokens
|
|
403
|
+
*/
|
|
404
|
+
parse_attributes() {
|
|
405
|
+
const attributes = [];
|
|
406
|
+
|
|
407
|
+
while (this.index < this.src.length) {
|
|
408
|
+
// Skip whitespace
|
|
409
|
+
while (html_is_whitespace(this.char_current())) {
|
|
410
|
+
this.char_step();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const char = this.char_current();
|
|
414
|
+
if (char === '>' || char === '/') break;
|
|
415
|
+
|
|
416
|
+
// Check for liquid tag (including conditionals)
|
|
417
|
+
if (char === '{' && this.chars_match('{%')) {
|
|
418
|
+
const liquid_tag = this.parse_liquid();
|
|
419
|
+
// Store liquid tag directly - it will be processed in build phase
|
|
420
|
+
attributes.push(liquid_tag);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const position = this.position_get();
|
|
425
|
+
|
|
426
|
+
// Parse attribute name
|
|
427
|
+
let name = '';
|
|
428
|
+
while (this.index < this.src.length) {
|
|
429
|
+
const c = this.char_current();
|
|
430
|
+
if (html_is_whitespace(c) || c === '=' || c === '>' || c === '/' || c === '{') break;
|
|
431
|
+
name += c;
|
|
432
|
+
this.char_step();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (!name) break;
|
|
436
|
+
|
|
437
|
+
// Skip whitespace after name
|
|
438
|
+
while (html_is_whitespace(this.char_current())) {
|
|
439
|
+
this.char_step();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Check for '='
|
|
443
|
+
let value_tokens = null;
|
|
444
|
+
if (this.char_current() === '=') {
|
|
445
|
+
this.char_step();
|
|
446
|
+
|
|
447
|
+
// Skip whitespace after '='
|
|
448
|
+
while (html_is_whitespace(this.char_current())) {
|
|
449
|
+
this.char_step();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Parse attribute value
|
|
453
|
+
const quote = this.char_current();
|
|
454
|
+
if (quote === '"' || quote === "'") {
|
|
455
|
+
this.char_step();
|
|
456
|
+
value_tokens = this.parse_attribute_value(quote);
|
|
457
|
+
this.chars_consume(quote);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
// Unquoted attribute value
|
|
461
|
+
value_tokens = [];
|
|
462
|
+
let text = '';
|
|
463
|
+
const text_position = this.position_get();
|
|
464
|
+
|
|
465
|
+
while (this.index < this.src.length) {
|
|
466
|
+
const c = this.char_current();
|
|
467
|
+
if (html_is_whitespace(c) || c === '>' || c === '/') break;
|
|
468
|
+
text += c;
|
|
469
|
+
this.char_step();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (text) {
|
|
473
|
+
value_tokens.push({
|
|
474
|
+
type: TOKEN_TEXT,
|
|
475
|
+
...text_position,
|
|
476
|
+
trim_before: false,
|
|
477
|
+
trim_after: false,
|
|
478
|
+
value: text,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
attributes.push({
|
|
485
|
+
type: TOKEN_ATTRIBUTE,
|
|
486
|
+
...position,
|
|
487
|
+
trim_before: false,
|
|
488
|
+
trim_after: false,
|
|
489
|
+
name: html_attr_to_dom(name),
|
|
490
|
+
value: value_tokens,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return attributes;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
Parses an attribute value which can contain text, liquid expressions, and liquid tags.
|
|
499
|
+
@param {string} quote - The quote character used
|
|
500
|
+
@returns {Array} Array of tokens (text, expressions, and liquid tags)
|
|
501
|
+
*/
|
|
502
|
+
parse_attribute_value(quote) {
|
|
503
|
+
const tokens = [];
|
|
504
|
+
let text = '';
|
|
505
|
+
let text_position = this.position_get();
|
|
506
|
+
|
|
507
|
+
while (this.index < this.src.length) {
|
|
508
|
+
const char = this.char_current();
|
|
509
|
+
|
|
510
|
+
if (char === quote) break;
|
|
511
|
+
|
|
512
|
+
// Check for liquid tag {% ... %}
|
|
513
|
+
if (char === '{' && this.chars_match('{%')) {
|
|
514
|
+
// Save accumulated text
|
|
515
|
+
if (text) {
|
|
516
|
+
tokens.push({
|
|
517
|
+
type: TOKEN_TEXT,
|
|
518
|
+
...text_position,
|
|
519
|
+
trim_before: false,
|
|
520
|
+
trim_after: false,
|
|
521
|
+
value: text,
|
|
522
|
+
});
|
|
523
|
+
text = '';
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Parse liquid tag (same as in parse_liquid)
|
|
527
|
+
tokens.push(this.parse_liquid());
|
|
528
|
+
text_position = this.position_get();
|
|
529
|
+
}
|
|
530
|
+
// Check for liquid expression {{ ... }}
|
|
531
|
+
else if (char === '{' && this.chars_match('{{')) {
|
|
532
|
+
// Save accumulated text
|
|
533
|
+
if (text) {
|
|
534
|
+
tokens.push({
|
|
535
|
+
type: TOKEN_TEXT,
|
|
536
|
+
...text_position,
|
|
537
|
+
trim_before: false,
|
|
538
|
+
trim_after: false,
|
|
539
|
+
value: text,
|
|
540
|
+
});
|
|
541
|
+
text = '';
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Parse liquid expression
|
|
545
|
+
const expr_position = this.position_get();
|
|
546
|
+
this.chars_consume('{{');
|
|
547
|
+
const expression = this.chars_consume_until('}}', 'liquid expression');
|
|
548
|
+
const varName = expression.trim();
|
|
549
|
+
this.variables.set(varName, null);
|
|
550
|
+
tokens.push({
|
|
551
|
+
type: TOKEN_EXPRESSION,
|
|
552
|
+
...expr_position,
|
|
553
|
+
trim_before: false,
|
|
554
|
+
trim_after: false,
|
|
555
|
+
value: varName,
|
|
556
|
+
});
|
|
557
|
+
text_position = this.position_get();
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
text += char;
|
|
561
|
+
this.char_step();
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Add remaining text
|
|
566
|
+
if (text) {
|
|
567
|
+
tokens.push({
|
|
568
|
+
type: TOKEN_TEXT,
|
|
569
|
+
...text_position,
|
|
570
|
+
trim_before: false,
|
|
571
|
+
trim_after: false,
|
|
572
|
+
value: text,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return tokens.length === 0 ? null : tokens;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
Process conditional attributes with support for nested conditionals.
|
|
582
|
+
@param {Array} tokens
|
|
583
|
+
@param {number} start
|
|
584
|
+
@param {number} end
|
|
585
|
+
@param {string} condition_prefix
|
|
586
|
+
@param {Object} result_props
|
|
587
|
+
@returns {Object} result_props
|
|
588
|
+
*/
|
|
589
|
+
function process_conditional_attributes(tokens, start, end, condition_prefix, result_props) {
|
|
590
|
+
let i = start;
|
|
591
|
+
while (i < end) {
|
|
592
|
+
const t = tokens[i];
|
|
593
|
+
|
|
594
|
+
if (t.type === TOKEN_LIQUID && (t.command === COMMAND_IF || t.command === COMMAND_UNLESS)) {
|
|
595
|
+
// Nested conditional
|
|
596
|
+
const nested_conditional = build_conditional(
|
|
597
|
+
tokens,
|
|
598
|
+
i,
|
|
599
|
+
end,
|
|
600
|
+
(tokens, nested_start, nested_end, is_unless, nested_condition) => {
|
|
601
|
+
const new_nested_condition = is_unless ? `!(${nested_condition})` : nested_condition;
|
|
602
|
+
const combined_condition = `${condition_prefix} && ${new_nested_condition}`;
|
|
603
|
+
const nested_props = {};
|
|
604
|
+
process_conditional_attributes(tokens, nested_start, nested_end, combined_condition, nested_props);
|
|
605
|
+
return nested_props;
|
|
606
|
+
}
|
|
607
|
+
);
|
|
608
|
+
Object.assign(result_props, nested_conditional.result);
|
|
609
|
+
i = nested_conditional.index;
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (t.type !== TOKEN_ATTRIBUTE) {
|
|
614
|
+
i++;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (t.value === null) { // boolean attribute
|
|
619
|
+
result_props[t.name] = {
|
|
620
|
+
type: VALUE_TYPE_FIELD,
|
|
621
|
+
data: condition_prefix,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
else { // non-boolean attribute with value
|
|
625
|
+
const value = build_value(t.value);
|
|
626
|
+
const value_str = (
|
|
627
|
+
value.type === VALUE_TYPE_STATIC
|
|
628
|
+
? JSON.stringify(value.data)
|
|
629
|
+
: value.type === VALUE_TYPE_FIELD
|
|
630
|
+
? value.data
|
|
631
|
+
: generate_value_inline(value, t)
|
|
632
|
+
);
|
|
633
|
+
result_props[t.name] = {
|
|
634
|
+
type: VALUE_TYPE_FIELD,
|
|
635
|
+
data: `${condition_prefix} ? ${value_str} : ""`,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
i++;
|
|
639
|
+
}
|
|
640
|
+
return result_props;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
Generic conditional helper that finds matching end tag and processes body.
|
|
645
|
+
@param {Array} tokens
|
|
646
|
+
@param {number} index - Index of the opening conditional token
|
|
647
|
+
@param {number} index_end - End of search range (exclusive)
|
|
648
|
+
@param {Function} process_body - Function to process tokens in body
|
|
649
|
+
@returns {Object} {result, index} - result from process_body and index after end tag
|
|
650
|
+
*/
|
|
651
|
+
function build_conditional(tokens, index, index_end, process_body) {
|
|
652
|
+
const token = tokens[index];
|
|
653
|
+
const is_unless = token.command === COMMAND_UNLESS;
|
|
654
|
+
const condition = token.value;
|
|
655
|
+
const end_command = is_unless ? COMMAND_ENDUNLESS : COMMAND_ENDIF;
|
|
656
|
+
|
|
657
|
+
// find matching end tag
|
|
658
|
+
let depth = 1;
|
|
659
|
+
let body_end = index + 1;
|
|
660
|
+
for (; body_end < index_end; body_end++) {
|
|
661
|
+
const t = tokens[body_end];
|
|
662
|
+
if (t.type !== TOKEN_LIQUID) continue;
|
|
663
|
+
|
|
664
|
+
if (t.command === COMMAND_IF || t.command === COMMAND_UNLESS) {
|
|
665
|
+
depth++;
|
|
666
|
+
}
|
|
667
|
+
else if ((t.command === COMMAND_ENDIF || t.command === COMMAND_ENDUNLESS) && --depth <= 0) {
|
|
668
|
+
// Accept any end tag (endif or endunless) when depth reaches 0
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (depth > 0) {
|
|
674
|
+
error(`Unclosed ${command_map_reverse.get(token.command)} block`, token);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Process body with provided function
|
|
678
|
+
const result = process_body(tokens, index + 1, body_end, is_unless, condition);
|
|
679
|
+
|
|
680
|
+
return {
|
|
681
|
+
result,
|
|
682
|
+
index: body_end + 1,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
Build nodes from a token range.
|
|
688
|
+
@param {Array} tokens
|
|
689
|
+
@param {number} index start
|
|
690
|
+
@param {number} index_end (exclusive)
|
|
691
|
+
@param {string} condition_prefix - Optional condition prefix for flattening nested conditionals
|
|
692
|
+
@returns {Array} nodes
|
|
693
|
+
*/
|
|
694
|
+
function build_nodes(tokens, index, index_end, condition_prefix = '') {
|
|
695
|
+
const nodes = [];
|
|
696
|
+
|
|
697
|
+
while (index < index_end) {
|
|
698
|
+
const token = tokens[index];
|
|
699
|
+
|
|
700
|
+
switch (token.type) {
|
|
701
|
+
case TOKEN_HTML_START: {
|
|
702
|
+
const node = build_element_node(tokens, index);
|
|
703
|
+
if (condition_prefix) {
|
|
704
|
+
// Wrap element in conditional
|
|
705
|
+
nodes.push({
|
|
706
|
+
type: NODE_TYPE_IF,
|
|
707
|
+
condition: {
|
|
708
|
+
type: VALUE_TYPE_FIELD,
|
|
709
|
+
data: condition_prefix,
|
|
710
|
+
},
|
|
711
|
+
child: node.element,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
nodes.push(node.element);
|
|
716
|
+
}
|
|
717
|
+
index = node.index;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
case TOKEN_HTML_END:
|
|
721
|
+
error(`Unexpected closing tag </${token.tag_name}>`, token);
|
|
722
|
+
case TOKEN_TEXT:
|
|
723
|
+
case TOKEN_EXPRESSION: {
|
|
724
|
+
// merge text and expression tokens
|
|
725
|
+
const merge_list = [];
|
|
726
|
+
for (; index < index_end; index++) {
|
|
727
|
+
const token = tokens[index];
|
|
728
|
+
if (
|
|
729
|
+
token.type !== TOKEN_TEXT &&
|
|
730
|
+
token.type !== TOKEN_EXPRESSION
|
|
731
|
+
) break;
|
|
732
|
+
merge_list.push(token);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// as lui does not allow text nodes, create a span
|
|
736
|
+
const innerText = build_value_trimmed(merge_list);
|
|
737
|
+
if (innerText) {
|
|
738
|
+
const span_node = {
|
|
739
|
+
type: NODE_TYPE_ELEMENT,
|
|
740
|
+
tag: 'span',
|
|
741
|
+
props: {
|
|
742
|
+
innerText,
|
|
743
|
+
},
|
|
744
|
+
children: [],
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
if (condition_prefix) {
|
|
748
|
+
// Wrap span in conditional
|
|
749
|
+
nodes.push({
|
|
750
|
+
type: NODE_TYPE_IF,
|
|
751
|
+
condition: {
|
|
752
|
+
type: VALUE_TYPE_FIELD,
|
|
753
|
+
data: condition_prefix,
|
|
754
|
+
},
|
|
755
|
+
child: span_node,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
nodes.push(span_node);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
case TOKEN_LIQUID:
|
|
765
|
+
switch (token.command) {
|
|
766
|
+
case COMMAND_IF:
|
|
767
|
+
case COMMAND_UNLESS: {
|
|
768
|
+
const conditional = build_conditional(tokens, index, index_end, (tokens, start, end, is_unless, condition) => {
|
|
769
|
+
const new_condition = is_unless ? `!(${condition})` : condition;
|
|
770
|
+
const combined_condition = condition_prefix
|
|
771
|
+
? `${condition_prefix} && ${new_condition}`
|
|
772
|
+
: new_condition;
|
|
773
|
+
|
|
774
|
+
// Build children with combined condition - this flattens nested conditionals
|
|
775
|
+
const children = build_nodes(tokens, start, end, combined_condition);
|
|
776
|
+
return children;
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
// Add all flattened children
|
|
780
|
+
nodes.push(...conditional.result);
|
|
781
|
+
index = conditional.index;
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
case COMMAND_ENDIF:
|
|
785
|
+
case COMMAND_ENDUNLESS:
|
|
786
|
+
case COMMAND_ENDFOR:
|
|
787
|
+
// end tags are handled by the if/unless logic above
|
|
788
|
+
error(`Unexpected ${command_map_reverse.get(token.command)} without matching opening tag`, token);
|
|
789
|
+
}
|
|
790
|
+
default:
|
|
791
|
+
error(`Unexpected liquid tag ${command_map_reverse.get(token.command)}`, token);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
index++;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return nodes;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
Builds an element node from tokens, including its children.
|
|
802
|
+
@param {Array} tokens
|
|
803
|
+
@param {number} index start
|
|
804
|
+
@returns {Object} {element, index}
|
|
805
|
+
*/
|
|
806
|
+
function build_element_node(tokens, index) {
|
|
807
|
+
const token_start = tokens[index++];
|
|
808
|
+
|
|
809
|
+
const props = {};
|
|
810
|
+
if (token_start.attributes)
|
|
811
|
+
for (let index = 0; index < token_start.attributes.length; index++) {
|
|
812
|
+
const token = token_start.attributes[index];
|
|
813
|
+
|
|
814
|
+
if (token.type === TOKEN_ATTRIBUTE) {
|
|
815
|
+
props[token.name] = (
|
|
816
|
+
token.value === null // boolean
|
|
817
|
+
? {type: VALUE_TYPE_STATIC, data: true}
|
|
818
|
+
: build_value(token.value)
|
|
819
|
+
);
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (token.type !== TOKEN_LIQUID) error(`Unexpected attribute type ${token.type}`, token);
|
|
823
|
+
|
|
824
|
+
// conditional attributes
|
|
825
|
+
switch (token.command) {
|
|
826
|
+
case COMMAND_ENDIF:
|
|
827
|
+
case COMMAND_ENDUNLESS:
|
|
828
|
+
case COMMAND_ENDFOR:
|
|
829
|
+
// Skip end tags - they're handled by the opening if/unless
|
|
830
|
+
break;
|
|
831
|
+
case COMMAND_IF:
|
|
832
|
+
case COMMAND_UNLESS: {
|
|
833
|
+
const conditional = build_conditional(
|
|
834
|
+
token_start.attributes,
|
|
835
|
+
index,
|
|
836
|
+
token_start.attributes.length,
|
|
837
|
+
(tokens, start, end, is_unless, condition) => {
|
|
838
|
+
const new_condition = is_unless ? `!(${condition})` : condition;
|
|
839
|
+
// Process attributes in conditional body with prefix support
|
|
840
|
+
const result_props = {};
|
|
841
|
+
return process_conditional_attributes(tokens, start, end, new_condition, result_props);
|
|
842
|
+
}
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
// Merge conditional props into main props
|
|
846
|
+
Object.assign(props, conditional.result);
|
|
847
|
+
index = conditional.index - 1; // -1 because for loop will increment
|
|
848
|
+
break;
|
|
849
|
+
}// case
|
|
850
|
+
}// switch
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
let children = [];
|
|
854
|
+
if (!html_is_self_closing(token_start.tag_name)) {
|
|
855
|
+
const index_start = index;
|
|
856
|
+
({children, index} = build_children(tokens, index, token_start.tag_name));
|
|
857
|
+
|
|
858
|
+
text_extract: if (children.length === 0) {
|
|
859
|
+
const merge_list = [];
|
|
860
|
+
loop: for (let i = index_start; i < tokens.length; i++) {
|
|
861
|
+
const token = tokens[i];
|
|
862
|
+
|
|
863
|
+
switch (token.type) {
|
|
864
|
+
case TOKEN_HTML_END:
|
|
865
|
+
if (token.tag_name === token_start.tag_name) break loop;
|
|
866
|
+
case TOKEN_LIQUID:
|
|
867
|
+
// apart from loops, everything is allowed in text-only
|
|
868
|
+
if (token.command !== COMMAND_FOR) break;
|
|
869
|
+
case TOKEN_HTML_START:
|
|
870
|
+
text_only = false;
|
|
871
|
+
break text_extract;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
merge_list.push(token);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const value = build_value_trimmed(merge_list);
|
|
878
|
+
if (value) props.innerText = value;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
element: {
|
|
884
|
+
type: NODE_TYPE_ELEMENT,
|
|
885
|
+
tag: token_start.tag_name,
|
|
886
|
+
props,
|
|
887
|
+
children,
|
|
888
|
+
},
|
|
889
|
+
index,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
Helper to generate value inline for complex expressions.
|
|
895
|
+
Should be removed in future as its hacky af.
|
|
896
|
+
@param {Object} value
|
|
897
|
+
@returns {string}
|
|
898
|
+
*/
|
|
899
|
+
function generate_value_inline(value, position) {
|
|
900
|
+
switch (value.type) {
|
|
901
|
+
case VALUE_TYPE_FIELD:
|
|
902
|
+
return value.data;
|
|
903
|
+
case VALUE_TYPE_STATIC:
|
|
904
|
+
return JSON.stringify(value.data);
|
|
905
|
+
case VALUE_TYPE_STRING_CONCAT:
|
|
906
|
+
// Generate template literal
|
|
907
|
+
return '`' + (
|
|
908
|
+
value.data.map(part => (
|
|
909
|
+
part.type === VALUE_TYPE_STATIC
|
|
910
|
+
? part.data
|
|
911
|
+
.replace(/\\/g, '\\\\')
|
|
912
|
+
.replace(/`/g, '\\`')
|
|
913
|
+
.replace(/\$/g, '\\$')
|
|
914
|
+
: '${' + part.data + '}'
|
|
915
|
+
)).join('')
|
|
916
|
+
) + '`';
|
|
917
|
+
}
|
|
918
|
+
error(`Unsupported value type: ${value.type}`, position);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
Builds children nodes before the end tag.
|
|
923
|
+
@param {Array} tokens
|
|
924
|
+
@param {number} index start
|
|
925
|
+
@param {string} tag_parent
|
|
926
|
+
@returns {Object} {children, index}
|
|
927
|
+
*/
|
|
928
|
+
function build_children(tokens, index, tag_parent) {
|
|
929
|
+
const content = [];
|
|
930
|
+
|
|
931
|
+
let text_only = true;
|
|
932
|
+
let depth = 1;
|
|
933
|
+
loop: for (; index < tokens.length; index++) {
|
|
934
|
+
const token = tokens[index];
|
|
935
|
+
|
|
936
|
+
switch (token.type) {
|
|
937
|
+
case TOKEN_HTML_START:
|
|
938
|
+
if (token.tag_name === tag_parent) depth++;
|
|
939
|
+
text_only = false;
|
|
940
|
+
break;
|
|
941
|
+
case TOKEN_LIQUID:
|
|
942
|
+
if (token.command === COMMAND_IF || token.command === COMMAND_UNLESS) {
|
|
943
|
+
text_only = false;
|
|
944
|
+
}
|
|
945
|
+
break;
|
|
946
|
+
case TOKEN_HTML_END:
|
|
947
|
+
if (
|
|
948
|
+
token.tag_name === tag_parent &&
|
|
949
|
+
--depth === 0
|
|
950
|
+
) {
|
|
951
|
+
// skip the end tag
|
|
952
|
+
index++;
|
|
953
|
+
break loop;
|
|
954
|
+
}
|
|
955
|
+
text_only = false;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
content.push(token);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Check if we exited the loop without finding the closing tag
|
|
962
|
+
if (depth > 0) {
|
|
963
|
+
error(`Unclosed tag <${tag_parent}>`, tokens[index - 1] || tokens[0]);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
return {
|
|
967
|
+
children: (
|
|
968
|
+
text_only
|
|
969
|
+
? []
|
|
970
|
+
: build_nodes(content, 0, content.length)
|
|
971
|
+
),
|
|
972
|
+
index,
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
Builds a value object out of text and expression tokens.
|
|
978
|
+
@param {Array} tokens
|
|
979
|
+
@returns {Object}
|
|
980
|
+
*/
|
|
981
|
+
function build_value(tokens, condition_prefix = '') {
|
|
982
|
+
// Handle conditionals in attribute values (for nested conditionals)
|
|
983
|
+
if (tokens.some(t => t.type === TOKEN_LIQUID)) {
|
|
984
|
+
return build_value_with_conditionals(tokens, condition_prefix);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// liquid trim feature {%- -%}
|
|
988
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
989
|
+
const token = tokens[index];
|
|
990
|
+
if (token.type !== TOKEN_TEXT) continue;
|
|
991
|
+
|
|
992
|
+
const token_before = tokens[index - 1];
|
|
993
|
+
const token_after = tokens[index + 1];
|
|
994
|
+
|
|
995
|
+
if (token_before != null && token_before.trim_after) {
|
|
996
|
+
token.value = token.value.trimStart();
|
|
997
|
+
}
|
|
998
|
+
if (token_after != null && token_after.trim_before) {
|
|
999
|
+
token.value = token.value.trimEnd();
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const values = tokens.map(token => ({
|
|
1004
|
+
type: (
|
|
1005
|
+
token.type === TOKEN_TEXT
|
|
1006
|
+
? VALUE_TYPE_STATIC
|
|
1007
|
+
: VALUE_TYPE_FIELD
|
|
1008
|
+
),
|
|
1009
|
+
data: token.value,
|
|
1010
|
+
}));
|
|
1011
|
+
return (
|
|
1012
|
+
values.length === 1
|
|
1013
|
+
? values[0]
|
|
1014
|
+
: {
|
|
1015
|
+
type: VALUE_TYPE_STRING_CONCAT,
|
|
1016
|
+
data: values,
|
|
1017
|
+
}
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
Builds a value with conditional support (for attribute values with nested conditionals).
|
|
1023
|
+
@param {Array} tokens
|
|
1024
|
+
@param {string} condition_prefix
|
|
1025
|
+
@returns {Object} value object
|
|
1026
|
+
*/
|
|
1027
|
+
function build_value_with_conditionals(tokens, condition_prefix = '') {
|
|
1028
|
+
let index = 0;
|
|
1029
|
+
const parts = [];
|
|
1030
|
+
|
|
1031
|
+
while (index < tokens.length) {
|
|
1032
|
+
const token = tokens[index];
|
|
1033
|
+
|
|
1034
|
+
if (token.type === TOKEN_LIQUID && (token.command === COMMAND_IF || token.command === COMMAND_UNLESS)) {
|
|
1035
|
+
const conditional = build_conditional(
|
|
1036
|
+
tokens,
|
|
1037
|
+
index,
|
|
1038
|
+
tokens.length,
|
|
1039
|
+
(tokens, start, end, is_unless, condition) => {
|
|
1040
|
+
const new_condition = is_unless ? `!(${condition})` : condition;
|
|
1041
|
+
const combined_condition = condition_prefix
|
|
1042
|
+
? `${condition_prefix} && ${new_condition}`
|
|
1043
|
+
: new_condition;
|
|
1044
|
+
|
|
1045
|
+
// Build the value inside the conditional
|
|
1046
|
+
const inner_tokens = tokens.slice(start, end).filter(t =>
|
|
1047
|
+
t.type !== TOKEN_LIQUID ||
|
|
1048
|
+
(t.command !== COMMAND_ENDIF && t.command !== COMMAND_ENDUNLESS)
|
|
1049
|
+
);
|
|
1050
|
+
|
|
1051
|
+
if (inner_tokens.length > 0) {
|
|
1052
|
+
const inner_value = build_value(inner_tokens, combined_condition);
|
|
1053
|
+
return { condition: combined_condition, value: inner_value };
|
|
1054
|
+
}
|
|
1055
|
+
return null;
|
|
1056
|
+
}
|
|
1057
|
+
);
|
|
1058
|
+
|
|
1059
|
+
if (conditional.result) {
|
|
1060
|
+
parts.push(conditional.result);
|
|
1061
|
+
}
|
|
1062
|
+
index = conditional.index;
|
|
1063
|
+
}
|
|
1064
|
+
else if (token.type !== TOKEN_LIQUID) {
|
|
1065
|
+
// Regular text or expression token
|
|
1066
|
+
parts.push({ condition: condition_prefix, value: {
|
|
1067
|
+
type: token.type === TOKEN_TEXT ? VALUE_TYPE_STATIC : VALUE_TYPE_FIELD,
|
|
1068
|
+
data: token.value,
|
|
1069
|
+
}});
|
|
1070
|
+
index++;
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
index++;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// If all parts have the same condition (or no condition), merge them
|
|
1078
|
+
if (parts.length === 0) {
|
|
1079
|
+
return { type: VALUE_TYPE_STATIC, data: '' };
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const first_condition = parts[0].condition;
|
|
1083
|
+
const same_condition = parts.every(p => p.condition === first_condition);
|
|
1084
|
+
|
|
1085
|
+
if (same_condition && parts.length > 1) {
|
|
1086
|
+
// Merge all values into a string concat
|
|
1087
|
+
const merged_data = parts.map(p => p.value.type === VALUE_TYPE_STRING_CONCAT ? p.value.data : [p.value]).flat();
|
|
1088
|
+
const result = {
|
|
1089
|
+
type: VALUE_TYPE_STRING_CONCAT,
|
|
1090
|
+
data: merged_data,
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
if (first_condition) {
|
|
1094
|
+
// Wrap in conditional
|
|
1095
|
+
return {
|
|
1096
|
+
type: VALUE_TYPE_FIELD,
|
|
1097
|
+
data: `${first_condition} ? ${generate_value_inline(result, { path: 'generated', line: 0, column: 0 })} : ""`,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
return result;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Multiple different conditions - build complex expression
|
|
1104
|
+
if (parts.length === 1) {
|
|
1105
|
+
if (parts[0].condition) {
|
|
1106
|
+
const value_str = generate_value_inline(parts[0].value, { path: 'generated', line: 0, column: 0 });
|
|
1107
|
+
return {
|
|
1108
|
+
type: VALUE_TYPE_FIELD,
|
|
1109
|
+
data: `${parts[0].condition} ? ${value_str} : ""`,
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
return parts[0].value;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Build concatenated string with conditional parts
|
|
1116
|
+
// e.g., "btn " + (isActive ? "active" : "") + " " + (isDisabled ? "disabled" : "")
|
|
1117
|
+
const concat_parts = [];
|
|
1118
|
+
const dummy_pos = { path: 'generated', line: 0, column: 0 };
|
|
1119
|
+
for (const part of parts) {
|
|
1120
|
+
if (part.condition) {
|
|
1121
|
+
const value_str = generate_value_inline(part.value, dummy_pos);
|
|
1122
|
+
concat_parts.push({
|
|
1123
|
+
type: VALUE_TYPE_FIELD,
|
|
1124
|
+
data: `(${part.condition} ? ${value_str} : "")`,
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
else {
|
|
1128
|
+
concat_parts.push(part.value);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// If all parts are static, merge into single static value
|
|
1133
|
+
if (concat_parts.every(p => p.type === VALUE_TYPE_STATIC)) {
|
|
1134
|
+
return {
|
|
1135
|
+
type: VALUE_TYPE_STATIC,
|
|
1136
|
+
data: concat_parts.map(p => p.data).join(''),
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// Otherwise, return as string concat
|
|
1141
|
+
return {
|
|
1142
|
+
type: VALUE_TYPE_STRING_CONCAT,
|
|
1143
|
+
data: concat_parts,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
Builds a value object out of text and expression tokens.
|
|
1149
|
+
Trims whitespace from the start and end.
|
|
1150
|
+
@param {Array} tokens
|
|
1151
|
+
@returns {Object} value object or null
|
|
1152
|
+
*/
|
|
1153
|
+
function build_value_trimmed(tokens) {
|
|
1154
|
+
const filtered = [];
|
|
1155
|
+
|
|
1156
|
+
let empty = true;
|
|
1157
|
+
for (const token of tokens) {
|
|
1158
|
+
if (token.type === TOKEN_EXPRESSION) {
|
|
1159
|
+
filtered.push(token);
|
|
1160
|
+
empty = false;
|
|
1161
|
+
}
|
|
1162
|
+
else if (token.type === TOKEN_TEXT) {
|
|
1163
|
+
// Keep all text, even whitespace, to preserve spacing
|
|
1164
|
+
filtered.push(token);
|
|
1165
|
+
if (token.value.trimStart()) {
|
|
1166
|
+
empty = false;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
if (empty) {
|
|
1171
|
+
return null;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// remove spaces from start and end
|
|
1175
|
+
if (filtered[0].type === TOKEN_TEXT) {
|
|
1176
|
+
const trimmed = filtered[0].value.trimStart();
|
|
1177
|
+
if (trimmed) filtered[0].value = trimmed;
|
|
1178
|
+
else {
|
|
1179
|
+
filtered.shift();
|
|
1180
|
+
if (filtered.length === 0) return null;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
const last = filtered[filtered.length - 1];
|
|
1184
|
+
if (last.type === TOKEN_TEXT) {
|
|
1185
|
+
const trimmed = last.value.trimEnd();
|
|
1186
|
+
if (trimmed) last.value = trimmed;
|
|
1187
|
+
else {
|
|
1188
|
+
filtered.pop();
|
|
1189
|
+
if (filtered.length === 0) return null;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
return build_value(filtered);
|
|
1194
|
+
}
|