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.
Files changed (34) hide show
  1. package/.vscode/settings.json +10 -0
  2. package/README.md +103 -0
  3. package/package.json +32 -0
  4. package/schema/intermediary.json +147 -0
  5. package/src/cli.js +59 -0
  6. package/src/constants.js +8 -0
  7. package/src/generator.js +276 -0
  8. package/src/main.js +74 -0
  9. package/src/parser.js +57 -0
  10. package/src/parsers/json.js +9 -0
  11. package/src/parsers/liquid.js +1194 -0
  12. package/test/basic.js +9 -0
  13. package/test/templates/article.liquid +9 -0
  14. package/test/templates/button.liquid +3 -0
  15. package/test/templates/complex-nested.liquid +11 -0
  16. package/test/templates/complex.liquid +4 -0
  17. package/test/templates/conditional-bool-attr.liquid +4 -0
  18. package/test/templates/conditional-nonbool-attr.liquid +4 -0
  19. package/test/templates/conditional-unless-attr.liquid +4 -0
  20. package/test/templates/conditional-unless-nonbool-attr.liquid +4 -0
  21. package/test/templates/conditional.liquid +6 -0
  22. package/test/templates/dynamic-class.liquid +1 -0
  23. package/test/templates/greeting.json +74 -0
  24. package/test/templates/image.liquid +5 -0
  25. package/test/templates/link.json +31 -0
  26. package/test/templates/mixed.liquid +5 -0
  27. package/test/templates/nested-attr-conditionals.liquid +1 -0
  28. package/test/templates/nested-comprehensive.liquid +51 -0
  29. package/test/templates/nested-conditionals.liquid +9 -0
  30. package/test/templates/nested-if-flatten.liquid +6 -0
  31. package/test/templates/root-text.liquid +2 -0
  32. package/test/templates/test-attr-cond.liquid +1 -0
  33. package/test/templates/text-nodes.liquid +5 -0
  34. 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
+ }