lui-templates 0.0.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,7 @@ Here are some templating languages I think about supporting:
13
13
  - [x] [Liquid](https://shopify.github.io/liquid/) (proof of concept)
14
14
  - [ ] [Handlebars](https://handlebarsjs.com/)
15
15
  - [ ] [Knockout](https://knockoutjs.com/documentation/introduction.html)
16
+ - [x] Plain HTML (for static templates without dynamic content)
16
17
  - [x] Raw JSON (as intermediary format, see test/templates/greeting.json)
17
18
 
18
19
  ## Usage
@@ -21,6 +22,10 @@ Here are some templating languages I think about supporting:
21
22
  npx lui-templates src/templates/greeting.liquid > src/components/greeting.js
22
23
  ```
23
24
 
25
+ ```sh
26
+ npx lui-templates src/templates/simple.html > src/components/simple.js
27
+ ```
28
+
24
29
  ```sh
25
30
  npx lui-templates --help
26
31
  ```
@@ -52,8 +57,8 @@ init(() => {
52
57
  ```js
53
58
  import lui_templates from 'lui-templates';
54
59
 
55
- const code = await lui_templates('src/templates/Greeting.liquid');
56
- await fs.writeFile('src/generated/Greeting.js', code, 'utf8');
60
+ const code = await lui_templates('src/templates/greeting.liquid');
61
+ await fs.writeFile('src/components/greeting.js', code, 'utf8');
57
62
 
58
63
  await bundleApp('src/main.js'); // or whatever
59
64
  ```
@@ -80,6 +85,41 @@ You may have it in your `.gitignore` to prevent duplication.
80
85
 
81
86
  ... And did I mention that this file is generated? 🎉
82
87
 
88
+ ## HTML Example
89
+
90
+ For static templates without dynamic content, you can use plain HTML:
91
+
92
+ ### `src/templates/simple.html`
93
+
94
+ ```html
95
+ <div>
96
+ <h1>Hello World</h1>
97
+ <p>This is a simple HTML test.</p>
98
+ </div>
99
+ ```
100
+
101
+ ### Generated `src/components/simple.js`
102
+
103
+ ```js
104
+ // generated by lui-templates
105
+
106
+ import {
107
+ hook_dom,
108
+ node_dom,
109
+ } from "lui";
110
+
111
+ export default function Simple() {
112
+ hook_dom("div");
113
+
114
+ return [
115
+ node_dom("h1[innerText=Hello World]"),
116
+ node_dom("p[innerText=This is a simple HTML test.]"),
117
+ ];
118
+ }
119
+ ```
120
+
121
+ The HTML parser supports all standard HTML elements and attributes but does not include dynamic content like variables or conditionals. For dynamic content, use the Liquid parser instead.
122
+
83
123
  ## Interface of `lui_templates(path[, {options}])`
84
124
 
85
125
  ### `path`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lui-templates",
3
- "version": "0.0.6",
3
+ "version": "0.2.0",
4
4
  "description": "transform html templates into lui components",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli.js CHANGED
@@ -13,7 +13,9 @@ if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
13
13
  Input: The template file or directory containing the templates
14
14
  Options:
15
15
  -e, --externs <path> File unknown components are imported from
16
+ -g, --global Assume window.lui is defined
16
17
  -h, --help Show this help message
18
+ --ssr '{"prop": ...}' Render to html using lui-ssr (must be installed)
17
19
  --version Show version information
18
20
 
19
21
  Example:
@@ -38,6 +40,8 @@ if (args[0].startsWith('-')) {
38
40
 
39
41
  const path = args.shift();
40
42
  let arg_externs = './externs.js';
43
+ let arg_global = false;
44
+ let arg_ssr = null;
41
45
 
42
46
  while (args.length > 0) {
43
47
  const arg = args.shift();
@@ -46,14 +50,35 @@ while (args.length > 0) {
46
50
  case '--externs':
47
51
  arg_externs = args.shift();
48
52
  break;
53
+ case '--ssr':
54
+ arg_ssr = JSON.parse(args.shift());
55
+ case '-g':
56
+ case '--global':
57
+ arg_global = true;
58
+ break;
49
59
  default:
50
60
  console.error(`Unknown argument: ${arg}, see --help`);
51
61
  process.exit(1);
52
62
  }
53
63
  }
54
64
 
55
- const result = await lui_templates(path, {
65
+ let result = await lui_templates(path, {
56
66
  components_name: arg_externs,
67
+ lui_global: arg_global,
57
68
  });
58
69
 
70
+ if (arg_ssr) {
71
+ const {default: lui_ssr} = await import('lui-ssr');
72
+ const name = result.match(/function\s+(\w+)\s*\(/)?.[1];
73
+
74
+ result += `
75
+ lui.init(() => {
76
+ return [
77
+ lui.node(${name}, ${JSON.stringify(arg_ssr)}),
78
+ ];
79
+ });`;
80
+
81
+ result = lui_ssr(result)();
82
+ }
83
+
59
84
  console.log(result);
package/src/main.js CHANGED
@@ -21,6 +21,7 @@ const version = JSON.parse(
21
21
 
22
22
  const options_default = {
23
23
  lui_name: 'lui',
24
+ lui_global: false,
24
25
  components_name: './externs.js',
25
26
  };
26
27
 
@@ -31,6 +32,11 @@ export default async function lui_templates(path, options = {}) {
31
32
  };
32
33
  const is_directory = (await stat(path)).isDirectory();
33
34
 
35
+ if (
36
+ is_directory &&
37
+ options.lui_global
38
+ ) throw new Error('with global setting, only a single template is allowed');
39
+
34
40
  const paths = is_directory
35
41
  ? (await readdir(path)).map(name => `${path}/${name}`)
36
42
  : [path];
@@ -44,29 +50,37 @@ export default async function lui_templates(path, options = {}) {
44
50
  const result = [];
45
51
 
46
52
  for (const [name, parsed] of all_parsed) {
47
- const expression = generate(name, parsed, lui_imports, component_imports);
53
+ let expression = generate(name, parsed, lui_imports, component_imports);
54
+
55
+ if (!options.lui_global) {
56
+ if (!is_directory) expression = 'default ' + expression;
57
+ expression = 'export ' + expression;
58
+ }
48
59
 
49
- result.push(
50
- 'export ' +
51
- (is_directory ? '' : 'default ') +
52
- expression
53
- );
60
+ result.push(expression);
54
61
  }
55
62
 
56
63
  // write component_imports to the top of the file
57
64
  for (const [name] of all_parsed) {
58
65
  component_imports.delete(name);
59
66
  }
60
- if (component_imports.size > 0) {
67
+ if (component_imports.size > 0 && !options.lui_global) {
61
68
  result.unshift(`import {${
62
69
  list_generate([...component_imports], 0)
63
70
  }} from ${JSON.stringify(options.components_name)};`);
64
71
  }
65
72
 
66
73
  // write lui_imports to the top of the file
67
- result.unshift(`import {${
68
- list_generate([...lui_imports], 0)
69
- }} from ${JSON.stringify(options.lui_name)};`);
74
+ if (options.lui_global) {
75
+ result.unshift(`const {${
76
+ list_generate([...lui_imports], 0)
77
+ }} = ${options.lui_name};`);
78
+ }
79
+ else {
80
+ result.unshift(`import {${
81
+ list_generate([...lui_imports], 0)
82
+ }} from ${JSON.stringify(options.lui_name)};`);
83
+ }
70
84
 
71
85
  result.unshift('// generated by lui-templates ' + version);
72
86
 
package/src/parser.js CHANGED
@@ -47,7 +47,7 @@ export function html_attr_to_dom(attr) {
47
47
  return html_attr_to_dom_map.get(attr) || attr;
48
48
  }
49
49
 
50
- const html_whitespaces = ' \n\r\t\f\v'.split('');
50
+ export const html_whitespaces = ' \n\r\t\f\v'.split('');
51
51
  export const html_is_whitespace = char => html_whitespaces.includes(char);
52
52
 
53
53
  const html_self_closing = 'img,input,br,hr'.split(',');
@@ -0,0 +1,345 @@
1
+ import {
2
+ NODE_TYPE_ELEMENT,
3
+ VALUE_TYPE_STATIC,
4
+ } from '../constants.js';
5
+ import {
6
+ html_is_self_closing,
7
+ html_is_whitespace,
8
+ html_attr_to_dom,
9
+ html_whitespaces,
10
+ } from '../parser.js';
11
+
12
+ const TOKEN_HTML_START = 0;
13
+ const TOKEN_HTML_END = 1;
14
+ const TOKEN_TEXT = 2;
15
+
16
+ export default async function parse_html(src, path) {
17
+ const tokenizer = new Tokenizer(src, path);
18
+ const tokens = tokenizer.parse_nodes();
19
+ const nodes = build_nodes(tokens, 0, tokens.length);
20
+
21
+ return {
22
+ inputs: [],
23
+ transformations: [],
24
+ effects: [],
25
+ nodes,
26
+ };
27
+ }
28
+
29
+ function error(message, obj) {
30
+ console.error(`Error in ${obj.path}:${obj.line}:${obj.column}: ${message}`);
31
+ process.exit(1);
32
+ }
33
+
34
+ class Tokenizer {
35
+ constructor(src, path) {
36
+ this.src = src;
37
+ this.path = path;
38
+ this.line = 1;
39
+ this.column = 0;
40
+ this.index = 0;
41
+ }
42
+
43
+ char_current() {
44
+ return this.src.charAt(this.index);
45
+ }
46
+
47
+ chars_match(chars) {
48
+ return this.src.slice(this.index, this.index + chars.length) === chars;
49
+ }
50
+
51
+ char_step() {
52
+ const char = this.char_current();
53
+ if (char == null) error('Unexpected end of input', this);
54
+ this.index++;
55
+ if (char === '\n') {
56
+ this.line++;
57
+ this.column = 0;
58
+ }
59
+ else this.column++;
60
+ }
61
+
62
+ chars_step(n) {
63
+ for (let i = 0; i < n; i++) {
64
+ this.char_step();
65
+ }
66
+ }
67
+
68
+ chars_consume(chars) {
69
+ if (!this.chars_match(chars)) error(`Expected "${chars}"`, this);
70
+ this.chars_step(chars.length);
71
+ }
72
+
73
+ chars_consume_until(limit, desc) {
74
+ const index_end = this.src.indexOf(limit, this.index);
75
+ if (index_end === -1) error(`Unclosed ${desc}`, this);
76
+ const value = this.src.slice(this.index, index_end);
77
+ this.chars_step(index_end - this.index);
78
+ return value;
79
+ }
80
+
81
+ chars_consume_until_one(limits, desc) {
82
+ let value = '';
83
+ while (this.index < this.src.length) {
84
+ const char = this.char_current();
85
+ if (limits.includes(char)) return value;
86
+ value += char;
87
+ this.char_step();
88
+ }
89
+ error(`Unclosed ${desc}`, this);
90
+ }
91
+
92
+ chars_skip_whitespace() {
93
+ while (
94
+ this.index < this.src.length &&
95
+ html_is_whitespace(this.char_current())
96
+ ) {
97
+ this.char_step();
98
+ }
99
+ }
100
+
101
+ parse_nodes() {
102
+ const tokens = [];
103
+
104
+ while (this.index < this.src.length) {
105
+ const char = this.char_current();
106
+ const position = {
107
+ path: this.path,
108
+ line: this.line,
109
+ column: this.column,
110
+ };
111
+ // text nodes
112
+ if (char !== '<') {
113
+ // we do this manually since text can also end at EOF
114
+ const index_lt = this.src.indexOf('<', this.index);
115
+ const value = this.src.slice(
116
+ this.index,
117
+ index_lt > -1
118
+ ? index_lt
119
+ : this.src.length
120
+ );
121
+ this.chars_step(value.length);
122
+ tokens.push({
123
+ type: TOKEN_TEXT,
124
+ ...position,
125
+ value,
126
+ });
127
+ }
128
+ // comment
129
+ else if (this.chars_match('<!--')) {
130
+ this.chars_consume_until('-->', 'comment');
131
+ }
132
+ // end tag
133
+ else if (this.chars_match('</')) {
134
+ this.chars_consume('</');
135
+
136
+ const tag_name = this.chars_consume_until('>', 'end tag');
137
+ if (!tag_name) error('empty end tag', position);
138
+ if (tag_name !== tag_name.trim()) error('spaces in end tag', position);
139
+
140
+ this.chars_consume('>');
141
+
142
+ tokens.push({
143
+ type: TOKEN_HTML_END,
144
+ ...position,
145
+ tag_name,
146
+ });
147
+ }
148
+ // start tag
149
+ else {
150
+ tokens.push(
151
+ ...this.parse_html_start(position)
152
+ );
153
+ }
154
+ }
155
+
156
+ return tokens;
157
+ }
158
+
159
+ parse_html_start(position) {
160
+ this.chars_consume('<');
161
+ const tag_name = this.chars_consume_until_one(
162
+ ['/', '>', ...html_whitespaces],
163
+ 'tag'
164
+ );
165
+ if (!tag_name) error('empty start tag or spaces before tag name', position);
166
+
167
+ const props = this.parse_attributes();
168
+
169
+ this.chars_skip_whitespace();
170
+ const has_self_closing_slash = this.char_current() === '/';
171
+ if (has_self_closing_slash) {
172
+ this.chars_consume('/');
173
+ }
174
+
175
+ this.chars_consume('>');
176
+
177
+ const start_token = {
178
+ type: TOKEN_HTML_START,
179
+ ...position,
180
+ tag_name,
181
+ props,
182
+ };
183
+
184
+ if (
185
+ has_self_closing_slash ||
186
+ html_is_self_closing(tag_name)
187
+ ) {
188
+ return [
189
+ start_token,
190
+ {
191
+ type: TOKEN_HTML_END,
192
+ ...position,
193
+ tag_name,
194
+ }
195
+ ];
196
+ }
197
+
198
+ return [start_token];
199
+ }
200
+
201
+ parse_attributes() {
202
+ const props = {};
203
+
204
+ while (this.index < this.src.length) {
205
+ this.chars_skip_whitespace();
206
+
207
+ if (['/', '>'].includes(this.char_current())) return props;
208
+
209
+ const name = this.chars_consume_until_one(
210
+ ['=', '/', '>', ...html_whitespaces],
211
+ 'attribute name'
212
+ );
213
+
214
+ if (!name) error('attribute name missing', this);
215
+
216
+ this.chars_skip_whitespace();
217
+
218
+ let value = true;
219
+ if (this.char_current() === '=') {
220
+ this.chars_consume('=');
221
+
222
+ this.chars_skip_whitespace();
223
+
224
+ const quote = this.char_current();
225
+ if (quote === '"' || quote === "'") {
226
+ this.chars_consume(quote);
227
+ value = this.chars_consume_until(quote, 'attribute value');
228
+ this.chars_consume(quote);
229
+ }
230
+ else {
231
+ value = this.chars_consume_until_one(
232
+ ['/', '>', ...html_whitespaces],
233
+ 'attribute value'
234
+ );
235
+ }
236
+ }
237
+
238
+ props[html_attr_to_dom(name)] = {
239
+ type: VALUE_TYPE_STATIC,
240
+ data: value,
241
+ };
242
+ }
243
+
244
+ error('Unclosed tag', this);
245
+ }
246
+ }
247
+
248
+ function build_nodes(tokens, index, index_end) {
249
+ const nodes = [];
250
+ for (; index < index_end; index++) {
251
+ const token = tokens[index];
252
+ switch (token.type) {
253
+ case TOKEN_HTML_START: {
254
+ const {tag_name, props} = token;
255
+ const index_start = ++index;
256
+
257
+ // find matching end tag index
258
+ let depth = 1;
259
+ loop: for (; index < index_end; index++) {
260
+ const token = tokens[index];
261
+ switch (token.type) {
262
+ case TOKEN_HTML_START:
263
+ if (token.tag_name === tag_name) depth++;
264
+ break;
265
+ case TOKEN_HTML_END:
266
+ if (
267
+ token.tag_name === tag_name &&
268
+ --depth === 0
269
+ ) {
270
+ break loop;
271
+ }
272
+ }
273
+ }
274
+ if (depth > 0) error(`Unclosed tag <${tag_name}>`, token);
275
+
276
+ const children = build_nodes(tokens, index_start, index);
277
+
278
+ if (
279
+ children.length > 0 &&
280
+ children[0].is_wrapper
281
+ ) {
282
+ props.innerText = children.shift().props.innerText;
283
+ }
284
+
285
+ nodes.push({
286
+ is_wrapper: false,
287
+ type: NODE_TYPE_ELEMENT,
288
+ tag: tag_name,
289
+ props,
290
+ children,
291
+ });
292
+ break;
293
+ }
294
+ case TOKEN_HTML_END:
295
+ error(`Unexpected closing tag </${token.tag_name}>`, token);
296
+ case TOKEN_TEXT: {
297
+ let {value} = token;
298
+ if (!value) break;
299
+
300
+ const value_trimmed_start = value.trimStart();
301
+ // if first node
302
+ if (nodes.length === 0) {
303
+ if (!value_trimmed_start) break;
304
+ value = value_trimmed_start;
305
+ }
306
+ // if between other nodes and empty
307
+ else if (
308
+ index_end - index > 1 &&
309
+ !value_trimmed_start
310
+ ) {
311
+ break;
312
+ }
313
+ // if not first but has leading whitespace
314
+ else if (value !== value_trimmed_start) {
315
+ value = ' ' + value_trimmed_start;
316
+ }
317
+
318
+ const value_trimmed_end = value.trimEnd();
319
+ // if last node
320
+ if (index_end - index === 1) {
321
+ if (!value_trimmed_end) break;
322
+ value = value_trimmed_end;
323
+ }
324
+ // if not last but has trailing whitespace
325
+ else if (value !== value_trimmed_end) {
326
+ value = value_trimmed_end + ' ';
327
+ }
328
+
329
+ nodes.push({
330
+ is_wrapper: true,
331
+ type: NODE_TYPE_ELEMENT,
332
+ tag: 'span',
333
+ props: {
334
+ innerText: {
335
+ type: VALUE_TYPE_STATIC,
336
+ data: value,
337
+ },
338
+ },
339
+ children: [],
340
+ });
341
+ }
342
+ }
343
+ }
344
+ return nodes;
345
+ }