lui-templates 0.0.6 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ # Liquid Template Syntax Specification
2
+
3
+ This document describes the Liquid template syntax used in this project. AI agents and contributors should follow these specifications when working with Liquid templates.
4
+
5
+ ## Syntax Overview
6
+
7
+ Liquid uses two types of delimiters:
8
+
9
+ ### 1. Output (Expressions): `{{ }}`
10
+ Used for outputting variables and expressions:
11
+ ```liquid
12
+ {{ variable }}
13
+ {{ user.name }}
14
+ {{ "Hello " + name }}
15
+ ```
16
+
17
+ ### 2. Tags (Commands): `{% %}`
18
+ Used for logic, control flow, and special commands:
19
+ ```liquid
20
+ {% if condition %}
21
+ Content
22
+ {% endif %}
23
+
24
+ {% unless condition %}
25
+ Content
26
+ {% endunless %}
27
+
28
+ {% for item in items %}
29
+ {{ item }}
30
+ {% endfor %}
31
+ ```
32
+
33
+ ## Supported Commands
34
+
35
+ ### Control Flow Commands
36
+ - `{% if condition %}...{% endif %}` - Conditional rendering
37
+ - `{% unless condition %}...{% endunless %}` - Inverse conditional rendering
38
+ - `{% for item in collection %}...{% endfor %}` - Loop over collections
39
+
40
+ ### Special Commands
41
+ - `{% comment %}...{% endcomment %}` - Comments (not rendered)
42
+ - `{% raw %}...{% endraw %}` - Raw content (no processing)
43
+ - `{% render 'component', prop: value %}` - Render a component
44
+
45
+ ## Component Rendering
46
+
47
+ The `{% render %}` command is used to instantiate components:
48
+
49
+ ```liquid
50
+ {% render 'button', label: 'Submit', type: 'primary' %}
51
+ {% render 'user-card', name: user.name, age: 25 %}
52
+ {% render 'components/ui/icon', name: 'check', size: 24 %}
53
+ ```
54
+
55
+ ### Syntax
56
+ ```liquid
57
+ {% render 'path/to/component', prop1: value1, prop2: value2 %}
58
+ ```
59
+
60
+ ### Rules
61
+ 1. **Path**: The component path (string). Only the last segment is used (e.g., `'ui/button'` → `Button`)
62
+ 2. **Component Name**: Converted to PascalCase (e.g., `'user-card'` → `UserCard`)
63
+ 3. **Props**: Key-value pairs separated by commas
64
+ 4. **Values**: Can be:
65
+ - String literals: `'text'` or `"text"`
66
+ - Numbers: `42`, `3.14`
67
+ - Booleans: `true`, `false`
68
+ - Variables: `userName`, `count`
69
+ - Expressions: `user.name`, `items.length`
70
+
71
+ ### Examples
72
+
73
+ Simple with static values:
74
+ ```liquid
75
+ {% render 'button', label: 'Click me', disabled: false %}
76
+ ```
77
+
78
+ With variables:
79
+ ```liquid
80
+ {% render 'input', name: fieldName, value: fieldValue %}
81
+ ```
82
+
83
+ With expressions:
84
+ ```liquid
85
+ {% render 'user-card', name: user.name, count: items.length %}
86
+ ```
87
+
88
+ Within conditionals:
89
+ ```liquid
90
+ {% if showProfile %}
91
+ {% render 'profile', user: currentUser %}
92
+ {% endif %}
93
+ ```
94
+
95
+ ## Important Notes for AI Agents
96
+
97
+ 1. **Always use `{% %}` for the render command**, not `{{ }}`
98
+ 2. The render command is a **tag/command**, not an expression
99
+ 3. Component names are automatically converted from kebab-case to PascalCase
100
+ 4. Only simple variable identifiers are registered as component inputs
101
+ 5. Complex expressions (e.g., `user.name`) are passed through but not registered as inputs
package/README.md CHANGED
@@ -52,8 +52,8 @@ init(() => {
52
52
  ```js
53
53
  import lui_templates from 'lui-templates';
54
54
 
55
- const code = await lui_templates('src/templates/Greeting.liquid');
56
- await fs.writeFile('src/generated/Greeting.js', code, 'utf8');
55
+ const code = await lui_templates('src/templates/greeting.liquid');
56
+ await fs.writeFile('src/components/greeting.js', code, 'utf8');
57
57
 
58
58
  await bundleApp('src/main.js'); // or whatever
59
59
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lui-templates",
3
- "version": "0.0.6",
3
+ "version": "0.1.0",
4
4
  "description": "transform html templates into lui components",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,5 @@
1
1
  import {
2
+ NODE_TYPE_COMPONENT,
2
3
  NODE_TYPE_ELEMENT,
3
4
  NODE_TYPE_IF,
4
5
  VALUE_TYPE_FIELD,
@@ -25,6 +26,7 @@ const COMMAND_FOR = 3;
25
26
  const COMMAND_ENDIF = 4;
26
27
  const COMMAND_ENDUNLESS = 5;
27
28
  const COMMAND_ENDFOR = 6;
29
+ const COMMAND_RENDER = 7;
28
30
 
29
31
  const command_map = new Map([
30
32
  ['if', COMMAND_IF],
@@ -33,6 +35,7 @@ const command_map = new Map([
33
35
  ['endif', COMMAND_ENDIF],
34
36
  ['endunless', COMMAND_ENDUNLESS],
35
37
  ['endfor', COMMAND_ENDFOR],
38
+ ['render', COMMAND_RENDER],
36
39
  ]);
37
40
  const command_map_reverse = new Map(
38
41
  Array.from(command_map.entries())
@@ -172,6 +175,214 @@ class Tokenizer {
172
175
  };
173
176
  }
174
177
 
178
+ /**
179
+ Parses a liquid expression (string, number, or variable).
180
+ Used in {{ }}, command arguments, and other places.
181
+ @returns {Object} Value object with type and data
182
+ */
183
+ parse_liquid_expression() {
184
+ const position = this.position_get();
185
+
186
+ // Skip whitespace
187
+ while (this.index < this.src.length && /\s/.test(this.char_current())) {
188
+ this.char_step();
189
+ }
190
+
191
+ const char = this.char_current();
192
+
193
+ // String literal (single or double quoted)
194
+ if (char === '"' || char === "'") {
195
+ const quote = char;
196
+ this.char_step(); // Skip opening quote
197
+ let value = '';
198
+ while (this.index < this.src.length) {
199
+ const c = this.char_current();
200
+ if (c === quote) {
201
+ this.char_step(); // Skip closing quote
202
+ return {
203
+ type: VALUE_TYPE_STATIC,
204
+ data: value,
205
+ };
206
+ }
207
+ value += c;
208
+ this.char_step();
209
+ }
210
+ error('Unclosed string literal', position);
211
+ }
212
+
213
+ // Number literal (base 10 only)
214
+ if (/[0-9]/.test(char)) {
215
+ let value = '';
216
+ while (this.index < this.src.length && /[0-9.]/.test(this.char_current())) {
217
+ value += this.char_current();
218
+ this.char_step();
219
+ }
220
+ return {
221
+ type: VALUE_TYPE_STATIC,
222
+ data: parseFloat(value),
223
+ };
224
+ }
225
+
226
+ // Variable name or property access (e.g., variable, variable.prop, variable.prop.subprop)
227
+ // Also handles boolean literals (true, false) and nil
228
+ // Only allows valid identifiers separated by dots, no other JavaScript expressions
229
+ if (/[a-zA-Z_$]/.test(char)) {
230
+ let value = '';
231
+ let base_variable = '';
232
+ let first_identifier = true;
233
+
234
+ while (this.index < this.src.length) {
235
+ const c = this.char_current();
236
+
237
+ // Start of an identifier
238
+ if (/[a-zA-Z_$]/.test(c)) {
239
+ let identifier = '';
240
+ while (this.index < this.src.length && /[a-zA-Z0-9_$]/.test(this.char_current())) {
241
+ identifier += this.char_current();
242
+ this.char_step();
243
+ }
244
+ value += identifier;
245
+
246
+ // Track the first identifier as the base variable
247
+ if (first_identifier) {
248
+ base_variable = identifier;
249
+ first_identifier = false;
250
+ }
251
+ }
252
+ // Property access dot
253
+ else if (c === '.') {
254
+ // Peek ahead to ensure there's an identifier after the dot
255
+ if (this.index + 1 < this.src.length && /[a-zA-Z_$]/.test(this.src.charAt(this.index + 1))) {
256
+ value += c;
257
+ this.char_step();
258
+ } else {
259
+ error('Expected property name after dot', position);
260
+ }
261
+ }
262
+ // Stop at delimiters
263
+ else if (/[\s,}%)]/.test(c)) {
264
+ break;
265
+ }
266
+ // Invalid character
267
+ else {
268
+ error(`Invalid character '${c}' in expression`, position);
269
+ }
270
+ }
271
+
272
+ if (!value) {
273
+ error('Expected expression', position);
274
+ }
275
+
276
+ // Check for boolean literals and nil
277
+ if (value === 'true') {
278
+ return {
279
+ type: VALUE_TYPE_STATIC,
280
+ data: true,
281
+ };
282
+ }
283
+ if (value === 'false') {
284
+ return {
285
+ type: VALUE_TYPE_STATIC,
286
+ data: false,
287
+ };
288
+ }
289
+ if (value === 'nil') {
290
+ return {
291
+ type: VALUE_TYPE_STATIC,
292
+ data: null,
293
+ };
294
+ }
295
+
296
+ // Register the base variable (not for keywords)
297
+ if (base_variable) {
298
+ this.variables.set(base_variable, null);
299
+ }
300
+
301
+ return {
302
+ type: VALUE_TYPE_FIELD,
303
+ data: value,
304
+ };
305
+ }
306
+
307
+ error('Expected string, number, or variable', position);
308
+ }
309
+
310
+ /**
311
+ Parses command arguments in the format: arg1, key1: value1, key2: value2
312
+ Used for render and potentially other commands.
313
+ @param {string} args_str - The arguments string
314
+ @returns {Object} Parsed arguments with path and props
315
+ */
316
+ parse_command_arguments(args_str) {
317
+ const position = this.position_get();
318
+ const saved_index = this.index;
319
+ const saved_src = this.src;
320
+
321
+ // Temporarily set src to args_str for parsing
322
+ this.src = args_str;
323
+ this.index = 0;
324
+
325
+ const result = {
326
+ path: null,
327
+ props: {},
328
+ };
329
+
330
+ try {
331
+ // Parse first argument (path)
332
+ result.path = this.parse_liquid_expression();
333
+
334
+ // Skip whitespace and comma
335
+ while (this.index < this.src.length && /[\s,]/.test(this.char_current())) {
336
+ this.char_step();
337
+ }
338
+
339
+ // Parse key-value pairs
340
+ while (this.index < this.src.length) {
341
+ // Skip whitespace
342
+ while (this.index < this.src.length && /\s/.test(this.char_current())) {
343
+ this.char_step();
344
+ }
345
+
346
+ if (this.index >= this.src.length) break;
347
+
348
+ // Parse key
349
+ const key_start = this.index;
350
+ while (this.index < this.src.length && /[a-zA-Z_$0-9]/.test(this.char_current())) {
351
+ this.char_step();
352
+ }
353
+ const key = this.src.slice(key_start, this.index);
354
+
355
+ if (!key) break;
356
+
357
+ // Skip whitespace
358
+ while (this.index < this.src.length && /\s/.test(this.char_current())) {
359
+ this.char_step();
360
+ }
361
+
362
+ // Expect colon
363
+ if (this.char_current() !== ':') {
364
+ error(`Expected ':' after property name '${key}'`, position);
365
+ }
366
+ this.char_step(); // Skip colon
367
+
368
+ // Parse value
369
+ const value = this.parse_liquid_expression();
370
+ result.props[key] = value;
371
+
372
+ // Skip whitespace and comma
373
+ while (this.index < this.src.length && /[\s,]/.test(this.char_current())) {
374
+ this.char_step();
375
+ }
376
+ }
377
+ } finally {
378
+ // Restore original src and index
379
+ this.src = saved_src;
380
+ this.index = saved_index;
381
+ }
382
+
383
+ return result;
384
+ }
385
+
175
386
  /**
176
387
  Expects to be in either top level or inside a block.
177
388
  @returns {Array} Array of tokens
@@ -337,6 +548,21 @@ class Tokenizer {
337
548
  value,
338
549
  };
339
550
  }
551
+ case 'render': {
552
+ // Parse command arguments and store them in the token
553
+ const args = this.parse_command_arguments(value);
554
+
555
+ // Variable tracking is now handled inside parse_liquid_expression()
556
+
557
+ return {
558
+ type: TOKEN_LIQUID,
559
+ ...position,
560
+ trim_before,
561
+ trim_after,
562
+ command: COMMAND_RENDER,
563
+ args, // Store parsed arguments instead of raw string
564
+ };
565
+ }
340
566
  }
341
567
 
342
568
  const command = command_map.get(command_str);
@@ -781,6 +1007,26 @@ function build_nodes(tokens, index, index_end, condition_prefix = '') {
781
1007
  index = conditional.index;
782
1008
  continue;
783
1009
  }
1010
+ case COMMAND_RENDER: {
1011
+ const component_node = build_render_node(token);
1012
+
1013
+ if (condition_prefix) {
1014
+ // Wrap component in conditional
1015
+ nodes.push({
1016
+ type: NODE_TYPE_IF,
1017
+ condition: {
1018
+ type: VALUE_TYPE_FIELD,
1019
+ data: condition_prefix,
1020
+ },
1021
+ child: component_node,
1022
+ });
1023
+ }
1024
+ else {
1025
+ nodes.push(component_node);
1026
+ }
1027
+ index++;
1028
+ continue;
1029
+ }
784
1030
  case COMMAND_ENDIF:
785
1031
  case COMMAND_ENDUNLESS:
786
1032
  case COMMAND_ENDFOR:
@@ -864,8 +1110,8 @@ function build_element_node(tokens, index) {
864
1110
  case TOKEN_HTML_END:
865
1111
  if (token.tag_name === token_start.tag_name) break loop;
866
1112
  case TOKEN_LIQUID:
867
- // apart from loops, everything is allowed in text-only
868
- if (token.command !== COMMAND_FOR) break;
1113
+ // apart from loops and render commands, everything is allowed in text-only
1114
+ if (token.command !== COMMAND_FOR && token.command !== COMMAND_RENDER) break;
869
1115
  case TOKEN_HTML_START:
870
1116
  text_only = false;
871
1117
  break text_extract;
@@ -939,7 +1185,7 @@ function build_children(tokens, index, tag_parent) {
939
1185
  text_only = false;
940
1186
  break;
941
1187
  case TOKEN_LIQUID:
942
- if (token.command === COMMAND_IF || token.command === COMMAND_UNLESS) {
1188
+ if (token.command === COMMAND_IF || token.command === COMMAND_UNLESS || token.command === COMMAND_RENDER) {
943
1189
  text_only = false;
944
1190
  }
945
1191
  break;
@@ -1144,6 +1390,51 @@ function build_value_with_conditionals(tokens, condition_prefix = '') {
1144
1390
  };
1145
1391
  }
1146
1392
 
1393
+ /**
1394
+ Builds a component node from a render command token.
1395
+ @param {Object} token - The render command token with pre-parsed args
1396
+ @returns {Object} Component node
1397
+ */
1398
+ function build_render_node(token) {
1399
+ const { args } = token;
1400
+
1401
+ // Extract path value
1402
+ let path = '';
1403
+ if (args.path.type === VALUE_TYPE_STATIC) {
1404
+ path = args.path.data;
1405
+ } else if (args.path.type === VALUE_TYPE_FIELD) {
1406
+ // If path is a variable, we can't determine the component name at parse time
1407
+ // For now, just use the variable name as component name
1408
+ path = args.path.data;
1409
+ } else {
1410
+ error('Invalid path type in render command', token);
1411
+ }
1412
+
1413
+ // Extract component name from path (only use last part after splitting by '/')
1414
+ const component_name = path.split('/').pop();
1415
+ if (!component_name) error('Invalid component path in render command', token);
1416
+
1417
+ // Format component name to PascalCase (kebab-case to PascalCase)
1418
+ // e.g., 'user-card' -> 'UserCard', 'button' -> 'Button'
1419
+ const component = (
1420
+ component_name
1421
+ .charAt(0).toUpperCase() +
1422
+ component_name.slice(1)
1423
+ .replace(/-([a-z])/g, (_, char) => char.toUpperCase())
1424
+ .replace(/_([a-z])/g, (_, char) => char.toUpperCase())
1425
+ );
1426
+
1427
+ // Props are already parsed, just use them directly
1428
+ const props = args.props;
1429
+
1430
+ return {
1431
+ type: NODE_TYPE_COMPONENT,
1432
+ component,
1433
+ props,
1434
+ children: [],
1435
+ };
1436
+ }
1437
+
1147
1438
  /**
1148
1439
  Builds a value object out of text and expression tokens.
1149
1440
  Trims whitespace from the start and end.
@@ -0,0 +1,21 @@
1
+ <div>
2
+ <!-- Simple render with static values -->
3
+ {% render 'button', label: 'Submit', type: 'primary' %}
4
+
5
+ <!-- Render with variables -->
6
+ {% render 'input', name: fieldName, value: fieldValue %}
7
+
8
+ <!-- Render with complex expressions -->
9
+ {% render 'user-card', name: user.name, age: user.age %}
10
+
11
+ <!-- Render with nested paths (only last part is used) -->
12
+ {% render 'components/ui/icon', name: 'check', size: 24 %}
13
+
14
+ <!-- Render with boolean and number literals -->
15
+ {% render 'checkbox', checked: true, count: 5 %}
16
+
17
+ <!-- Render within conditionals -->
18
+ {% if showProfile %}
19
+ {% render 'profile', user: currentUser %}
20
+ {% endif %}
21
+ </div>
@@ -0,0 +1,6 @@
1
+ <div>
2
+ {% if showButton %}
3
+ {% render 'button', label: buttonLabel %}
4
+ {% endif %}
5
+ {% render 'icon', name: 'check' %}
6
+ </div>
@@ -0,0 +1,3 @@
1
+ <div>
2
+ {% render 'button', label: user.name, count: items.length %}
3
+ </div>
@@ -0,0 +1,7 @@
1
+ <div>
2
+ {% render 'button', label: 'Click me', color: 'blue' %}
3
+ {% render 'components/nested/icon', name: iconName, size: 24 %}
4
+ <p>Some text</p>
5
+ {% render 'link', href: url, text: linkText %}
6
+ {% render 'card', title: 'Test', enabled: true, count: 5 %}
7
+ </div>