htm-transform 0.1.2 → 0.1.4

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/index.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @typedef {Object} ElementNode
3
+ * @property {'element'} type
4
+ * @property {boolean | {type: 'expression', expr: import('acorn').Node}} tag
5
+ * @property {Object<string, string | boolean | {type: 'expression', expr: import('acorn').Node}>} props
6
+ * @property {Array<ElementNode | TextNode | ExpressionNode>} children
7
+ */
8
+ /**
9
+ * @typedef {object} TextNode
10
+ * @property {'text'} type
11
+ * @property {string} value
12
+ */
13
+ /**
14
+ * @typedef {object} ExpressionNode
15
+ * @property {'expression'} type
16
+ * @property {string} value
17
+ * @property {import('acorn').Node} [expr]
18
+ */
19
+ /**
20
+ * @typedef {object} Token
21
+ * @property {'openTag' | 'closeTag' | 'text' | 'expression'} type
22
+ * @property {string} [tag]
23
+ * @property {Record<string, string | boolean>} [props]
24
+ * @property {boolean} [selfClosing]
25
+ * @property {string} [value]
26
+ */
27
+ /**
28
+ * Transforms htm tagged templates into h function calls
29
+ * @param {string} code - JavaScript code containing htm tagged templates
30
+ * @param {object} [options] - Transform options
31
+ * @param {string} [options.pragma] - The h function name (default: 'h')
32
+ * @param {string} [options.tag] - The tag name to look for (default: 'html')
33
+ * @param {object} [options.import] - Import configuration
34
+ * @param {string} options.import.from - Module to import from (e.g., 'preact', 'react')
35
+ * @param {string} options.import.name - Export name to import (e.g., 'h', 'createElement')
36
+ * @returns {string} - Transformed code
37
+ */
38
+ export default function transform(code: string, options?: {
39
+ pragma?: string;
40
+ tag?: string;
41
+ import?: {
42
+ from: string;
43
+ name: string;
44
+ };
45
+ }): string;
46
+ export type ElementNode = {
47
+ type: "element";
48
+ tag: boolean | {
49
+ type: "expression";
50
+ expr: import("acorn").Node;
51
+ };
52
+ props: {
53
+ [x: string]: string | boolean | {
54
+ type: "expression";
55
+ expr: import("acorn").Node;
56
+ };
57
+ };
58
+ children: Array<ElementNode | TextNode | ExpressionNode>;
59
+ };
60
+ export type TextNode = {
61
+ type: "text";
62
+ value: string;
63
+ };
64
+ export type ExpressionNode = {
65
+ type: "expression";
66
+ value: string;
67
+ expr?: import("acorn").Node;
68
+ };
69
+ export type Token = {
70
+ type: "openTag" | "closeTag" | "text" | "expression";
71
+ tag?: string;
72
+ props?: Record<string, string | boolean>;
73
+ selfClosing?: boolean;
74
+ value?: string;
75
+ };
package/index.js CHANGED
@@ -11,23 +11,23 @@ import { simple } from "acorn-walk";
11
11
  */
12
12
 
13
13
  /**
14
- * @typedef {Object} TextNode
14
+ * @typedef {object} TextNode
15
15
  * @property {'text'} type
16
16
  * @property {string} value
17
17
  */
18
18
 
19
19
  /**
20
- * @typedef {Object} ExpressionNode
20
+ * @typedef {object} ExpressionNode
21
21
  * @property {'expression'} type
22
22
  * @property {string} value
23
23
  * @property {import('acorn').Node} [expr]
24
24
  */
25
25
 
26
26
  /**
27
- * @typedef {Object} Token
27
+ * @typedef {object} Token
28
28
  * @property {'openTag' | 'closeTag' | 'text' | 'expression'} type
29
29
  * @property {string} [tag]
30
- * @property {Object<string, string | boolean>} [props]
30
+ * @property {Record<string, string | boolean>} [props]
31
31
  * @property {boolean} [selfClosing]
32
32
  * @property {string} [value]
33
33
  */
@@ -35,10 +35,10 @@ import { simple } from "acorn-walk";
35
35
  /**
36
36
  * Transforms htm tagged templates into h function calls
37
37
  * @param {string} code - JavaScript code containing htm tagged templates
38
- * @param {Object} options - Transform options
39
- * @param {string} options.pragma - The h function name (default: 'h')
40
- * @param {string} options.tag - The tag name to look for (default: 'html')
41
- * @param {Object} options.import - Import configuration
38
+ * @param {object} [options] - Transform options
39
+ * @param {string} [options.pragma] - The h function name (default: 'h')
40
+ * @param {string} [options.tag] - The tag name to look for (default: 'html')
41
+ * @param {object} [options.import] - Import configuration
42
42
  * @param {string} options.import.from - Module to import from (e.g., 'preact', 'react')
43
43
  * @param {string} options.import.name - Export name to import (e.g., 'h', 'createElement')
44
44
  * @returns {string} - Transformed code
@@ -46,22 +46,28 @@ import { simple } from "acorn-walk";
46
46
  export default function transform(code, options = {}) {
47
47
  const { pragma = "h", tag: tagName = "html", import: importConfig } = options;
48
48
 
49
- const ast = acorn.parse(code, {
50
- ecmaVersion: "latest",
51
- sourceType: "module",
52
- });
49
+ const ast = /** @type {import('acorn').Program} */ (
50
+ acorn.parse(code, {
51
+ ecmaVersion: "latest",
52
+ sourceType: "module",
53
+ })
54
+ );
53
55
 
54
56
  let hasTransformation = false;
55
57
 
56
58
  simple(ast, {
59
+ /**
60
+ * @param {import('acorn').TaggedTemplateExpression} node
61
+ */
57
62
  TaggedTemplateExpression(node) {
58
63
  if (node.tag.type === "Identifier" && node.tag.name === tagName) {
59
64
  hasTransformation = true;
60
65
  const transformed = transformTaggedTemplate(node, pragma);
61
66
 
62
67
  // Replace the node with the transformed version
63
- for (const key in node) {
64
- delete node[key];
68
+ const mutableNode = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (node));
69
+ for (const key in mutableNode) {
70
+ delete mutableNode[key];
65
71
  }
66
72
  Object.assign(node, transformed);
67
73
  }
@@ -78,43 +84,73 @@ export default function transform(code, options = {}) {
78
84
 
79
85
  /**
80
86
  * Adds an import declaration at the top of the AST
81
- * @param {import('acorn').Node} ast - The AST to modify
87
+ * @param {import('acorn').Program} ast - The AST to modify
82
88
  * @param {string} moduleName - The module to import from
83
89
  * @param {string} exportName - The export name to import
84
90
  * @returns {void}
85
91
  */
86
92
  function addImportDeclaration(ast, moduleName, exportName) {
87
93
  const hasImport = ast.body.some(
88
- (node) =>
89
- node.type === "ImportDeclaration" &&
90
- node.source.value === moduleName &&
91
- node.specifiers.some(
92
- (spec) => spec.type === "ImportSpecifier" && spec.imported.name === exportName,
93
- ),
94
+ /**
95
+ * @param {import('acorn').Statement | import('acorn').ModuleDeclaration} node
96
+ * @returns {boolean}
97
+ */
98
+ (node) => {
99
+ if (node.type !== "ImportDeclaration") return false;
100
+ const importNode = /** @type {import('acorn').ImportDeclaration} */ (node);
101
+ return (
102
+ importNode.source.value === moduleName &&
103
+ importNode.specifiers.some(
104
+ /**
105
+ * @param {import('acorn').ImportSpecifier | import('acorn').ImportDefaultSpecifier | import('acorn').ImportNamespaceSpecifier} spec
106
+ * @returns {boolean}
107
+ */
108
+ (spec) => {
109
+ if (spec.type !== "ImportSpecifier") return false;
110
+ const importSpec = /** @type {import('acorn').ImportSpecifier} */ (spec);
111
+ return (
112
+ importSpec.imported.type === "Identifier" && importSpec.imported.name === exportName
113
+ );
114
+ },
115
+ )
116
+ );
117
+ },
94
118
  );
95
119
 
96
120
  if (hasImport) return;
97
121
 
98
122
  // Create import declaration: import { exportName } from 'moduleName';
123
+ /** @type {import('acorn').ImportDeclaration} */
99
124
  const importDeclaration = {
100
125
  type: "ImportDeclaration",
126
+ start: 0,
127
+ end: 0,
101
128
  specifiers: [
102
129
  {
103
130
  type: "ImportSpecifier",
131
+ start: 0,
132
+ end: 0,
104
133
  imported: {
105
134
  type: "Identifier",
135
+ start: 0,
136
+ end: 0,
106
137
  name: exportName,
107
138
  },
108
139
  local: {
109
140
  type: "Identifier",
141
+ start: 0,
142
+ end: 0,
110
143
  name: exportName,
111
144
  },
112
145
  },
113
146
  ],
114
147
  source: {
115
148
  type: "Literal",
149
+ start: 0,
150
+ end: 0,
116
151
  value: moduleName,
117
152
  },
153
+ attributes: [],
118
154
  };
119
155
 
120
156
  // Add to the beginning of the body
@@ -123,9 +159,9 @@ function addImportDeclaration(ast, moduleName, exportName) {
123
159
 
124
160
  /**
125
161
  * Transforms a single tagged template expression
126
- * @param {import('acorn').Node} node - The tagged template expression node
162
+ * @param {import('acorn').TaggedTemplateExpression} node - The tagged template expression node
127
163
  * @param {string} pragma - The pragma function name
128
- * @returns {import('acorn').Node} The transformed AST node
164
+ * @returns {import('acorn').Literal | import('acorn').CallExpression | import('acorn').ArrayExpression | import('acorn').Expression} The transformed AST node
129
165
  */
130
166
  function transformTaggedTemplate(node, pragma) {
131
167
  const quasi = node.quasi;
@@ -149,15 +185,21 @@ function transformTaggedTemplate(node, pragma) {
149
185
 
150
186
  // Convert to h() calls
151
187
  if (elements.length === 0) {
152
- return { type: "Literal", value: null };
188
+ /** @type {import('acorn').Literal} */
189
+ const literal = { type: "Literal", start: 0, end: 0, value: null };
190
+ return literal;
153
191
  } else if (elements.length === 1) {
154
192
  return elementsToAST(elements[0], pragma);
155
193
  } else {
156
194
  // Multiple root elements - return array
157
- return {
195
+ /** @type {import('acorn').ArrayExpression} */
196
+ const arrayExpr = {
158
197
  type: "ArrayExpression",
198
+ start: 0,
199
+ end: 0,
159
200
  elements: elements.map((el) => elementsToAST(el, pragma)),
160
201
  };
202
+ return arrayExpr;
161
203
  }
162
204
  }
163
205
 
@@ -185,15 +227,16 @@ function parseTemplate(template, placeholders) {
185
227
 
186
228
  if (token.type === "openTag") {
187
229
  i++;
230
+ /** @type {ElementNode} */
188
231
  const element = {
189
232
  type: "element",
190
- tag: token.tag,
191
- props: token.props,
233
+ tag: token.tag || "",
234
+ props: token.props || {},
192
235
  children: [],
193
236
  };
194
237
 
195
238
  if (!token.selfClosing) {
196
- element.children = parse(token.tag);
239
+ element.children = parse(token.tag || "");
197
240
  }
198
241
 
199
242
  children.push(element);
@@ -204,13 +247,17 @@ function parseTemplate(template, placeholders) {
204
247
  }
205
248
  i++;
206
249
  } else if (token.type === "text") {
207
- const text = token.value.trim();
250
+ const text = (token.value || "").trim();
208
251
  if (text) {
209
- children.push({ type: "text", value: text });
252
+ /** @type {TextNode} */
253
+ const textNode = { type: "text", value: text };
254
+ children.push(textNode);
210
255
  }
211
256
  i++;
212
257
  } else if (token.type === "expression") {
213
- children.push({ type: "expression", value: token.value });
258
+ /** @type {ExpressionNode} */
259
+ const exprNode = { type: "expression", value: token.value || "" };
260
+ children.push(exprNode);
214
261
  i++;
215
262
  } else {
216
263
  i++;
@@ -232,6 +279,7 @@ function parseTemplate(template, placeholders) {
232
279
  * @returns {Array<Token>} Array of tokens
233
280
  */
234
281
  function tokenize(template) {
282
+ /** @type {Token[]} */
235
283
  const tokens = [];
236
284
  let i = 0;
237
285
 
@@ -244,21 +292,27 @@ function tokenize(template) {
244
292
  const [full, isClosing, tagName, attrsStr, selfClosing] = tagMatch;
245
293
 
246
294
  if (isClosing) {
247
- tokens.push({ type: "closeTag", tag: tagName });
295
+ /** @type {Token} */
296
+ const closeToken = { type: "closeTag", tag: tagName };
297
+ tokens.push(closeToken);
248
298
  } else {
249
299
  const props = parseAttributes(attrsStr);
250
- tokens.push({
300
+ /** @type {Token} */
301
+ const openToken = {
251
302
  type: "openTag",
252
303
  tag: tagName,
253
304
  props,
254
305
  selfClosing: selfClosing === "/",
255
- });
306
+ };
307
+ tokens.push(openToken);
256
308
  }
257
309
 
258
310
  i += full.length;
259
311
  } else {
260
312
  // Not a valid tag, treat as text
261
- tokens.push({ type: "text", value: "<" });
313
+ /** @type {Token} */
314
+ const textToken = { type: "text", value: "<" };
315
+ tokens.push(textToken);
262
316
  i++;
263
317
  }
264
318
  } else {
@@ -272,9 +326,13 @@ function tokenize(template) {
272
326
  // Check if this is an expression placeholder
273
327
  const exprMatch = text.match(/^(__EXPR_\d+__)/);
274
328
  if (exprMatch && text === exprMatch[0]) {
275
- tokens.push({ type: "expression", value: exprMatch[0] });
329
+ /** @type {Token} */
330
+ const exprToken = { type: "expression", value: exprMatch[0] };
331
+ tokens.push(exprToken);
276
332
  } else if (text.trim()) {
277
- tokens.push({ type: "text", value: text });
333
+ /** @type {Token} */
334
+ const textToken = { type: "text", value: text };
335
+ tokens.push(textToken);
278
336
  }
279
337
  }
280
338
  }
@@ -288,6 +346,7 @@ function tokenize(template) {
288
346
  * @returns {Object<string, string | boolean>} Parsed attributes object
289
347
  */
290
348
  function parseAttributes(attrsStr) {
349
+ /** @type {Record<string, string | boolean>} */
291
350
  const props = {};
292
351
  const attrRegex = /([a-zA-Z0-9_:-]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
293
352
  let match;
@@ -319,17 +378,70 @@ function resolvePlaceholders(nodes, placeholders) {
319
378
  if (node.type === "element") {
320
379
  // Resolve tag name if it's a placeholder (component)
321
380
  if (typeof node.tag === "string" && node.tag.startsWith("__EXPR_")) {
322
- node.tag = { type: "expression", expr: placeholderMap.get(node.tag) };
381
+ const expr = placeholderMap.get(node.tag);
382
+ if (expr) {
383
+ node.tag = { type: "expression", expr };
384
+ }
323
385
  }
324
386
 
325
387
  // Resolve props, preserving order
388
+ /** @type {Record<string, string | boolean | {type: 'expression', expr: import('acorn').Node}>} */
326
389
  const newProps = {};
327
390
  for (const [key, value] of Object.entries(node.props)) {
328
391
  if (key.startsWith("__EXPR_")) {
329
392
  // Handle spread props (key is the placeholder)
330
- newProps["...spread"] = { type: "expression", expr: placeholderMap.get(key) };
331
- } else if (typeof value === "string" && value.startsWith("__EXPR_")) {
332
- newProps[key] = { type: "expression", expr: placeholderMap.get(value) };
393
+ const expr = placeholderMap.get(key);
394
+ if (expr) {
395
+ newProps["...spread"] = { type: "expression", expr };
396
+ }
397
+ } else if (typeof value === "string" && placeholderMap.has(value)) {
398
+ // Single placeholder value
399
+ const expr = placeholderMap.get(value);
400
+ if (expr) {
401
+ newProps[key] = { type: "expression", expr };
402
+ }
403
+ } else if (typeof value === "string" && value.includes("__EXPR_")) {
404
+ // Template string with placeholders - create TemplateLiteral
405
+ const parts = value.split(/(__EXPR_\d+__)/);
406
+ /** @type {Array<import('acorn').TemplateElement>} */
407
+ const quasis = [];
408
+ /** @type {Array<import('acorn').Expression>} */
409
+ const expressions = [];
410
+
411
+ for (let i = 0; i < parts.length; i++) {
412
+ if (i % 2 === 0) {
413
+ // Static string part
414
+ /** @type {import('acorn').TemplateElement} */
415
+ const templateElement = {
416
+ type: "TemplateElement",
417
+ start: 0,
418
+ end: 0,
419
+ value: { raw: parts[i], cooked: parts[i] },
420
+ tail: i === parts.length - 1,
421
+ };
422
+ quasis.push(templateElement);
423
+ } else {
424
+ // Expression placeholder
425
+ const expr = placeholderMap.get(parts[i]);
426
+ if (expr) {
427
+ expressions.push(/** @type {import('acorn').Expression} */ (expr));
428
+ }
429
+ }
430
+ }
431
+
432
+ /** @type {import('acorn').TemplateLiteral} */
433
+ const templateLiteral = {
434
+ type: "TemplateLiteral",
435
+ start: 0,
436
+ end: 0,
437
+ quasis,
438
+ expressions,
439
+ };
440
+
441
+ newProps[key] = {
442
+ type: "expression",
443
+ expr: templateLiteral,
444
+ };
333
445
  } else {
334
446
  newProps[key] = value;
335
447
  }
@@ -337,7 +449,9 @@ function resolvePlaceholders(nodes, placeholders) {
337
449
  node.props = newProps;
338
450
 
339
451
  // Resolve children
340
- node.children = node.children.map(resolve);
452
+ node.children = /** @type {Array<ElementNode | TextNode | ExpressionNode>} */ (
453
+ node.children.map(resolve).flat()
454
+ );
341
455
  } else if (node.type === "expression") {
342
456
  const expr = placeholderMap.get(node.value);
343
457
  if (expr) {
@@ -347,21 +461,30 @@ function resolvePlaceholders(nodes, placeholders) {
347
461
  // Check if text contains placeholders
348
462
  const parts = node.value.split(/(__EXPR_\d+__)/);
349
463
  if (parts.length > 1) {
350
- return parts
464
+ /** @type {Array<ElementNode | TextNode | ExpressionNode>} */
465
+ const result = parts
351
466
  .filter((p) => p)
352
467
  .map((p) => {
353
468
  if (p.startsWith("__EXPR_")) {
354
- return { type: "expression", expr: placeholderMap.get(p) };
469
+ const expr = placeholderMap.get(p);
470
+ if (expr) {
471
+ /** @type {ExpressionNode} */
472
+ const exprNode = { type: "expression", value: p, expr };
473
+ return exprNode;
474
+ }
355
475
  }
356
- return { type: "text", value: p };
476
+ /** @type {TextNode} */
477
+ const textNode = { type: "text", value: p };
478
+ return textNode;
357
479
  });
480
+ return result;
358
481
  }
359
482
  }
360
483
 
361
484
  return node;
362
485
  }
363
486
 
364
- return nodes.map(resolve).flat();
487
+ return /** @type {Array<ElementNode | TextNode | ExpressionNode>} */ (nodes.map(resolve).flat());
365
488
  }
366
489
 
367
490
  /**
@@ -378,26 +501,36 @@ function needsQuoting(key) {
378
501
  * Converts element nodes to AST nodes representing h() calls
379
502
  * @param {ElementNode | TextNode | ExpressionNode} node - The node to convert
380
503
  * @param {string} pragma - The pragma function name
381
- * @returns {import('acorn').Node} The AST node
504
+ * @returns {import('acorn').Literal | import('acorn').CallExpression | import('acorn').Expression} The AST node
382
505
  */
383
506
  function elementsToAST(node, pragma) {
384
507
  if (node.type === "text") {
385
- return { type: "Literal", value: node.value };
508
+ /** @type {import('acorn').Literal} */
509
+ const literal = { type: "Literal", start: 0, end: 0, value: node.value };
510
+ return literal;
386
511
  }
387
512
 
388
513
  if (node.type === "expression") {
389
- return node.expr;
514
+ return /** @type {import('acorn').Expression} */ (node.expr);
390
515
  }
391
516
 
392
517
  if (node.type === "element") {
393
518
  // Build h(tag, props, ...children)
519
+ /** @type {Array<import('acorn').Expression | import('acorn').SpreadElement>} */
394
520
  const args = [];
395
521
 
396
522
  // Tag argument
397
- if (node.tag.type === "expression") {
398
- args.push(node.tag.expr);
523
+ if (typeof node.tag === "object" && node.tag.type === "expression") {
524
+ args.push(/** @type {import('acorn').Expression} */ (node.tag.expr));
399
525
  } else {
400
- args.push({ type: "Literal", value: node.tag });
526
+ /** @type {import('acorn').Literal} */
527
+ const tagLiteral = {
528
+ type: "Literal",
529
+ start: 0,
530
+ end: 0,
531
+ value: /** @type {string} */ (node.tag),
532
+ };
533
+ args.push(tagLiteral);
401
534
  }
402
535
 
403
536
  // Props argument
@@ -405,54 +538,122 @@ function elementsToAST(node, pragma) {
405
538
  if (propsEntries.length > 0) {
406
539
  const spreadProp = node.props["...spread"];
407
540
 
408
- if (spreadProp && propsEntries.length === 1) {
541
+ if (
542
+ spreadProp &&
543
+ typeof spreadProp === "object" &&
544
+ spreadProp.type === "expression" &&
545
+ propsEntries.length === 1
546
+ ) {
409
547
  // Just spread props
410
- args.push(spreadProp.expr);
411
- } else if (spreadProp) {
548
+ args.push(/** @type {import('acorn').Expression} */ (spreadProp.expr));
549
+ } else if (spreadProp && typeof spreadProp === "object" && spreadProp.type === "expression") {
412
550
  // Merge spread with other props, preserving order
551
+ /** @type {Array<import('acorn').Property | import('acorn').SpreadElement>} */
413
552
  const properties = propsEntries.map(([key, value]) => {
414
- if (key === "...spread") {
415
- return {
553
+ if (key === "...spread" && typeof value === "object" && value.type === "expression") {
554
+ /** @type {import('acorn').SpreadElement} */
555
+ const spread = {
416
556
  type: "SpreadElement",
417
- argument: value.expr,
557
+ start: 0,
558
+ end: 0,
559
+ argument: /** @type {import('acorn').Expression} */ (value.expr),
418
560
  };
561
+ return spread;
419
562
  }
420
- return {
563
+ /** @type {import('acorn').Property} */
564
+ const prop = {
421
565
  type: "Property",
566
+ start: 0,
567
+ end: 0,
422
568
  key: needsQuoting(key)
423
- ? { type: "Literal", value: key }
424
- : { type: "Identifier", name: key },
425
- value: value.type === "expression" ? value.expr : { type: "Literal", value },
569
+ ? /** @type {import('acorn').Literal} */ ({
570
+ type: "Literal",
571
+ start: 0,
572
+ end: 0,
573
+ value: key,
574
+ })
575
+ : /** @type {import('acorn').Identifier} */ ({
576
+ type: "Identifier",
577
+ start: 0,
578
+ end: 0,
579
+ name: key,
580
+ }),
581
+ value:
582
+ typeof value === "object" && value.type === "expression"
583
+ ? /** @type {import('acorn').Expression} */ (value.expr)
584
+ : /** @type {import('acorn').Literal} */ ({
585
+ type: "Literal",
586
+ start: 0,
587
+ end: 0,
588
+ value: /** @type {string | boolean} */ (value),
589
+ }),
426
590
  kind: "init",
427
591
  method: false,
428
592
  shorthand: false,
429
593
  computed: false,
430
594
  };
595
+ return prop;
431
596
  });
432
597
 
433
- args.push({
598
+ /** @type {import('acorn').ObjectExpression} */
599
+ const objExpr = {
434
600
  type: "ObjectExpression",
601
+ start: 0,
602
+ end: 0,
435
603
  properties,
436
- });
604
+ };
605
+ args.push(objExpr);
437
606
  } else {
438
607
  // Normal props
439
- args.push({
440
- type: "ObjectExpression",
441
- properties: propsEntries.map(([key, value]) => ({
608
+ /** @type {Array<import('acorn').Property | import('acorn').SpreadElement>} */
609
+ const properties = propsEntries.map(([key, value]) => {
610
+ /** @type {import('acorn').Property} */
611
+ const prop = {
442
612
  type: "Property",
613
+ start: 0,
614
+ end: 0,
443
615
  key: needsQuoting(key)
444
- ? { type: "Literal", value: key }
445
- : { type: "Identifier", name: key },
446
- value: value.type === "expression" ? value.expr : { type: "Literal", value },
616
+ ? /** @type {import('acorn').Literal} */ ({
617
+ type: "Literal",
618
+ start: 0,
619
+ end: 0,
620
+ value: key,
621
+ })
622
+ : /** @type {import('acorn').Identifier} */ ({
623
+ type: "Identifier",
624
+ start: 0,
625
+ end: 0,
626
+ name: key,
627
+ }),
628
+ value:
629
+ typeof value === "object" && value.type === "expression"
630
+ ? /** @type {import('acorn').Expression} */ (value.expr)
631
+ : /** @type {import('acorn').Literal} */ ({
632
+ type: "Literal",
633
+ start: 0,
634
+ end: 0,
635
+ value: /** @type {string | boolean} */ (value),
636
+ }),
447
637
  kind: "init",
448
638
  method: false,
449
639
  shorthand: false,
450
640
  computed: false,
451
- })),
641
+ };
642
+ return prop;
452
643
  });
644
+ /** @type {import('acorn').ObjectExpression} */
645
+ const objExpr = {
646
+ type: "ObjectExpression",
647
+ start: 0,
648
+ end: 0,
649
+ properties,
650
+ };
651
+ args.push(objExpr);
453
652
  }
454
653
  } else {
455
- args.push({ type: "Literal", value: null });
654
+ /** @type {import('acorn').Literal} */
655
+ const nullLiteral = { type: "Literal", start: 0, end: 0, value: null };
656
+ args.push(nullLiteral);
456
657
  }
457
658
 
458
659
  // Children arguments
@@ -460,12 +661,24 @@ function elementsToAST(node, pragma) {
460
661
  args.push(elementsToAST(child, pragma));
461
662
  }
462
663
 
463
- return {
664
+ /** @type {import('acorn').CallExpression} */
665
+ const callExpr = {
464
666
  type: "CallExpression",
465
- callee: { type: "Identifier", name: pragma },
667
+ start: 0,
668
+ end: 0,
669
+ callee: /** @type {import('acorn').Identifier} */ ({
670
+ type: "Identifier",
671
+ start: 0,
672
+ end: 0,
673
+ name: pragma,
674
+ }),
466
675
  arguments: args,
676
+ optional: false,
467
677
  };
678
+ return callExpr;
468
679
  }
469
680
 
470
- return { type: "Literal", value: null };
681
+ /** @type {import('acorn').Literal} */
682
+ const nullLiteral = { type: "Literal", start: 0, end: 0, value: null };
683
+ return nullLiteral;
471
684
  }
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "htm-transform",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Transform htm tagged templates into h function calls using acorn",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
- "types": "index.js",
7
+ "types": "index.d.ts",
8
8
  "exports": {
9
9
  ".": {
10
- "types": "./index.js",
10
+ "types": "./index.d.ts",
11
11
  "default": "./index.js"
12
12
  }
13
13
  },
14
14
  "scripts": {
15
15
  "test": "node --test test.js",
16
- "test:watch": "node --test --watch test.js"
16
+ "dts": "tsc"
17
17
  },
18
18
  "keywords": [
19
19
  "htm",
@@ -28,5 +28,8 @@
28
28
  "acorn": "^8.11.3",
29
29
  "acorn-walk": "^8.3.2",
30
30
  "astring": "^1.8.6"
31
+ },
32
+ "devDependencies": {
33
+ "typescript": "^5.9.3"
31
34
  }
32
35
  }
package/test.js CHANGED
@@ -380,4 +380,76 @@ const result = myH("div", null, "Test");`;
380
380
 
381
381
  assert.strictEqual(normalize(output), normalize(expected));
382
382
  });
383
+
384
+ test("handles template literals in attribute values", () => {
385
+ const input = `const result = html\`<div class="\${css.foo} \${css.bar}">Test</div>\`;`;
386
+ const output = transform(input);
387
+ const expected = `const result = h("div", {
388
+ class: \`\${css.foo} \${css.bar}\`
389
+ }, "Test");`;
390
+
391
+ assert.strictEqual(normalize(output), normalize(expected));
392
+ });
393
+
394
+ test("todo", () => {
395
+ const input = `import html from "~/util/preact/html.js";
396
+ /** @import { JSX } from "preact" */
397
+
398
+ import button from "~/style/button.js";
399
+ import Icon from "./Icon.js";
400
+
401
+ import { adopt } from "~/util/css.js";
402
+ import sheet from "./Button.module.css" with { type: "css" };
403
+ const css = adopt(sheet);
404
+
405
+ /**
406
+ * @typedef {object} Props
407
+ * @property {"primary" | "secondary" | "tertiary"} [level = "secondary"]
408
+ * @property {"neutral" | "positive" | "negative"} [kind = "neutral"]
409
+ * @property {"small" | "base" | "large"} [size = "base"]
410
+ * @property {boolean} [loading]
411
+ * @property {string} [popovertarget]
412
+ * @property {(el: HTMLButtonElement) => void} [innerRef]
413
+ */
414
+
415
+ /** @param {Props & JSX.ButtonHTMLAttributes<HTMLButtonElement>} props */
416
+ export default function Button(props) {
417
+ const {
418
+ level,
419
+ kind = "neutral",
420
+ size = "base",
421
+ loading,
422
+ disabled,
423
+ type = "button",
424
+ children,
425
+ innerRef: ref,
426
+ ...rest
427
+ } = props;
428
+
429
+ return html\`
430
+ <button
431
+ class="\${css.wrapper} \${button.button}"
432
+ data-level=\${level}
433
+ data-kind=\${kind}
434
+ data-size=\${size}
435
+ type=\${type}
436
+ disabled=\${disabled || loading}
437
+ ref=\${ref}
438
+ ...\${rest}
439
+ >
440
+ <span class="\${css.layer} \${css.contents}">\${children}</span>
441
+ \${loading
442
+ ? html\`
443
+ <span class="\${css.layer} \${css.loader}">
444
+ <\${Icon} class="\${css.loader}" name="loader" />
445
+ </span>
446
+ \`
447
+ : null}
448
+ </button>
449
+ \`;
450
+ }
451
+ `;
452
+
453
+ const output = transform(input);
454
+ });
383
455
  });
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "esnext",
4
+ "moduleResolution": "bundler",
5
+ "target": "es2022",
6
+ "lib": ["es2022"],
7
+ "declaration": true,
8
+ "emitDeclarationOnly": true,
9
+ "allowJs": true,
10
+ "checkJs": true,
11
+ "skipLibCheck": true,
12
+ },
13
+ "include": ["index.js"],
14
+ "exclude": ["node_modules", "test.js"],
15
+ }
package/jsconfig.json DELETED
@@ -1,12 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "module": "ESNext",
4
- "moduleResolution": "bundler",
5
- "target": "ES2022",
6
- "checkJs": true,
7
- "strict": true,
8
- "lib": ["ES2022"]
9
- },
10
- "include": ["index.js", "test.js"],
11
- "exclude": ["node_modules"]
12
- }